SECCON CTF 2023 Quals Writeup
2023/9/16(土) 14:00 ~ 2023/9/17(日) 14:00に開催されたSECCON CTF 2023 QualsのWriteupです。
チームDouble Lariat
で参加し、全体で41位、国内で8位という結果になりました。今年も国内決勝に行けるようで非常に嬉しいです。
私はrev問を担当し、xuyao以外の4問を解きました。
[rev] jumpout
ELFバイナリのjumpout
が与えられます。中身はシンプルなフラグチェッカーで、フラグを入力するとCorrect!
かWrong...
を表示してくれます。
$ ./jumpout FLAG: hogehoge Wrong...
Ghidraでmain関数の処理を見ると、puts("Wrong...")
等の処理が見当たらず、中途半端なデコンパイル結果になっていることが分かりました。
undefined8 main(void) { int iVar1; long i; undefined8 *ptr; long in_FS_OFFSET; undefined8 buf [13]; long local_40; local_40 = *(long *)(in_FS_OFFSET + 0x28); ptr = buf; for (i = 0xc; i != 0; i = i + -1) { *ptr = 0; ptr = ptr + 1; } *(undefined4 *)ptr = 0; do { FUN_001010c0(1,"FLAG: "); iVar1 = __isoc99_scanf("%99s",buf); } while (iVar1 == 1); if (local_40 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return 1; }
アセンブリを詳しく見てみると、複数の場所で以下のアセンブリコードが見つかります。
mov rax, QWORD ptr [rsp + rbx*8] lea ebx, [r14 - 1] jmp rax
このコードによって処理の順番がばらばらになり、デコンパイル結果が中途半端になっていたと考えられます。ジャンプ先のアドレスをたどり、デコンパイル結果を組み合わせると以下のソースコードが得られました。
#include <stdio.h> unsigned char xor[] = {0xf6, 0xf5, ...}; unsigned char enc_flag[] = {0xf0, 0xe4, ...}; unsigned char FUN_00101360(unsigned char a, int b) { return a ^ (unsigned char)b ^ 0x55 ^ xor[b]; } int FUN_00101480(char *str) { size_t len = strlen(str); int unaff_EBP = 1; if (len != 0x1d) { return 1; } for (int i = 0; i < 0x1d; i++) { unaff_EBP &= (FUN_00101360(str[i], i) == enc_flag[i]); } return unaff_EBP == 0; } int main() { char buf[100]; long *ptr; int ret; // memset(buf, 0, 100)? ptr = (long*)buf; for (int i = 0xc; i != 0; i--) { *ptr = 0; ptr++; } *ptr = 0; do { ret = scanf("%99s", buf); } while (ret != 1); if (FUN_00101480(buf) == 0) { puts("Correct!"); } else { puts("Wrong..."); } }
FUN_0010360
の出力結果がenc_flag
と一致するような値を以下のソースコードで求めることでフラグを得られました。
enc = [246, 245, 49, 200, 129, 21, 20, 104, 246, 53, 229, 62, 130, 9, 202, 241, 138, 169, 223, 223, 51, 42, 109, 129, 245, 166, 133, 223, 23] target = [240, 228, 37, 221, 159, 11, 60, 80, 222, 4, 202, 63, 175, 48, 243, 199, 170, 178, 253, 239, 23, 24, 87, 180, 208, 143, 184, 244, 35] for i, e in enumerate(enc): for x in range(256): if 0x55 ^ i ^ e ^ x == target[i]: print(chr(x), end="") print()
$ python3 solve.py SECCON{jump_table_everywhere}
[rev] Sickle
pickle化されたフラグチェッカーのコードを実行するPythonスクリプトproblem.py
が与えられます。
flickingを用いてpickle化されたコード(problem.py
中のpayload
)をデコンパイルすると、以下のコードが得られました。
import ast import pickle from fickling.pickle import Pickled payload = b'\x8c\x08builtins\x8c\x07getattr...\x85R.' print(ast.dump(Pickled.load(payload).ast, indent=4))
_var0 = input('FLAG> ') _var1 = getattr(_var0, 'encode') _var2 = _var1() _var3 = getattr(dict, 'get') _var4 = globals() _var5 = _var3(_var4, 'f') _var6 = getattr(_var5, 'seek') _var7 = getattr(int, '__add__') _var8 = getattr(int, '__mul__') _var9 = getattr(int, '__eq__') _var10 = len(_var2) _var11 = _var9(_var10, 64) _var12 = _var7(_var11, 261) result = _var6(_var12)
payload
中にみられる__xor__
やappend
などが結果に表れておらず、不自然な結果となっていることが分かります。調べてみると、payload
中にSTOP命令を表す「.
」が複数存在することが原因であることが確認できました。
pickle化されたコードの中から.
を削除し、pickletools.optimize
で最適化したものをflickingでデコンパイルすると以下のコードが得られました。
_var0 = input('FLAG> ') _var1 = getattr(_var0, 'encode') _var2 = _var1() _var3 = getattr(dict, 'get') _var4 = globals() _var5 = _var3(_var4, 'f') _var6 = getattr(_var5, 'seek') _var7 = getattr(int, '__add__') _var8 = getattr(int, '__mul__') _var9 = getattr(int, '__eq__') _var10 = len(_var2) _var11 = _var9(_var10, 64) _var12 = _var7(_var11, 261) _var13 = _var6(_var12) _var14 = getattr(_var2, '__getitem__') _var15 = _var14(0) _var16 = getattr(_var15, '__le__') _var17 = _var16(127) _var18 = _var7(_var17, 330) _var19 = _var6(_var18) _var20 = _var7(0, 1) _var21 = _var9(_var20, 64) _var22 = _var8(_var21, 85) _var23 - _var7(_var22, 290) _var24 = _var6(_var23) _var25 = getattr([], 'append') _var26 = getattr([], "__getitem__") _var27 = getattr(int, 'from_bytes') _var28 = _var8(0, 8) _var29 = _var7(0, 1) _var30 = _var8(_var29, 8) _var31 = slice(_var28, _var30) _var32 = _var14(_var31) _var33 = _var27(_var32, 'little') _var34 = _var25(_var33) _var35 = _var7(0, 1) _var36 = _var9(_var35, 8) _var37 = _var8(_var36, 119) _var38 = _var7(_var37, 457) _var39 = _var6(_var38) _var40 = getattr([], 'append') _var41 = getattr([], '__getitem__') _var42 = getattr(int, '__xor__') _var43 = _var26(0) _var44 = _var42(_var43, 1244422970072434993) _var45 = pow(_var44, 65537, 18446744073709551557) _var46 = _var40(_var45) _var47 = _var41(0) _var48 = _var7(0, 1) _var49 = _var9(_var48, 8) _var50 = _var8(_var49, 131) _var51 = _var7(_var50, 679) _var52 = _var6(_var51) _var53 = getattr([], '__eq__') _var54 = _var53([8215359690687096682,1862662588367509514,8350772864914849965,11616510986494699232,3711648467207374797,9722127090168848805,16780197523811627561,18138828537077112905])
処理をまとめると、以下の6つのステップに分けられます。
- 標準入力からフラグを入力させる(以下flagと表記)
- flagの長さが64文字か確認する
- flagの各byteが0x7fより小さいことを確認する
- flagを8文字ごとに区切り、int.from_bytesで整数型に変換する(区切られたflagをブロックと表記する)
- 定数とxorをとって鍵
(n, e) = (18446744073709551557, 65537)
のRSAで暗号化する - 正解のフラグを暗号化したものと5.で暗号化したものを比較する
公開鍵に用いられているn
は素数であるため秘密鍵はd = pow(e, -1, n-1)
で求められ、暗号文はpow(enc, d, n)
で復号できます。最後に、復号して得られた平文と定数でxorをとることでフラグを求められます。
以下のコードで復号を試みましたが、最初のブロックのフラグは得られたものの2ブロック目以降はフラグとは関係のないバイト列が出力されました。
from Crypto.Util.number import * enc = [8215359690687096682,1862662588367509514,8350772864914849965,11616510986494699232,3711648467207374797,9722127090168848805,16780197523811627561,18138828537077112905] p = 18446744073709551557 phi = p - 1 d = pow(65537, -1, phi) key = 1244422970072434993 for e in enc: print(long_to_bytes(pow(e, d, p) ^ key)[::-1].decode(), end="")
$ python solve.py b'SECCON{C' b':\xc0\xa9\xcbj\xab"\x0c' b'Ut\xd05\xb4\xf0\xfd{' b'\xf9\xd6\xd4]\xdf\x96\xf9\x03' b'\x8e\x0b\x94\x84]P\x14\xd5' b"\x8e\x91\xe3\xd5\xf6'\x97K" b'\xf7\x03ZjR\x99\xd7\xe3' b'}\x18@f\xc2\x17\xa5\x84'
試行錯誤して2ブロック目以降でxorをとる値を直前のブロックの暗号文に変更したところ、フラグが出力されました。
from Crypto.Util.number import * enc = [8215359690687096682,1862662588367509514,8350772864914849965,11616510986494699232,3711648467207374797,9722127090168848805,16780197523811627561,18138828537077112905] keys = [1244422970072434993, *enc[:-1]] p = 18446744073709551557 phi = p - 1 d = pow(65537, -1, phi) for e, k in zip(enc, keys): print(long_to_bytes(pow(e, d, p) ^ k)[::-1].decode(), end="")
$ python solve.py SECCON{Can_someone_please_make_a_debugger_for_Pickle_bytecode??}
[rev] optinimize
nim言語で作成されたバイナリmain
が与えられます。
実行するとフラグが一文字ずつ出力されていきますが、フラグの後半になるにつれて出力にかかる時間が増大していき、10文字目あたりから全然出力されなくなります。
$ ./main SECCON{3b4
気合で解析してmain
が行う処理をPythonで書き直すと以下のソースコードになります。エントリポイントはNimMainModule
です。
qmain_vals = [0x4a,0x55,0x6f,0x79,0x80,0x95,0xae,0xbf,0xc7,0xd5,0x306,0x1ac8,0x24ba,0x3d00,0x4301,0x5626,0x6ad9,0x7103,0x901b,0x9e03,0x1e5fb6,0x26f764,0x30bd9e,0x407678,0x5b173b,0x6fe3b1,0x78ef25,0x858e5f,0x98c639,0xad6af6,0x1080096,0x18e08cd,0x1bb6107,0x1f50ff1,0x25c6327,0x2a971b6,0x2d68493,0x362f0c0,0x3788ead,0x3caa8ed] xor_vals = [0x3c,0xf4,0x1a,0xd0,0x8a,0x17,0x7c,0x4c,0xdf,0x21,0xdf,0xb0,0x12,0xb8,0x4e,0xfa,0xd9,0x2d,0x66,0xfa,0xd4,0x95,0xf0,0x66,0x6d,0xce,0x69,0x0,0x7d,0x95,0xea,0xd9,0xa,0xeb,0x27,0x63,0x75,0x11,0x37,0xd4] def P_main(x): if x == 0: return 3 elif x == 1: return 0 elif x == 2: return 2 i, j, k = 3, 0, 2 while (x > 2): tmp = i + j i, j, k = j, k, tmp x -= 1 return k def Q_main(x): i, j = 0, 0 while i < x: j += 1 if P_main(j) % j == 0: i += 1 return j def NimMainModule(): for i in range(40): x = qmain_vals[i] y = Q_main(x) % 256 z = y ^ xor_vals[i] if (z < 0x100) { print(chr(z), end="")
ループの中でP_main
が何度も呼ばれ、P_main
自体が非常に処理の重い関数であるため高速化する必要があるのですが、いい感じに高速化する方法が思いつきませんでした。rev部分だけ終わらせて他の問題に手を付けたり、高速化手法を考えていたところチームメンバーの方がP_main(j) % j == 0
となるj
が素数の数列であることに気づき、総当たりで解いてくれました。
SECCON{3b4297373223a58ccf3dc06a6102846f}
チームメンバーに感謝🙏
[rev] Perfect Blu
ブルーレイディスクのisoファイルperfect-blu.iso
が与えられます。VLCなどで再生するとフラグ入力画面が表示され、画面中央下のCHECKボタンを押すことで入力したフラグの正誤を判定してくれます。
BDeditでisoファイルを開き、CLIPINFタブでcpiファイルを選択、Program Infoパネルのstream typeがIGとなっている要素をダブルクリックすると以下の画面が表示されます。
複数あるボタンの中で一つだけCallObjectの引数が異なっているボタンが存在します。0000.clpi~0047.clpiに対して、「CallObjectの引数が異なっているボタンを見つけ、その座標を記録する」という作業を地道に行い、最後にボタンの座標から文字列に変換することでフラグが得られました。
xs = [315, 453, 590, 727, 865, 1002, 1139, 1277, 1414, 1551] ys = [413, 543, 672, 802] chars = "1234567890QWERTYUIOPASDFGHJKL{ZXCVBNM_-}" button_coords = [ (453, 672), (590, 543), (590, 802), (590, 802), (1414, 543), (1002, 802), (1551, 672), (1139, 672), (453, 543), (865, 802), (1002, 672), (1414, 802), (865, 413), (1277, 413), (590, 543), (1414, 672), (1414, 802), (315, 543), (453, 543), (727, 543), (1414, 672), (1414, 802), (590, 802), (1414, 672), (453, 672), (453, 543), (1414, 802), (1139, 543), (727, 672), (727, 543), (1277, 543), (1414, 802), (453, 802), (1139, 543), (1002, 543), (590, 413), (1414, 802), (1002, 543), (1002, 672), (1277, 672), (1277, 672), (1414, 802), (1277, 672), (727, 672), (865, 802), (727, 802), (1551, 802) ] def coord_to_key(coord): for i, x in enumerate(xs): for j, y in enumerate(ys): if x == coord[0] and y == coord[1]: return chars[i + j*10] return "" flag = "" for coord in button_coords: flag += coord_to_key(coord) print(flag)
$ python solve.py SECCON{JWBH-58EL-QWRL-CLSW-UFRI-XUY3-YHKK-KFBV}
感想
rev力が足りずxuyaoが解けなかったのは少し悔しいですが、5問あるrev問の中で4問も解けたのは良かったです。
今回もrev以外の問題に手を付けられなかったので少しずつ手を出せるジャンルを増やしていきたいですね。