這次 33C3 CTF 的時間還蠻糟糕的,不是在週末,只能用空閒的時間看看 reverse 題
可惜還是沒在結束前解完,花了點時間完成後決定記錄一下
同也是我首次拿 angr
來解 CTF 題目
每年 3XC3 CTF 都會解到一些蠻有趣的題目
然後獲得一些不曉得可以用在哪裡的技能
但這就是 CTF 的有趣之處吧 XD
0x00 - Challenge
How do you feel about figuring out a really scary beast of a retro console music file?
You are invited to use the VM we prepared for you, which contains a player and the song file.Solves: 8
0x01 - SNES and SPC700
- 題目給了個 SNES 遊戲使用的
SPC700
格式音訊檔,跟播放用的 Emulatorgme_player
:
play_me.spc: SNES SPC700 sound file, version 0.30, with ID666 tag, song "PUT FLAG HERE TO PLAY MUSIC", game "33C3 CTF"
- 還很好心的告訴你模擬器是從哪一個 commit build 來的:
We cloned game-music-emu from git@bitbucket.org:mpyne/game-music-emu.git,
commit 2cbb70f3c27412db7e54ca65fa1a3fac3f6a7d64 to build libgme and gme_player.
- 考古發現在這之後的 commit 修正了
libgme
中SPC700
CPU Emulator 的安全性問題:
Redux: compromising Linux using... SNES Ricoh 5A22 processor opcodes?!
配合題目敘述可以猜測大概會透過這個 exploit 去執行某些 code,但在題目提供的 VM 中執行後似乎沒做什麼特別的事情,只好繼續分析
0x02 - Reverse
參考 SPC File Format 嘗試分析 play_me.spc
- 有
PC
,A
,X
,Y
4 個 Register +0x0025
為PC Register
: 得知Entry Point
位於0xf100
+0x0100
開始為64KB RAM
,因此執行代碼應在檔案0xf200
處
IDA Pro
從 6.9
之後才開始支援 SPC700
CPU ... 只好尋找其他的 disassembler,同時修改 libgme
來分析程式行為,花了非常久的時間才將全部的 code 分析完: https://gist.github.com/L4ys/09878e88d1dd344e2ee854946d46c96e
play_me.spc
會透過 libgme
中的漏洞來將控制程式流程, 最後呼叫一段 shellcode 來檢查 ID666 tag
上的 Song Title
是否為正確 Flag,若通過則將 spc 檔案的 Entry Point
改為 0x06c2
,開始播放 super mario
的音樂
0x03 - SPC Exploit
為了分析 spc 行為,理解 exploit 原理還是必要的
事實上可以跳過這部分,但總之我還是寫了...
MOV (X)+,A
op code 0xAF
的指令對於 Register X
的遞增範圍沒有限制
由於 X
是存放於 int
中, 因此可以透過重複的 MOV (X)+,A
指令使 X
的值 out of bound:
int x = m.cpu_regs.x;
...
case 0xAF: // MOV (X)+,A
WRITE_DP( 0, x, a + no_read_before_write );
x++;
goto loop;
比對 op code 0xBF
中則會將 x
限制在 8bit 的範圍內:
case 0xBF:{// MOV A,(X)+
int temp = x + dp;
x = (uint8_t) (x + 1);
a = nz = READ( -1, temp );
goto loop;
但事實上,我們無法透過 X
做 out-of-bound write 來寫入 RAM 以外的地方, 原因是為了要讓 CPU 和 Audio Generation 同步進行,當 CPU
執行了一定的週期 (約 32768 cycles) 之後, 會先將暫存器保存,切換至 Audio Generator Routine
執行
而儲存 Register 的程式碼如下,X
在此會被限制回 8bit 的大小:
m.cpu_regs.pc = (uint16_t) GET_PC();
m.cpu_regs.sp = ( uint8_t) GET_SP();
m.cpu_regs.a = ( uint8_t) a;
m.cpu_regs.x = ( uint8_t) x;
m.cpu_regs.y = ( uint8_t) y;
但在有限的 CPU cycle 中,我們還是可以拿 out-of-bound 的 X
做點事情, 透過 MOV 0xFFFF,+X, A
似乎也能做到 out-of-bound write
但看看 Snes_Spc.h
:
struct state_t
{
...
struct
{
// padding to neutralize address overflow
union {
uint8_t padding1 [0x100];
uint16_t align; // makes compiler align data for 16-bit access
} padding1 [1];
uint8_t ram [0x10000];
uint8_t padding2 [0x100];
} ram;
};
在 RAM 前後存在各 0x100
bytes 的 padding
來防止 address overflow,因此無法直接透過 MOV 0xFFFF,+X, A
來寫入 RAM 以外的位置。 但配合前面的方法先讓 X
overflow 之後,再配合 MOV 0xFFFF, +X, A
就可以讓 X
的值超過 0x10000 + 0x100
,就能夠實現 out-of-bound write!
不過很不幸的,在 Spc_Cpu.cpp
中,當寫入的 offset
大於 0x10000
時,會以 cpu_pad_fill
(0xff) 取代原本要寫的值 ...
void Snes_Spc::cpu_write_high( int data, int i, rel_time_t time )
{
if ( i < rom_size )
{
m.hi_ram [i] = (uint8_t) data;
if ( m.rom_enabled )
RAM [i + rom_addr] = m.rom [i]; // restore overwritten ROM
}
else
{
assert( RAM [i + rom_addr] == (uint8_t) data );
RAM [i + rom_addr] = cpu_pad_fill; // restore overwritten padding
cpu_write( data, i + rom_addr - 0x10000, time );
}
}
雖然無法直接做到任意寫,但 out-of-bound 的 X
本身還是可以被利用的
MUL / DIV 大部分的指令對於操作 Register 之後的結果都有將範圍限制在 8bit 中,但仍然存在一些例外:
case 0xCF: { // MUL YA
unsigned temp = y * a;
a = (uint8_t) temp;
nz = ((temp >> 1) | temp) & 0x7F;
y = temp >> 8; // <-- !!
nz |= y;
goto loop;
MUL
指令會將 Y
跟 A
相乘,得到 16bit 的結果,並將結果分為高低位各 8bit
存回 Y
跟 A
, 其中 Y
的結果並沒有被限制在 8bit 內,正常情況下兩個 8bit 的值相乘的最大結果為 0xff * 0xff
== 0xfe01
, 並不會發生 overflow 的問題,但如果我們可以把 out-of-bound 的 X
assign 給 Y
及 A
,再透過 MUL
指令就可以在有限的 CPU Cycle 中產生出比 X
更大的 Y
:
- 透過
MOV (X)+,A
指令將X
遞增至512
- 透過
MOV A,X
,MOV Y,A
指令將A
跟Y
也設為512
- 執行
MUL
指令,將Y = (512*512) >> 8 = 1024
,存進X
- 再將
Y
,A
設為X
,MUL
後,Y = (1024*1024) >> 8 = 4096
- 重複以上操作,最後可以得到
X = (65536*65536)>>8 = 16777216
,約為16MB
DIV
指令跟 MUL
一樣不會限制運算後 Y
的範圍大小:
case 0x9E: // DIV YA,X
{
unsigned ya = y * 0x100 + a;
...
if ( y < x * 2 )
{
a = ya / x;
y = ya - a * x; // <-- !!
}
else
{
a = 255 - (ya - x * 0x200) / (256 - x);
y = x + (ya - x * 0x200) % (256 - x); // <-- !!
}
...
a = (uint8_t) a;
根據 exploit 作者的測試,Register 中傳進特定的值可以讓 Y
變成負數:
- Input:
A = 0
,X = 257
,Y = 16777215
- Output:
A = 255
,X = 257
,Y = -131583
如此一來,便可以透過 Y
去讀寫 RAM 之前的資料,而 RAM 位於 Snes_Spc
Object 的底部,往前可以存取到 Snes_Spc
Object 中的不少資料,其中也包含 vtable pointer, 此外往前讀寫也不會受到 Snes_Spc::cpu_write_high
中 address 大於 0x10000
時無法控制寫入值的限制,漏洞的利用變得容易許多:
Spc_Emu
Object read/write:
- 透過
MOV (X)+,A
將X
遞增至511
,將Y
,A
設為511
- 執行
MUL
,Y = (511 * 513) >> 8 = 0x3ff
- 重複以上兩步,
2*^n+1
*2^n-1
,Y
會從0x3ff
變為0xfff
,0xffff
,最後變成0xffffff
- 執行
DIV
來產生一個負數,A = 0
,X = 257
,Y = 16777215
,得到Y = -131583
- 再執行一次
DIV
,A = X = Y = -131583
,會得到Y = -65024
- 接著就可以透過指令讀寫
Spc_Emu
Object 中的內容到 RAM 上,或是寫入
Arbitrarily Read:
- 將想要讀取的 address 寫進
Spc_Emu::buf_begin
, address + 8 寫進Spc_Emu::buf_end
- 寫掉
Spc_Emu::extra_clocks
使其在Snes_Spc::end_frame
中為一個接近0
的值 - 將
Spc_Emu::dsp_time
寫為0
防止Digital Sound Processor
執行,避免錯誤發生 - 執行迴圈耗盡剩下的 CPU Cycle
- 當 freame 結束時,
Spc_Emu::buf_begin
所指向位置的 8byte 會被讀取至Spc_Emu::extra_buf
上 - 讀出
Spc_Emu::extra
就是任意位置讀取了
後續可以寫掉 vtable 來控制程式流程
0x04 - play_me.spc
了解所有的原理後可以總結一下題目的 play_me.spc
行為:
- 讀取
Spc_Emu
Object 的 vtable address 到RAM + 0x20
- 讀取
Spc_Dsp::ram
,將RAM
的 address 寫到RAM + 0x28
- 讀取
ram[-4580]
上的 address (指向 spc 檔案內容) 到RAM + 0x30
- 讀取 spc 檔案 offset 0x2e 處的 32 位 Flag 字串到
RAM + 0xa0
- 讀取
Spc_Emu.vtable + 16 + 0x718
(free@got
) ,得到libc
中free()
的 address - 讀取
Spc_Emu.vtable + 16 + 0x738
(fread@got
) ,得到libc
中fread()
的 address - 計算
free()
-fread()
的 offset - 將
RAM + 0xa0
上的 Flag 每三個 byte 一組,加上上一步的 offset - 檢查
RAM + 0xa0
上的 Flag 前五位是否為0x92, 0x6b, 0x44, 0x92, 0x97
(以這五位減去"33C3_"
可以得到 offset 為0x01385F
) - 計算出
libc
中setcontext() + 0x35
的 address,寫到Ram + 0x80
- 計算出
libc
中mprotect()
的 address - 將 spc 中的一段 shellcode ( 位於檔案 offset + 0xfc00 ) 的 address 寫到
RAM + 0x70
- 寫入一些值到
Spc_Emu
Object 中,並修改Spc_Emu.vtable
指向RAM
Spc_Emu::play_()
執行時將會 callRAM + 0x80
(setcontext gadget
)- 最後執行
mprotect(&ram & 0xffffff00, 7, 0x100000)
,並跳轉到RAM + 0x70
指向的 shellcode 上
0x05 - Shellcode
Dump 出位於檔案 + 0xfc00
的 shellcode
:
https://gist.github.com/L4ys/fd2cb02417f21357ab83ddd1f412da78
經過分析後得知 會將 RAM + 0xa0
上的 Flag 透過 SSE 指令做 42 輪的神奇運算,之後跟特定值比對, 若相等則將 spc 寫入到 /dev/shm/r12j2x
,並修改 spc 的 Entry Point
為 0x06c2
, 接著透過 gme_player
播放出 Super Mario
的音樂
z3
跟 KLEE
在這種情況也不太適用,最後嘗試用 angr
來解:
(能直接對 shellcode binary 做 symbolic execution 真是挺方便的)
#!/usr/bin/env python
import angr
proj = angr.Project("./shellcode.bin", load_options = {
'main_opts': {
'backend': 'blob',
'custom_arch': 'x86_64',
},
})
s = proj.factory.blank_state(addr=0x3a, add_options={'BYPASS_UNSUPPORTED_SYSCALLS'})
s.mem[s.regs.rbp+160:] = s.se.BVS("a1", 16*8)
s.mem[s.regs.rbp+176:] = s.se.BVS("a2", 16*8)
pg = proj.factory.path_group(s)
pg.explore(find=0x116, avoid=0x238)
found = pg.found[0]
v1 = found.state.memory.load(found.state.regs.rbp+160, 16)
v2 = found.state.memory.load(found.state.regs.rbp+176, 16)
flag1 = found.state.se.any_n_int(v1, 16)[0]
flag2 = found.state.se.any_n_int(v2, 16)[0]
flag = hex(flag1)[2:-1] + hex(flag2)[2:-1]
print "encoded flag: " + flag
flag = flag.decode('hex')
key = [0x5f, 0x38, 0x01]
flag = "".join(chr(ord(c) - key[i % 3]) for i, c in enumerate(flag))
print flag
Flag: 33C3_inb4_SNES_c0mes_b4ck_h4rd:>