Vishwa CTF 2024 に参加してきました。
Rev は 1 問しかなかったので、こちらの Writeup を書きます。(かなりしんどかった)
もくじ
Your Bonus(Rev)
I am very kind, and you’re my friend too. I was about to share some flags with you, but unfortunately, a ransomware attack occurred on the file containing those flags. All the flags got encrypted by the ransomware. After cross-checking the directories, I found the ransomware file and some other related items.
I’m going to share that information with you. However, due to the ransomware, I’m unable to provide you with the flags 😥😥. Now, I need your help to recover those flags. Can you assist me, please? Your cooperation would be highly appreciated, and you will receive a reward for your help.
Note : Ransomware are not meant to be executed as it can harm your systems (although this won’t)
問題バイナリをデコンパイルすると、Flags.txt を開いた後以下のループ処理を行うことがわかります。
while( true ) {
piVar4 = (int *)std::getline<>(local_1a0,local_1b8);
bVar1 = std::basic_ios::operator.cast.to.bool
((basic_ios *)((int)piVar4 + *(int *)(*piVar4 + -0xc)));
if (!bVar1) break;
std::__cxx11::basic_string<>::basic_string(local_1b8);
std::__cxx11::basic_string<>::basic_string(local_a0);
pTextLine = &stack0xfffffe28;
devil_function();
std::__cxx11::basic_string<>::~basic_string(local_6c);
std::__cxx11::basic_string<>::length();
pTextLine = &stack0xfffffe28;
zarathos(&stack0xfffffe10,(basic_string *)pTextLine);
std::__cxx11::basic_string<>::basic_string((basic_string *)&stack0xfffffe28);
pTextLine = (char *)local_14;
local_24 = Lucifer(local_54,local_14);
std::__cxx11::basic_string<>::~basic_string(local_54);
ghost_ridders_wepon();
pTextLine = (char *)local_14;
matter_manipulation[abi:cxx11]((basic_string<> *)&stack0xfffffdf8,local_14);
std::__cxx11::basic_string<>::basic_string((basic_string *)&stack0xfffffdf8);
Trigon(local_3c);
std::__cxx11::basic_string<>::~basic_string((basic_string<> *)local_3c);
local_14 = local_14 + 1;
std::__cxx11::basic_string<>::~basic_string((basic_string<> *)&stack0xfffffdf8);
std::__cxx11::basic_string<>::~basic_string((basic_string<> *)&stack0xfffffe10);
std::__cxx11::basic_string<>::~basic_string((basic_string<> *)&stack0xfffffe28);
}
この中では、各行の文字列を取得した後、devilfunction、Lucifer、ghostridderswepon、mattermanipulation、Trigon という関数が順に呼び出され、最終的にファイルに書き込む暗号化文字列を生成します。
devil_function 関数
最初の devil_function 関数はどうやら何もしていないようなので無視します。
文字列の長さを取得
続く以下の箇所では、どうやら取得した行の文字列の長さを取得しているようです。
std::__cxx11::basic_string<>::~basic_string(local_6c);
std::__cxx11::basic_string<>::length();
pTextLine = &stack0xfffffe28;
basic_string の length() を実行した直後には、その行の文字列の長さが eax レジスタに返却されます。
ここで取得した文字列の長さは [esp+0x8] のスタックに格納され、edx レジスタには [ebp-0x1D0] から取得した行の文字列が格納されてるアドレスのポインタが返されます。
このポインタはさらにスタックの [esp+4] に配置され、最後にスタックトップに [ebp-0x1E8] から取得したよくわからないバイト領域のポインタ(初期値は未定義)が格納されます。
zarathos 関数
zarathos 関数は、スタックに以下の値を積んだ状態で呼び出されます。
- よくわからないバイト領域のポインタ
- ファイルから取得した行の文字列が格納されているアドレスのポインタ
- 文字列の長さ
Ghidra のデコンパイラだと引数を上手く解釈できてなさそうでしたのでそこだけ若干修正していますが、解析結果は以下のようになりました。
/* zarathos(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&, int)
*/
undefined * __cdecl zarathos(undefined *param_1,basic_string *param_2,int param_3)
{
undefined *puVar1;
int iVar2;
undefined4 uVar3;
undefined4 uVar4;
time_t tVar5;
char local_2b;
char local_2a;
char local_29;
undefined4 local_28;
undefined4 local_24;
char local_1d;
int local_1c;
int local_18;
int local_14;
int local_10;
local_2b = '5';
puVar1 = (undefined *)std::map<>::operator[]((map<> *)&_HM,&local_2b);
*puVar1 = 0x23;
local_10 = 3;
local_14 = std::__cxx11::basic_string<>::length();
local_14 = local_14 + -1;
std::operator+(param_1,param_2,param_2);
local_2a = '1';
puVar1 = (undefined *)std::map<>::operator[]((map<> *)&_HM,&local_2a);
*puVar1 = 0x29;
tVar5 = _time((time_t *)0x0);
_srand((uint)tVar5);
iVar2 = _rand();
local_18 = iVar2 % 6 + 3;
iVar2 = _rand();
local_1c = local_10 + iVar2 % ((local_14 - local_10) + 1);
local_29 = '6';
puVar1 = (undefined *)std::map<>::operator[]((map<> *)&_HM,&local_29);
*puVar1 = 0x28;
local_28 = std::__cxx11::basic_string<>::begin();
uVar3 = __gnu_cxx::__normal_iterator<>::operator+((__normal_iterator<> *)&local_28,local_1c);
uVar4 = std::__cxx11::basic_string<>::begin();
std::reverse<>(uVar4,uVar3);
uVar3 = std::__cxx11::basic_string<>::end();
local_24 = std::__cxx11::basic_string<>::begin();
uVar4 = __gnu_cxx::__normal_iterator<>::operator+((__normal_iterator<> *)&local_24,local_1c);
std::reverse<>(uVar4,uVar3);
uVar3 = std::__cxx11::basic_string<>::end();
uVar4 = std::__cxx11::basic_string<>::begin();
std::reverse<>(uVar4,uVar3);
local_1d = '2';
puVar1 = (undefined *)std::map<>::operator[]((map<> *)&_HM,&local_1d);
*puVar1 = 0x24;
return param_1;
}
冒頭の処理は、受け取った文字列を謎の領域に転写しています。
この時、格納されている文字列の長さが 52 文字に拡張されているのは何故なのかはよくわかってません。
続く処理ではランダムに値を生成しているようです。
その後、map で取得した恐らく 0x36 番目の要素に 0x28 を格納しています。(ここの実装の意味も不明)
次の処理は比較的わかりやすく、引数として与えられた文字列の map オブジェクトから、ランダムに生成した値の数分の文字列を先頭から抜き出しています。
このあとの処理では、取り出した先頭部分の文字列を Reverse し、元の文字列につなげています。
つまり、ABC…XYZ から 3 文字取り出して処理を行った場合、CBADEFG…XYZ となります。
続く処理では、取り出さなかった文字列(CDEF…XYZ)を反転します。
つまり、文字列は CBAZYX…FED となります。
最後に、この文字列全体を反転します。
一連の処理を行った結果、元の文字列は DEFG…XYZABC へと変換されました。
この関数の終了後、スタックトップの領域に転写された元の文字列が格納され、元々の入力値を格納していたアドレスには変換後の文字列が格納されます。
Lucifer 関数
zarathos 関数の処理が終わればすぐに Lucifer 関数が実行されます。
Lucifer 関数には、zarathos 関数で並び替えた文字列と文字数が引数として渡されるようです。
Lucifer 関数の実装で特筆すべき点はまずは以下かと思います。
local_38[0] = -3;
local_38[1] = 0xfffffffe;
local_38[2] = 0xffffffff;
local_38[3] = 1;
local_28 = 2;
local_24 = 3;
local_10 = random_pick(4,0);
local_3c = local_38[local_10];
local_38 の部分は補数表現になっていてわかりづらいですが、-3 から 4 までが定義されているように見えます。
randompick(4,0) の返り値が何かはわかりませんが、`local3c = local38[local10]` のインデックスとなっている点から、 local_10 にはランダムで -3 から 3 までの値が入るのではないかと予想できます。
続く実装では、引数の文字列のオブジェクトを取得し、イテレータを生成してループ処理を実装しているようです。
local_14 = param_1;
local_40 = std::__cxx11::basic_string<>::begin();
local_44 = std::__cxx11::basic_string<>::end();
while( true ) {
uVar2 = __gnu_cxx::operator!=(&local_40,&local_44);
if ((char)uVar2 == '\0') break;
pcVar3 = (char *)__gnu_cxx::__normal_iterator<>::operator*((__normal_iterator<> *)&local_40);
local_15 = *pcVar3;
local_1c = local_15 + local_3c;
adfedd(extraout_ECX,extraout_EDX,local_1c,param_2);
__gnu_cxx::__normal_iterator<>::operator++((__normal_iterator<> *)&local_40);
}
ここでは、直前の処理で取得したとみられる local_3c を各文字に加算した後、adfedd というよくわからない関数を呼び出しているようです。
この関数はデコンパイル結果としては以下のような感じになりました。
/* adfedd(int, int) */
void __fastcall adfedd(__cxx11 *param_1,undefined4 param_2,undefined4 param_3,undefined4 param_4)
{
basic_string local_24 [7];
std::__cxx11::to_string(param_1,(basic_string<> *)local_24,param_3);
std::__cxx11::basic_string<>::append(local_24);
std::__cxx11::basic_string<>::~basic_string((basic_string<> *)local_24);
return;
}
どうも、第 1 引数として受け取った値(直前の処理で取得したとみられる local_3c を各文字に加算した値)を 10 進数化したものの各桁を文字列として追加しているようです。
これは、謎だった 0x417148 の領域に一時的にマップされます。
ghostridderswepon 関数
次の ghostridderswepon 関数は、正直何をしているのかよくわからずでした。
単純に map オブジェクトを作成して 0x5e や 0x2a を格納して終了しています。
/* ghost_ridders_wepon() */
void ghost_ridders_wepon(void)
{
undefined *puVar1;
char local_e;
char local_d [9];
local_e = '4';
puVar1 = (undefined *)std::map<>::operator[]((map<> *)&_HM,&local_e);
*puVar1 = 0x5e;
local_d[0] = '0';
puVar1 = (undefined *)std::map<>::operator[]((map<> *)&_HM,local_d);
*puVar1 = 0x2a;
return;
}
matter_manipulation 関数
この関数を実行すると、戻り値として暗号化されたバイト列の領域へのポインタが返されます。
これは最終的に暗号化されたファイルに記録されたものですので、この関数で暗号化を行っている可能性が高そうです。
実装は以下のようになっていました。
ベクタテーブルを定義し、ループ処理の中で文字列を追加することを繰り返した後に、追加した文字列の領域を ret しています。
整理すると、pcVar2 = (char *)std::map<>::at(&local_25);
で取り出した値を std::__cxx11::basic_string<>::operator+=(param_1,*pcVar2);
で文字列に追加し、最終的な返り値を生成しています。
local_25 はイテレータなので、ここで入力値に対する処理を行っていそうです。
while( true ) {
uVar1 = __gnu_cxx::operator!=(&local_2c,&local_30);
if ((char)uVar1 == '\0') break;
pcVar2 = (char *)__gnu_cxx::__normal_iterator<>::operator*((__normal_iterator<> *)&local_2c);
local_25 = *pcVar2;
local_14 = 0x68;
std::vector<>::push_back((vector<> *)local_24,&local_14);
local_13 = 0x61;
std::vector<>::push_back((vector<> *)local_24,&local_13);
pcVar2 = (char *)std::map<>::at(&local_25);
std::__cxx11::basic_string<>::operator+=(param_1,*pcVar2);
__gnu_cxx::__normal_iterator<>::operator++((__normal_iterator<> *)&local_2c);
}
この時、イテレータで取得しているのは、先ほど 10 進数の数字文字列に変換した文字列でした。
続く pcVar2 = (char *)std::map<>::at(&local_25);
の箇所では、1 文字目の 8 を引数として実行した結果、%
という文字が返却されたことがわかります。
実際のベクタテーブルのマッピング情報は見つけることができませんでしたが、動的解析で値を追っていった結果、以下のような対応になっていることがわかりました。
%
: 8^
: 4#
: 5(
: 6)
: 1@
: 3$
: 2*
: 0&
: 9!
: 7
これは、実際に 8485868762636465666768697071727374757677787980818283
という文字列が %^%#%(%!($(@(^(#(((!(%(&!*!)!$!@!^!#!(!!!%!&%*%)%$%@
に置き換えられたことからも確認できました。
Solver を書く
ここまでの解析結果から、今回の暗号化は以下のステップで実施していることがわかりました。
- 元の文字列の先頭からランダムな値分取り出し、反転した上で元の文字列の先頭につなげる(ABC…XYZ から 3 文字取り出して処理を行った場合、CBADEFG…XYZ となる)
- 反転しなかった部分の文字列を反転する(取り出さなかった文字列 CDEF…XYZ を反転し CBAZYX…FED という文字列を作成する)
- 最後に文字列全体を反転する(DEFG…XYZABC となる)
- (たぶん) -3 から 3 までのいずれかの値をランダムに取得する
- 変換した文字列を先頭から取り出して [4.] で取得した値を減算した後 10 進数変換し、各桁の値を文字列として連結する(文字 D の場合は、6 と 8 が追加される)
- 作成した数字列を先頭から対応する記号に置き換える
以上のステップのうち、[1.] で文字列を何文字目まで切り出すかという点と、[4.] でどの値を取得するかという点がランダムに設定されています。
ただし、これらの値は十分に小さいので総当たりで Flag を求めることができそうです。
並べ替えの部分は総当たりする必要もあまりないので以下の Solver を作成しました。
table = {'*':'0', ')':'1', '$':'2', '@':'3', '^':'4', '#':'5', '(':'6', '!':'7', '%':'8', '&':'9'}
hacked_texts = r"""%##^!)@#(!!(!$%)%&%#(!^&%#^(#*##^&^%&)&)%^!)%)!*($($%$(#(%%&%#@^@)^%!)!)((&@##&$###^^*&@
##@@^&#^&@@%####!$!@!@(#!)&)%#%&!$#^@^^%!*%)&)&@@((@!@@@(%!@(@(@!^%)%&!!%#!!%###($@^
((!^#!%#()^&#)&@#*%*^&&@(^^&(@(%%@!^%&#(@&&)&)(%^(!&%^!)%)!*%#(@(#%$(%%&%*#*!(#)&$@^(%!^(!#%
^&&@%#@(#)#*%@%$^(^(%###(%%@!^%&#(@&&)&)!(%$((#*%^!)%)!*(%%&(((%%#!(#)^(()^&##^%%##*%*#*#*%!#)&@#*%*
!@((!!!(@&@%@&##!$((!#^&###^!#()#%(*(!##^&!$!)%#%@((((%#%&%@!*!)(@!(&@#(&)%&#(#!&)%##^#^($!)#^@^!)!@
!#%##*%*^&#)&@(^^&(@%@%$!)(%%@!^#&()&@(*%&#(@&&)&)%^!)%)!*%#(@(#%$(%%&#%&@#*%*^&&@%*#*!(#)^(
%)((%^@&!(!(%^(^%@%&#####*#^&)@%^*@^@(&)##^&^*@(@@@&()(*#%(*!!!!%#!)^&#((@!)!$((@&@%%)!@%#
@&^&#^^%#^#@#@()(*#%#(!)%)^*@(@@^%&)%&&)(%%#((%&&)%^!)%)!*(#%$(%%&####^%#^^*@(#^^&%@%#!(((@%
(*()()%&#&&@!!(((%^%@#@@#)!@(!%*!!%)!#(!!!(%!%%#&)!)!!%$&@%$!*!)!((&%)&@#*(@%*!@!@%#^*@((*(*
%!!*&@&@%!%#@^@#%&&)^(#@%!%#(!!(@%^((&@^@#%!%#(!%^&@@#%!%#@&@@@#%!@)%&@@%!@^&$%@%#%*!*%$(^&)%#@#
"""
for n in range(-3,4):
for hacked_text in hacked_texts.split("\n"):
flag = ""
buf = ""
for i,word in enumerate(hacked_text):
buf += table[word]
if i % 2 == 1:
flag += chr(int(buf)+n)
buf = ""
if "CTF" in flag:
print(flag)
これを実行すると、以下の出力が得られました。
DL;W?35_4R3_B3AFUL[:)]]F0QVISHWACTF[R4N5^$FLE<
MW4R35_B3AUTIFUL=?_>[:)]]VISHWACTF[<_4R3_R4N50
)382877?><:IS*][]FWD[]VISHCTF[9928*&83UWND(
真ん中の MW4R35_B3AUTIFUL=?_>[:)]]VISHWACTF[<_4R3_R4N50
が実際の Flag フォーマットに合致していそうです。
手動でいい感じに並べ替えると、VISHWACTF[4R3_R4N50MW4R35_B3AUTIFUL=]
という文字列が正しい Flag になることを特定できました。
まとめ
しんどかった。解析力不足すぎる。。