イントロ Link to this heading

最近 CTF を辞めたので、いつぞや開催された HITCON CTF 2020 の rev 問であるSOPを解いていく。 rev 問、やっぱ、むずい。けど、ほえ〜となる良い問題でした。

問題概要 Link to this heading

バイトコードとそのインタプリタ本体が与えられる。本体の方はバイトコードを 8byte ずつ読み取り、そのオペコードに応じて RDI/RSI/RDX/R10/R8/R9 及び RAX に値を格納し、syscall を呼び出す。ひたすらこれを繰り返すプログラムである。

python で実装したエミュレータは以下の通り。

emulator.py
  1#!/usr/bin/env python
  2#encoding: utf-8;
  3
  4from pwn import *
  5import sys
  6
  7# this file is some thing like:
  8# 0       read
  9# 1       write
 10# 2       open
 11# 3       close
 12with open('syscalls.txt', 'r') as f:
 13  lines = f.readlines()
 14syscalls = []
 15for l in lines:
 16  syscalls.append(l.split("\t")[1])
 17
 18def ex(inst, n):
 19  a = "0b0"
 20  for i in range(n):
 21    a  = a+"1"
 22  return inst & int(a, 2)
 23
 24# read byte_code
 25with open('sop_bytecode', 'rb') as f:
 26  bytecode = f.read()
 27
 28ip = 0                      # pc in interpreter
 29rax = 0                     # rax
 30regs = [0, 0, 0, 0, 0, 0]   # rdi, rsi, rdx, r10, r8, r9
 31access_prctl = []           # for debug
 32inst_ex = []                # for debug
 33prctl_arg = []              # for debug
 34prctl_names = {0x28: "GET_TID_ADDR", 0x26: "SET_NO_NEW_PRIVS", 0x16: "PR_SET_SECCOMP", 0xf: "PR_SETNAME", 0x10: "PR_GET_NAME"}
 35
 36set_tid = 0                 # previously set clear_child_tid
 37#workspace = 0x60            # workspace on stack, used in sig_action handler
 38workspace = 0x50            # workspace on stack, used in sig_action handler
 39original_workspace = workspace
 40
 41# represents memory, both on stack and .bss region
 42class mem:
 43  def __init__(self):
 44    self.mem = {}
 45    self.initmem(self.mem, 0x217000, 0x100) # .bss
 46    self.initmem(self.mem, 0, 0x8*100)      # stack
 47
 48  def initmem(self, mem, start, size):
 49    for i in range(size):
 50      mem[start + i] = 0
 51
 52  def show(self):
 53    for addr, value in self.mem.items():
 54      if addr%8 == 0:
 55        print("\n{}\t".format(hex(addr)[2:].rjust(8,'0')), end=" ")
 56      print(hex(value)[2:].rjust(2,'0'), end=" ")
 57    print("")
 58
 59  def getmem(self, addr, size=8):
 60    val = 0
 61    for i in range(size):
 62      val += self.mem[addr+i] << (8*i)
 63    return val
 64
 65  def setmem(self, addr, value, size=8):
 66    for i in range(size):
 67      self.mem[addr+i] = (value>>(0x8*i)) & 0xFF
 68
 69  def setstr(self, addr, yourstr):
 70    for i in range(len(yourstr)):
 71      self.mem[addr+i] = ord(yourstr[i])
 72
 73m = mem()
 74
 75
 76flag = "A"*0x20
 77#flag = "hitcon{SysCallOP57289ca4ce57585}"
 78
 79# print regs
 80def pregs():
 81  global regs
 82  if syscalls[rax] not in inst_ex:
 83    inst_ex.append(syscalls[rax])
 84
 85  if syscalls[rax] != "prctl":
 86    print("{} {}({})".format(hex(ip//8)[2:].rjust(3,'0'), syscalls[rax].rjust(17,' '), hex(rax)), end=" ")
 87  else:
 88    print("{} {}[{}]".format(hex(ip//8)[2:].rjust(3,'0'), syscalls[rax].rjust(17,' '), prctl_names[regs[0]]), end=" ")
 89  for reg in regs:
 90    print(hex(reg), end=" ")
 91  print("")
 92
 93comm = [0, 0]         # current->comm(char[0x10])
 94
 95
 96######## start of emulation ######################
 97
 98while True:
 99  # handle ########################
100  def handle():
101    global rax
102    global regs
103    global set_tid
104    global comm
105    global mem
106    global workspace
107
108    if syscalls[rax] == "set_tid_address":
109      set_tid = regs[0]
110
111    if syscalls[rax] == "prctl":
112      if regs[0] == 0x28:   # gettid
113        if set_tid != 0:
114          m.setmem(regs[1], set_tid)
115          access_prctl.append(regs[1])
116      elif regs[0] == 0xf: # setname
117        comm = [m.getmem(regs[1]), m.getmem(regs[1] + 8)]
118      elif regs[0] == 0x10: # getname
119        m.setmem(regs[1], comm[0])
120        m.setmem(regs[1] + 8, comm[1])
121
122    if syscalls[rax] == "getgid":
123      res = regs[0] & regs[1]
124      res &= 0xFFFF
125      m.setmem(workspace, res, 2)
126      workspace += 2
127
128    if syscalls[rax] == "getuid":
129      res = regs[1] >> regs[0]
130      res &= 0xFFFF
131      m.setmem(workspace, res, 2)
132      workspace += 2
133
134    if syscalls[rax] == "getpid":
135      res = regs[1] + regs[0]
136      res &= 0xFFFF
137      m.setmem(workspace, res, 2)
138      workspace += 2
139
140    if syscalls[rax] == "getegid":
141      res = regs[1] - regs[0]
142      res &= 0xFFFF
143      m.setmem(workspace, res, 2)
144      workspace += 2
145
146    if syscalls[rax] == "getpgrp":
147      res = regs[1] * regs[0]
148      res &= 0xFFFF
149      m.setmem(workspace, res, 2)
150      workspace += 2
151
152    if syscalls[rax] == "getppid":
153      res = regs[1]
154      if regs[0] > 0x32:
155        res = 0
156      else:
157        for i in range(regs[0]):
158          res <<= 1
159          res &= 0xFFFF
160      m.setmem(workspace, res, 2)
161      workspace += 2
162
163    if syscalls[rax] == "geteuid":
164      res = regs[1] ^ regs[0]
165      res &= 0xFFFF
166      m.setmem(workspace, res, 2)
167      workspace += 2
168
169    if syscalls[rax] == "read":
170      assert(regs[0] == 0) # stdin
171      assert(len(flag)==0x20)
172      print("entering your input (maybe flag??) size 0x20\n")
173      m.setstr(regs[1], flag)
174
175    if syscalls[rax] == "rt_sigaction":
176      print("rt_sigaction is called now")
177      #m.show()
178
179  # end handle ########################
180
181  inst = u64(bytecode[ip: ip+8])
182  if inst == 0:
183    break
184  #for i in range(len(regs)):
185  #  regs[i] = 0
186
187  rax = ex(inst, 8)
188  inst = inst>>0x8
189
190  for i in range(6):
191    addr = 0
192    opc =  ex(inst, 2)
193    inst = inst >> 2
194
195    if(opc == 0):
196      addr = ex(inst, 4)
197      inst = inst >> 4
198      regs[i] = m.getmem(addr * 8)
199    elif(opc == 1):
200      offset = ex(inst, 4)
201      inst = inst >> 4
202      regs[i] = offset * 8 # stack memory base is regarded as 0 in this parser
203    elif(opc == 2):
204      tmp = ex(inst, 5)
205      inst = inst >> 5
206      regs[i] = ex(inst, tmp+1)
207      inst = inst >> (tmp+1)
208    else:
209      break
210
211
212  ip += 8
213  pregs()     # show registers
214  handle()    # handle syscall
215
216  # end of bytecode
217  if(ip >= len(bytecode)):
218    # dump memory
219    m.show()
220    print(inst_ex)
221    # dump message
222    for i in range(0x30):
223      print(chr(m.getmem(0x217050 + i, 1)), end="")
224    print("")
225    exit()
226
227  if ip//8 == 0x370:
228    m.show()
229    for i in range(0x80):
230      print(chr(m.getmem(original_workspace + i, 1)), end="")
231  if(ip % 0x100 == 0 and ip!=0):
232    print("PC: {}".format(hex(ip)))

bytecode Link to this heading

.txt
1001   set_tid_address(0xda) 0x217000 0x0 0x0 0x0 0x0 0x0
2002             prctl(0x9d) 0x28 0x0 0x0 0x0 0x0 0x0
3003              mmap(0x9) 0x217000 0x1 0x7 0x22 0x0 0x0
4004              read(0x0) 0x0 0x217000 0x20 0x22 0x217000 0x217000

最初に set_tid_address(0x21700) を呼んでいる。これは、current->clear_child_tid を指定した値に設定するシスコールである。

set_tid_address

set_tid_address

直後の prctl(GET_TID_ADDRESS) では先程格納した current->clear_child_tid を第 2 引数で指定したユーザランド領域にコピーする。 これによって、直接的に mov 命令を呼び出すことなく syscall 経由で値をメモリ中に移すことができる。今回の場合は、アドレス 0x0 に対して値 0x217000mov したことになる。 なお、プログラム中ではスタック中の一部の領域を作業領域として割り当てているが、python パーサにおいてはこのアドレスを 0x0 としている。

その後、アドレス 0x217000 を RWX で mmap() し、割り当てた領域に対してユーザから 0x20 だけ read() している。

.txt
 1005   set_tid_address(0xda) 0x217050 0x217000 0x217000 0x217000 0x217000 0x217000
 2006             prctl(0x9d) 0x28 0x60 0x217000 0x217000 0x217000 0x217000
 3007   set_tid_address(0xda) 0x217020 0x60 0x217000 0x217000 0x217000 0x217000
 4008             prctl(0x9d) 0x28 0x0 0x217000 0x217000 0x217000 0x217000
 5009   set_tid_address(0xda) 0x217054 0x0 0x217020 0x217020 0x217020 0x217020
 600a             prctl(0x9d) 0x28 0x60 0x217020 0x217020 0x217020 0x217020
 700b   set_tid_address(0xda) 0x0 0x60 0x217020 0x217020 0x217020 0x217020
 800c             prctl(0x9d) 0x28 0x0 0x217020 0x217020 0x217020 0x217020
 900d   set_tid_address(0xda) 0x217058 0x0 0x217020 0x217020 0x217020 0x217020
1000e             prctl(0x9d) 0x28 0x68 0x217020 0x217020 0x217020 0x217020
1100f   set_tid_address(0xda) 0x4000004 0x68 0x217020 0x217020 0x217020 0x217020
12010             prctl(0x9d) 0x28 0x0 0x217020 0x217020 0x217020 0x217020
13011   set_tid_address(0xda) 0x21705c 0x0 0x4000004 0x4000004 0x4000004 0x4000004
14012             prctl(0x9d) 0x28 0x18 0x4000004 0x4000004 0x4000004 0x4000004
15013   set_tid_address(0xda) 0x0 0x18 0x4000004 0x4000004 0x4000004 0x4000004
16014             prctl(0x9d) 0x28 0x4 0x4000004 0x4000004 0x4000004 0x4000004
17015   set_tid_address(0xda) 0x217060 0x4 0x4000004 0x4000004 0x4000004 0x4000004
18016             prctl(0x9d) 0x28 0x18 0x4000004 0x4000004 0x4000004 0x4000004
19017   set_tid_address(0xda) 0x217044 0x18 0x4000004 0x4000004 0x4000004 0x4000004
20018             prctl(0x9d) 0x28 0x4 0x4000004 0x4000004 0x4000004 0x4000004

その後はひたすら get_tid_address/prctl を繰り返すことでメモリ中に値を書き込んでいく。 これがインタプリタ中の IP==0x52 まで続き、その時点でのメモリは以下のような感じ。 (最初の入力として "A"*0x20 を与えた場合)

.txt
1053      rt_sigaction(0xd) 0x1f 0x217050 0x0 0x8 0xcccc050f 0x217050

この後、rt_sigaction が呼ばれる。int signum0x1F==SIG_SYS であり、struct sigaction *act0x217050 になっている。

sa_sigaction() は以下のような命令列である。

一番最初に RCX に入れる値が壊れているが、後々以下のように書き換えられる。

.S
12a2   set_tid_address(0xda) 0x30 0x0 0xffffffff 0xffffffff 0xffffffff 0xffffffff
22a3             prctl(0x9d) 0x28 0x217022 0xffffffff 0xffffffff 0xffffffff 0xffffffff
32a4   set_tid_address(0xda) 0xffffffff 0x217022 0xffffffff 0xffffffff 0xffffffff 0xffffffff
42a5             prctl(0x9d) 0x28 0x0 0xffffffff 0xffffffff 0xffffffff 0xffffffff
52a6   set_tid_address(0xda) 0x2172840000 0x0 0xffffffff 0xffffffff 0xffffffff 0xffffffff
62a7             prctl(0x9d) 0x28 0x8 0xffffffff 0xffffffff 0xffffffff 0xffffffff

結果として、先程のsig_actionは以下のように書き換えられる。

.c
1rcx = workspace; # R9レジスタの場所の次
2*(short*)rcx = *(short*)(rsi+4);
3*(long*)(0x217022) += 2;

書き換えられた結果、先程のアドレスはスタック上のメモリ領域になっていることが分かる。 ここは、インタプリタ内部の R9 レジスタの次の領域である。 この領域を特別に workspace と名付けておくことにする。 2 番目のコードによってworkspaceに 2byte 分データが書き込まれた後、 3 番目のコードによって 1 番目のコード自体のオペランド部分をを書き換えている。 これによって rcx=workspace の命令は rcx=workspace+2 となり、 このハンドラを実行する度に workspace + 2*n 番地に値を書き込むようになる(mutable instruction)。

.S
1295 prctl[PR_SET_SECCOMP] 0x16 0x2 0x217050 0x217274 0x217274 0x217274

また、rt_sigactionの直後に上のように seccomp が設定される。 RSI==0x2より、seccomp_modeSECCOMP_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 をした後は以下のように続く:

.S
 1296 set_tid_address(0xda) 0x69a33fff 0x2 0x217050 0x0 0x0 0x0
 2297 prctl[GET_TID_ADDR] 0x28 0x10 0x217050 0x0 0x0 0x0
 3298 set_tid_address(0xda) 0x468932dc 0x10 0x217050 0x0 0x0 0x0
 4299 prctl[GET_TID_ADDR] 0x28 0x18 0x217050 0x0 0x0 0x0
 529a set_tid_address(0xda) 0x2b0b575b 0x18 0x217050 0x0 0x0 0x0
 629b prctl[GET_TID_ADDR] 0x28 0x20 0x217050 0x0 0x0 0x0
 729c set_tid_address(0xda) 0x1e8b51cc 0x20 0x217050 0x0 0x0 0x0
 829d prctl[GET_TID_ADDR] 0x28 0x28 0x217050 0x0 0x0 0x0
 929e prctl[PR_SETNAME] 0xf 0x217000 0x217050 0x0 0x0 0x0
1029f prctl[PR_GET_NAME] 0x10 0x30 0x217050 0x0 0x0 0x0
112a0 set_tid_address(0xda) 0xffffffff 0x30 0x217050 0x0 0x0 0x0

重要なのは prctl[PR_SETNAME] のところで、引数が最初に read() でユーザから入力した値になっている。 これによって、current->comm がユーザ入力値になる。(commchar[TASK_COMM_LEN==0x10]だから入力値の半分だけがプロセス名になる)

そのあと、入力値をメモリ(これは、スタック上に確保されるメモリ)のオフセット0x30prctl[PR_GETNAME] している。 このアドレスは、先程のsig_action()の一番最初の命令で読み込まれるアドレスであった。

これ以降は、先程 seccomp で設定したシスコールを呼ぶことで諸々の演算を行ったり、 上に示したようにGETNAMEで値を 8byte まるごとコピーしたりしながら続いていく。 一番最後に以下のように write を呼んで終わり:

.S
170a write(0x1) 0x217020 0x217050 0x24 0x0 0x0 0x0
270b write(0x1) 0x217020 0x217000 0x21 0x0 0x0 0x0

え、配布コード間違えてね?と一瞬思ったけどそんなはずもなく、ただただ自分の無力を恨みながら寝ることにします、おやすみなさい Link to this heading

あとは上の bpf の演算表を用いて計算していくだけのような気がしたんですが、結局どういう状態になれば正解でどういう状態になると不正解なのかわかりませんでした。

一番最後の write 及びその直前は、以下のようなコードになっています:

.S
1705             prctl[GET_TID_ADDR] 0x28 0x217070 0x10 0x0 0x0 0x0
2706   set_tid_address(0xda) 0x217020 0x217070 0x10 0x0 0x0 0x0
3707             prctl[GET_TID_ADDR] 0x28 0x10 0x10 0x0 0x0 0x0
4708   set_tid_address(0xda) 0xa 0x10 0x10 0x0 0x0 0x0
5709             prctl[GET_TID_ADDR] 0x28 0x217020 0x10 0x0 0x0 0x0
670a             write(0x1) 0x217020 0x217050 0x24 0x0 0x0 0x0
770b             write(0x1) 0x217020 0x217000 0x21 0x0 0x0 0x0

おそらく最後のwrite2 つは、入力値が正しくないと seccomp 中の fd!=0 の条件(ここでfdメモリ[0x10])で死ぬということだと思ったんですが。 但し、その直前の706/707メモリ[0x10]0xA(改行)を入れる操作をしているため、 最後の write は必ず条件を満たさず死ぬことになります。

ここで作問者の writeup にあるバイトコード生成プログラムを見てみると、最後のwriteの直前で以下のようなことをしています:

.rb
1put_val("\n".ord, INPUT_AT + INPUT_SIZE)

これによって flag の最後に改行を加えます。このput_valのコードを見てみると:

.rb
1srand(333)
2# val must be 32-bit
3def put_val(val, addr)
4  idx = rand(14) # 0~13
5  set_reg(idx, addr)
6  scall(:set_tid_address, val)
7  scall(:prctl, PR_GET_TID_ADDRESS, Reg.new(idx))
8end

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 問はやっぱよくわからん。 おやすみなさい。