newbieからバイナリアンへ

newbieからバイナリアンへ

昨日は海を見に行きました

【rev 7.0】SOP - HITCON CTF 2020

keywords

syscall oriented programming / hitcon / rev / seccomp

 

 

1: イントロ

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

 

2: 問題概要

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

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

emulator.py
#!/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 を指定した値に設定するシスコールである。

f:id:smallkirby:20201130161425p:plain

set_tid_address

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

f:id:smallkirby:20201130163555p:plain

 

053      rt_sigaction(0xd) 0x1f 0x217050 0x0 0x8 0xcccc050f 0x217050

この後、rt_sigaction が呼ばれる。int signum は 0x1F==SIG_SYS であり、struct sigaction *act は 0x217050 になっている。

f:id:smallkirby:20201130164409p:plain

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

f:id:smallkirby:20201130164548p:plain

 

一番最初に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は以下のように書き換えられる。

f:id:smallkirby:20201130170708p:plain

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をディスアセンブルすると以下の通り。

f:id:smallkirby:20201130173251p:plain

f:id:smallkirby:20201130173520p:plain

(書いてて気づいたけど、この問題の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]だから入力値の半分だけがプロセス名になる)

f:id:smallkirby:20201130175349p:plain

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

f:id:smallkirby:20201130175542p:plain

 

 

これ以降は、先程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むずいです。

github.com

 

良い問題でした。けどrev問はやっぱよくわからん。
おやすみなさい








続く...

 

 

 

 

 

 

You can cite code or comments in my blog as you like basically.
The exceptions are when the code belongs to some other license. In that case, follow it. Also, you can't use them for evil purpose. Finally, I don't take any responsibility for using my code or comment.
If you find my blog useful, I'll appreciate if you leave comments.