syscall oriented programming / hitcon / rev / seccomp
1: イントロ
最近CTFを辞めたので、いつぞや開催された HITCON CTF 2020のrev問である SOP を解いていく。rev問、やっぱ、むずい。けど、ほえ〜となる良い問題でした。
2: 問題概要
バイトコードとそのインタプリタ本体が与えられる。本体の方はバイトコードを8byteずつ読み取り、そのオペコードに応じて RDI/RSI/RDX/R10/R8/R9 及び RAX に値を格納し、syscall を呼び出す。ひたすらこれを繰り返すプログラムである。
#!/usr/bin/env python #encoding: utf-8; from pwn import * import sys # this file is some thing like: # 0 read # 1 write # 2 open # 3 close with open('syscalls.txt', 'r') as f: lines = f.readlines() syscalls = [] for l in lines: syscalls.append(l.split("\t")[1]) def ex(inst, n): a = "0b0" for i in range(n): a = a+"1" return inst & int(a, 2) # read byte_code with open('sop_bytecode', 'rb') as f: bytecode = f.read() ip = 0 # pc in interpreter rax = 0 # rax regs = [0, 0, 0, 0, 0, 0] # rdi, rsi, rdx, r10, r8, r9 access_prctl = [] # for debug inst_ex = [] # for debug prctl_arg = [] # for debug prctl_names = {0x28: "GET_TID_ADDR", 0x26: "SET_NO_NEW_PRIVS", 0x16: "PR_SET_SECCOMP", 0xf: "PR_SETNAME", 0x10: "PR_GET_NAME"} set_tid = 0 # previously set clear_child_tid #workspace = 0x60 # workspace on stack, used in sig_action handler workspace = 0x50 # workspace on stack, used in sig_action handler original_workspace = workspace # represents memory, both on stack and .bss region class mem: def __init__(self): self.mem = {} self.initmem(self.mem, 0x217000, 0x100) # .bss self.initmem(self.mem, 0, 0x8*100) # stack def initmem(self, mem, start, size): for i in range(size): mem[start + i] = 0 def show(self): for addr, value in self.mem.items(): if addr%8 == 0: print("\n{}\t".format(hex(addr)[2:].rjust(8,'0')), end=" ") print(hex(value)[2:].rjust(2,'0'), end=" ") print("") def getmem(self, addr, size=8): val = 0 for i in range(size): val += self.mem[addr+i] << (8*i) return val def setmem(self, addr, value, size=8): for i in range(size): self.mem[addr+i] = (value>>(0x8*i)) & 0xFF def setstr(self, addr, yourstr): for i in range(len(yourstr)): self.mem[addr+i] = ord(yourstr[i]) m = mem() flag = "A"*0x20 #flag = "hitcon{SysCallOP57289ca4ce57585}" # print regs def pregs(): global regs if syscalls[rax] not in inst_ex: inst_ex.append(syscalls[rax]) if syscalls[rax] != "prctl": print("{} {}({})".format(hex(ip//8)[2:].rjust(3,'0'), syscalls[rax].rjust(17,' '), hex(rax)), end=" ") else: print("{} {}[{}]".format(hex(ip//8)[2:].rjust(3,'0'), syscalls[rax].rjust(17,' '), prctl_names[regs[0]]), end=" ") for reg in regs: print(hex(reg), end=" ") print("") comm = [0, 0] # current->comm(char[0x10]) ######## start of emulation ###################### while True: # handle ######################## def handle(): global rax global regs global set_tid global comm global mem global workspace if syscalls[rax] == "set_tid_address": set_tid = regs[0] if syscalls[rax] == "prctl": if regs[0] == 0x28: # gettid if set_tid != 0: m.setmem(regs[1], set_tid) access_prctl.append(regs[1]) elif regs[0] == 0xf: # setname comm = [m.getmem(regs[1]), m.getmem(regs[1] + 8)] elif regs[0] == 0x10: # getname m.setmem(regs[1], comm[0]) m.setmem(regs[1] + 8, comm[1]) if syscalls[rax] == "getgid": res = regs[0] & regs[1] res &= 0xFFFF m.setmem(workspace, res, 2) workspace += 2 if syscalls[rax] == "getuid": res = regs[1] >> regs[0] res &= 0xFFFF m.setmem(workspace, res, 2) workspace += 2 if syscalls[rax] == "getpid": res = regs[1] + regs[0] res &= 0xFFFF m.setmem(workspace, res, 2) workspace += 2 if syscalls[rax] == "getegid": res = regs[1] - regs[0] res &= 0xFFFF m.setmem(workspace, res, 2) workspace += 2 if syscalls[rax] == "getpgrp": res = regs[1] * regs[0] res &= 0xFFFF m.setmem(workspace, res, 2) workspace += 2 if syscalls[rax] == "getppid": res = regs[1] if regs[0] > 0x32: res = 0 else: for i in range(regs[0]): res <<= 1 res &= 0xFFFF m.setmem(workspace, res, 2) workspace += 2 if syscalls[rax] == "geteuid": res = regs[1] ^ regs[0] res &= 0xFFFF m.setmem(workspace, res, 2) workspace += 2 if syscalls[rax] == "read": assert(regs[0] == 0) # stdin assert(len(flag)==0x20) print("entering your input (maybe flag??) size 0x20\n") m.setstr(regs[1], flag) if syscalls[rax] == "rt_sigaction": print("rt_sigaction is called now") #m.show() # end handle ######################## inst = u64(bytecode[ip: ip+8]) if inst == 0: break #for i in range(len(regs)): # regs[i] = 0 rax = ex(inst, 8) inst = inst>>0x8 for i in range(6): addr = 0 opc = ex(inst, 2) inst = inst >> 2 if(opc == 0): addr = ex(inst, 4) inst = inst >> 4 regs[i] = m.getmem(addr * 8) elif(opc == 1): offset = ex(inst, 4) inst = inst >> 4 regs[i] = offset * 8 # stack memory base is regarded as 0 in this parser elif(opc == 2): tmp = ex(inst, 5) inst = inst >> 5 regs[i] = ex(inst, tmp+1) inst = inst >> (tmp+1) else: break ip += 8 pregs() # show registers handle() # handle syscall # end of bytecode if(ip >= len(bytecode)): # dump memory m.show() print(inst_ex) # dump message for i in range(0x30): print(chr(m.getmem(0x217050 + i, 1)), end="") print("") exit() if ip//8 == 0x370: m.show() for i in range(0x80): print(chr(m.getmem(original_workspace + i, 1)), end="") if(ip % 0x100 == 0 and ip!=0): print("PC: {}".format(hex(ip)))
3: バイトコード
001 set_tid_address(0xda) 0x217000 0x0 0x0 0x0 0x0 0x0 002 prctl(0x9d) 0x28 0x0 0x0 0x0 0x0 0x0 003 mmap(0x9) 0x217000 0x1 0x7 0x22 0x0 0x0 004 read(0x0) 0x0 0x217000 0x20 0x22 0x217000 0x217000
最初に set_tid_address(0x21700) を呼んでいる。これは、current->clear_child_tid を指定した値に設定するシスコールである。
直後の prctl(GET_TID_ADDRESS) では先程格納した current->clear_child_tid を第2引数で指定したユーザランド領域にコピーする。これによって、直接的に mov 命令を呼び出すことなく syscall 経由で値をメモリ中に移すことができる。今回の場合は、アドレス 0x0 に対して値 0x217000 を mov したことになる。なお、プログラム中ではスタック中の一部の領域を作業領域として割り当てているが、pythonパーサにおいてはこのアドレスを 0x0 としている。
その後、アドレス 0x217000 をRWXで mmap() し、割り当てた領域に対してユーザから 0x20 だけ read() している。
005 set_tid_address(0xda) 0x217050 0x217000 0x217000 0x217000 0x217000 0x217000 006 prctl(0x9d) 0x28 0x60 0x217000 0x217000 0x217000 0x217000 007 set_tid_address(0xda) 0x217020 0x60 0x217000 0x217000 0x217000 0x217000 008 prctl(0x9d) 0x28 0x0 0x217000 0x217000 0x217000 0x217000 009 set_tid_address(0xda) 0x217054 0x0 0x217020 0x217020 0x217020 0x217020 00a prctl(0x9d) 0x28 0x60 0x217020 0x217020 0x217020 0x217020 00b set_tid_address(0xda) 0x0 0x60 0x217020 0x217020 0x217020 0x217020 00c prctl(0x9d) 0x28 0x0 0x217020 0x217020 0x217020 0x217020 00d set_tid_address(0xda) 0x217058 0x0 0x217020 0x217020 0x217020 0x217020 00e prctl(0x9d) 0x28 0x68 0x217020 0x217020 0x217020 0x217020 00f set_tid_address(0xda) 0x4000004 0x68 0x217020 0x217020 0x217020 0x217020 010 prctl(0x9d) 0x28 0x0 0x217020 0x217020 0x217020 0x217020 011 set_tid_address(0xda) 0x21705c 0x0 0x4000004 0x4000004 0x4000004 0x4000004 012 prctl(0x9d) 0x28 0x18 0x4000004 0x4000004 0x4000004 0x4000004 013 set_tid_address(0xda) 0x0 0x18 0x4000004 0x4000004 0x4000004 0x4000004 014 prctl(0x9d) 0x28 0x4 0x4000004 0x4000004 0x4000004 0x4000004 015 set_tid_address(0xda) 0x217060 0x4 0x4000004 0x4000004 0x4000004 0x4000004 016 prctl(0x9d) 0x28 0x18 0x4000004 0x4000004 0x4000004 0x4000004 017 set_tid_address(0xda) 0x217044 0x18 0x4000004 0x4000004 0x4000004 0x4000004 018 prctl(0x9d) 0x28 0x4 0x4000004 0x4000004 0x4000004 0x4000004
その後はひたすら get_tid_address/prctl を繰り返すことでメモリ中に値を書き込んでいく。これがインタプリタ中の IP==0x52 まで続き、その時点でのメモリは以下のような感じ。(最初の入力として "A"*0x20 を与えた場合)
053 rt_sigaction(0xd) 0x1f 0x217050 0x0 0x8 0xcccc050f 0x217050
この後、rt_sigaction が呼ばれる。int signum は 0x1F==SIG_SYS であり、struct sigaction *act は 0x217050 になっている。
sa_sigaction() は以下のような命令列である。
一番最初にRCXに入れる値が壊れているが、後々以下のように書き換えられる。
2a2 set_tid_address(0xda) 0x30 0x0 0xffffffff 0xffffffff 0xffffffff 0xffffffff 2a3 prctl(0x9d) 0x28 0x217022 0xffffffff 0xffffffff 0xffffffff 0xffffffff 2a4 set_tid_address(0xda) 0xffffffff 0x217022 0xffffffff 0xffffffff 0xffffffff 0xffffffff 2a5 prctl(0x9d) 0x28 0x0 0xffffffff 0xffffffff 0xffffffff 0xffffffff 2a6 set_tid_address(0xda) 0x2172840000 0x0 0xffffffff 0xffffffff 0xffffffff 0xffffffff 2a7 prctl(0x9d) 0x28 0x8 0xffffffff 0xffffffff 0xffffffff 0xffffffff
結果として、先程のsig_actionは以下のように書き換えられる。
rcx = workspace; # R9レジスタの場所の次 *(short*)rcx = *(short*)(rsi+4); *(long*)(0x217022) += 2;
書き換えられた結果、先程のアドレスはスタック上のメモリ領域になっていることが分かる。 ここは、インタプリタ内部のR9レジスタの次の領域である。この領域を特別に workspace と名付けておくことにする。2番目のコードによってworkspaceに2byte分データが書き込まれた後、3番目のコードによって1番目のコード自体のオペランド部分をを書き換えている。これによって rcx=workspace の命令は rcx=workspace+2 となり、このハンドラを実行する度に workspace + 2*n 番地に値を書き込むようになる(mutable instruction)。
295 prctl[PR_SET_SECCOMP] 0x16 0x2 0x217050 0x217274 0x217274 0x217274
また、rt_sigactionの直後に上のように seccomp が設定される。RSI==0x2より、seccomp_modeはSECCOMP_MODE_FILTER、設定されるフィルタは0x217050においてある。生成されるBPFをディスアセンブルすると以下の通り。
(書いてて気づいたけど、この問題のauthor、seccomp-toolのauthorじゃん。。。。)
fork/write/getgid/getpid/gettid/getegid/getpgrp/getppid/geteuidに対してフィルタがかかっていて、呼び出した際のRDI/RSIに対して演算を行うようになっている。
getgid -> &演算
getuid -> 右シフト演算
gettid -> OR演算
getpid -> 加算
getegid -> 減算
getpgrp -> 乗算
getppid -> 左シフト演算
geteuid -> XOR演算
fork -> 除算
演算の後、return (演算結果) | SECCOMP_RET_TRAPをすることによってSIGSYSをraiseしている。これによって、処理は先程のsig_action()へと移り、そこでworkspace+2*nに対して演算結果を格納するようになっている。
seccompをした後は以下のように続く。
296 set_tid_address(0xda) 0x69a33fff 0x2 0x217050 0x0 0x0 0x0
297 prctl[GET_TID_ADDR] 0x28 0x10 0x217050 0x0 0x0 0x0
298 set_tid_address(0xda) 0x468932dc 0x10 0x217050 0x0 0x0 0x0
299 prctl[GET_TID_ADDR] 0x28 0x18 0x217050 0x0 0x0 0x0
29a set_tid_address(0xda) 0x2b0b575b 0x18 0x217050 0x0 0x0 0x0
29b prctl[GET_TID_ADDR] 0x28 0x20 0x217050 0x0 0x0 0x0
29c set_tid_address(0xda) 0x1e8b51cc 0x20 0x217050 0x0 0x0 0x0
29d prctl[GET_TID_ADDR] 0x28 0x28 0x217050 0x0 0x0 0x0
29e prctl[PR_SETNAME] 0xf 0x217000 0x217050 0x0 0x0 0x0
29f prctl[PR_GET_NAME] 0x10 0x30 0x217050 0x0 0x0 0x0
2a0 set_tid_address(0xda) 0xffffffff 0x30 0x217050 0x0 0x0 0x0
重要なのは prctl[PR_SETNAME] のところで、引数が最初に read() でユーザから入力した値になっている。これによって、current->comm がユーザ入力値になる。(commはchar[TASK_COMM_LEN==0x10]だから入力値の半分だけがプロセス名になる)
そのあと、入力値をメモリ(これは、スタック上に確保されるメモリ)のオフセット0x30に prctl[PR_GETNAME] している。このアドレスは、先程のsig_action()の一番最初の命令で読み込まれるアドレスであった。
これ以降は、先程seccompで設定したシスコールを呼ぶことで諸々の演算を行ったり、上に示したようにGETNAMEで値を8byteまるごとコピーしたりしながら続いていく。一番最後に以下のように write を呼んで終わり。
70a write(0x1) 0x217020 0x217050 0x24 0x0 0x0 0x0
70b write(0x1) 0x217020 0x217000 0x21 0x0 0x0 0x0
4: え、配布コード間違えてね?と一瞬思ったけどそんなはずもなく、ただただ自分の無力を恨みながら寝ることにします、おやすみなさい
あとは上のbpfの演算表を用いて計算していくだけのような気がしたんですが、結局どういう状態になれば正解でどういう状態になると不正解なのかわかりませんでした。
一番最後の write 及びその直前は、以下のようなコードになっています。
705 prctl[GET_TID_ADDR] 0x28 0x217070 0x10 0x0 0x0 0x0 706 set_tid_address(0xda) 0x217020 0x217070 0x10 0x0 0x0 0x0 707 prctl[GET_TID_ADDR] 0x28 0x10 0x10 0x0 0x0 0x0 708 set_tid_address(0xda) 0xa 0x10 0x10 0x0 0x0 0x0 709 prctl[GET_TID_ADDR] 0x28 0x217020 0x10 0x0 0x0 0x0 70a write(0x1) 0x217020 0x217050 0x24 0x0 0x0 0x0 70b write(0x1) 0x217020 0x217000 0x21 0x0 0x0 0x0
おそらく最後のwrite2つは、入力値が正しくないとseccomp中の fd!=0 の条件(ここでfdはメモリ[0x10])で死ぬということだと思ったんですが。但し、その直前の706/707でメモリ[0x10]に0xA(改行)を入れる操作をしているため、最後のwriteは必ず条件を満たさず死ぬことになります。
ここで作問者のwriteupにあるバイトコード生成プログラムを見てみると、最後のwriteの直前で以下のようなことをしています。
put_val("\n".ord, INPUT_AT + INPUT_SIZE)
これによってflagの最後に改行を加えます。このput_valのコードを見てみると。
srand(333) # val must be 32-bit def put_val(val, addr) idx = rand(14) # 0~13 set_reg(idx, addr) scall(:set_tid_address, val) scall(:prctl, PR_GET_TID_ADDRESS, Reg.new(idx)) end
randで帰ってきた値のインデックスを持つレジスタを媒介にして操作を行っています。本来はここでreg[2]を除外するべきなような感じが。結果、生成されたバイトコードだとreg[2]を中継として使用することになっているので、reg[2]=0xAとなり、如何なる入力値を与えたとしても最後のwriteで死ぬようになっています。
ただ、このコードなかでrandは1回しか使われておらず、srand(333)の時の最初のrand(14)の値は12になるはずなので、なんで2がバイトコードなかで使われているのかちょっと不思議でした。まぁ、Rubyの乱数の仕組みよく知らんので知らんけど。
と思ったんですが、10+チームということはguessing要素がある確率は低いと思うので、多分僕の勘違いだと思います。
というわけで、結局author's writeupを見ても、結局何がどういう条件になればOKなのかわかりませんでした。シェルを取れば良いんでしょうか。誰かrevの解き方を教えてください。
なんか話が途中になりましたが、結局何がどうなれば正解なのかを突き止めるのに2時間くらい費やしてしまってもう眠くなったので寝ます。writeupは作問者様のgithubにあります。あとは、ひたすらやってる演算を逆演算するだけっぽいです。revむずいです。
良い問題でした。けどrev問はやっぱよくわからん。
おやすみなさい
続く...