SECCON CTF 13 に 0nePadding で参加しました。
個人では Rev を 2 問解き、国内 34 位、全体 89 位でフィニッシュでした。
いまいち消化不良ですが最近はあまりじっくり復習できる時間が無いのでとりあえず簡易 Writeup を書きます。
packed(Rev)
Packer is one of the most common technique malwares are using.
問題バイナリを調べると、UPX でパッキングされたファイルであることがわかりました。
そこで、upx コマンドを使用してアンパックしたバイナリを Binary Ninja で解析することにしました。
しかし、アンパックしたバイナリは受け取った入力値の検証をせずに常に Wrong
というエラーを表示する実装になっており、正しい Flag の特定に役立ちませんでした。
そこで、アンパックしていないバイナリを gdb でデバッグしてみると、入力値のサイズが 0x31 かどうかをチェックし、正しい Flag と一致するかの検証を行うコードが見つかります。
つまり、このバイナリは何らかの方法で upx によるアンパック時に一部のコードがバイナリに含まれないような加工がされていると推察できます。
実際のところどのような加工が行われているかはすぐに思いつくものがなかったので、今回はアンパックはせずにバイナリをそのまま動的解析していくことにしました。
入力値のサイズ検証を突破した後、このバイナリでは 1 文字ごとにハードコードされた Key との XOR 変換を行うようです。
ここで使用する Key は gdb で簡単に取得できます。
また、最終的に正しい Flag と一致するかに使用されるハードコードされたデータ列も、gdb で簡単に参照できました。
あとは以下の Solver で XOR 暗号を解除することで正解の Flag を取得することができます。
key = [0xe8,0x4a,0x00,0x00,0x00,0x83,0xf9,0x49,0x75,0x44,0x53,0x57,0x48,0x8d,0x4c,0x37,0xfd,0x5e,0x56,0x5b,0xeb,0x2f,0x48,0x39,0xce,0x73,0x32,0x56,0x5e,0xac,0x3c,0x80,0x72,0x0a,0x3c,0x8f,0x77,0x06,0x80,0x7e,0xfe,0x0f,0x74,0x06,0x2c,0xe8,0x3c,0x01]
target = [0xbb,0x0f,0x43,0x43,0x4f,0xcd,0x82,0x1c,0x25,0x1c,0x0c,0x24,0x7f,0xf8,0x2e,0x68,0xcc,0x2d,0x09,0x3a,0xb4,0x48,0x78,0x56,0xaa,0x2c,0x42,0x3a,0x6a,0xcf,0x0f,0xdf,0x14,0x3a,0x4e,0xd0,0x1f,0x37,0xe4,0x17,0x90,0x39,0x2b,0x65,0x1c,0x8c,0x0f,0x7c]
for i in range(0x30):
print(chr(key[i]^target[i]),end="")
# SECCON{UPX_s7ub_1s_a_g0od_pl4c3_f0r_h1din6_c0d3}
Jump(Rev)
Who would have predicted that ARM would become so popular?
※ We confirmed the binary of Jump accepts multiple flags. The SHA-1 of the correct flag is c69bc9382d04f8f3fbb92341143f2e3590a61a08 We’re sorry for your patience and inconvenience
問題バイナリとして与えられた ARM バイナリを Binary Ninja で解析しました。
main 関数から呼び出される jumper 関数は、コマンドライン引数の有無を確認し、存在している場合にはスタックにロードした実行アドレスに RET します。
これが今回の問題バイナリの特徴のようで、通常の関数呼び出しではなく ROP のような形で各関数内の特定の実行コードに RET する方法で処理が進んでいきます。(恐らく解析対策?)
コマンドライン引数が存在している場合、プログラムは target 関数のプロローグ直後のコードにジャンプします。
この中では、プログラムの実行中に更新されるフラグを条件として様々な処理にジャンプする Switch 文が定義されています。
一見すると実装が読めないですが、gdb を使って実際のジャンプ先を追跡すると、最終的に以下のチェッカーの関数内のコードにたどり着けることを確認できます。
void checker(int32_t* arg1)
{
data_41203c = 1;
switch (((uint64_t)index))
{
case 0: // SECC
{
uint64_t var_30_4 = ((uint64_t)*(uint32_t*)arg1);
int64_t (* var_28_4)() = sub_400b48;
break; // SECC
}
case 4: // ON{5
{
uint64_t var_30_1 = ((uint64_t)*(uint32_t*)((char*)arg1 + ((int64_t)index)));
int64_t (* var_28_1)() = sub_400aa8;
break; // ON{5
}
case 8: // h4k3
{
uint64_t var_30_2 = ((uint64_t)*(uint32_t*)((char*)arg1 + ((int64_t)index)));
int64_t (* var_28_2)() = sub_400ae4;
break; // h4k3
}
case 0xc: // _1t_
{
uint64_t var_30_5 = ((uint64_t)*(uint32_t*)((char*)arg1 + ((int64_t)index)));
void* const var_28_5 = &data_400b84;
break; // _1t_
}
case 0x10: // up_5
{
int32_t* var_30_7 = arg1;
void* const var_28_7 = &data_400bd4;
break; // up_5
}
case 0x14: // BBBB + up_5 = 0x9d949ddd
{
int32_t* var_30_3 = arg1;
int64_t (* var_28_3)() = sub_400b14;
break; // BBBB + up_5 = 0x9d949ddd
}
case 0x18: // CCCC + BBBB = 0x9d9d6295
{
int32_t* var_30 = arg1;
int64_t (* var_28)() = sub_400a6c;
break; // CCCC + BBBB = 0x9d9d6295
}
case 0x1c: // DDDD - CCCC = 0x47cb363b
{
int32_t* var_30_6 = arg1;
int64_t (* var_28_6)() = sub_400ba4;
break; // DDDD - CCCC = 0x47cb363b
}
}
}
このコードの中では、コマンドライン引数から受け取る Flag 文字列のチェック位置に対応してそれぞれ異なる検証コードにジャンプするように実装されています。
しかし、このバイナリにはバグがあり、まともに Flag のチェックが機能していなかったため、動的解析はここで終了し、正しい Flag は静的解析で特定することにしました。
コードを解析すると、最初の 16 文字は単にハードコードされたデータと Key の XOR によって特定できることがわかります。
4 文字ずつ XOR を行っていくと、最初の 16 文字が SECCON{5h4k3_1t
であることを特定できます。
続く 16 文字の部分は、前の 4 文字分の HEX との和や差がハードコードされた整数値になるか否かを検証していました。
そこで、先頭から順に手動で計算を行いました。
最終的に SECCON{5h4k3_1t_up_5h-5h-5h5hk3}
が正しい Flag であることを特定しました。
まとめ
Jump のバイナリがちゃんと動いておらず Flag 取得に時間がかかっていることにキレながら解析してましたが、 ちゃんと読むと静的解析で簡単に Flag が取れるようになっていたので単純に実力不足でしたすみません。
むしろプログラムの検証がまともに機能してたら初手の脳死 angr で Flag 取れてしまっていた可能性があるので逆に良かったかなと思います。
SECCON は毎回 3 問目以降から手も足も出なくなるので、そろそろ高難度問も解けるようになりたいところです。