33C3 CTF - mario

這次 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

play_me.spc: SNES SPC700 sound file, version 0.30, with ID666 tag, song "PUT FLAG HERE TO PLAY MUSIC", game "33C3 CTF"

We cloned game-music-emu from git@bitbucket.org:mpyne/game-music-emu.git,
commit 2cbb70f3c27412db7e54ca65fa1a3fac3f6a7d64 to build libgme and gme_player.

配合題目敘述可以猜測大概會透過這個 exploit 去執行某些 code
但在題目提供的 VM 中執行後似乎沒做什麼特別的事情,只好繼續分析


0x02. Reverse

參考 SPC File Format 嘗試分析 play_me.spc

遺憾的是 IDA Pro6.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 遞增到非常大並透過 X 做 out-of-bound write 來寫入 64KB Ram 以外的地方
原因是為了要讓 CPUaudio generation 同步進行
CPU 執行了一定的週期 ( 約 32768 cycles ) 之後
會先將暫存器保存,切換至 audio generator routine 處理
而儲存 Register 的 code 如下:

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;

此時 x 就會被限制回 8bit 的大小
但在有限的 CPU cycle 中,我們還是可以拿 out-of-bound 的 X 做點事情
先不討論 x 的 out-of-bound
單純透過 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;
};

64k Ram 前後存在各 0x100 bytes 的 padding 來防止 overflow
因此沒辦法直接透過 MOV 0xFFFF,+X, A 來寫入 Ram 以外的地方

但配合前面的方法先讓 x out-of-bound 之後,
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 中,但 MUL 例外:

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 指令會將 YA 相乘,得到 16bit 的結果,
並將結果分為高低位各 8bit 存回 YA
其中 Y 的結果並沒有被限制在 8bit 內,
正常情況下兩個 8bit 的值相乘的最大結果為 0xff * 0xff == 0xfe01
並不會發生 overflow 的問題,
但如果我們可以把 out-of-bound 的 X assign 給 YA
再透過 MUL 指令就可以在有限的 CPU Cycle 中產生出比 X 更大的 Y:

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 變成負數:

如此一來,便可以透過 Y 去讀寫 Ram 之前的資料
Ram 位於 Snes_Spc Object 的底部
往前可以存取到 Snes_Spc Object 中的不少資料
其中也包含 vtable pointer!

此外往前讀寫也不會受到 Snes_Spc::cpu_write_high 中 address 大於 0x10000 時無法控制寫入值的限制
漏洞的利用變得容易許多:

Spc_Emu Object read/write:

Arbitrarily Read:

後續可以寫掉 vtable 來控制程式流程


0x04. play_me.spc

了解所有的原理後可以總結一下題目的 play_me.spc 行為:

.text:0000000000041BD5                 mov     rsp, [rdi+0A0h]
.text:0000000000041BDC                 mov     rbx, [rdi+80h]
.text:0000000000041BE3                 mov     rbp, [rdi+78h]
.text:0000000000041BE7                 mov     r12, [rdi+48h]
.text:0000000000041BEB                 mov     r13, [rdi+50h]
.text:0000000000041BEF                 mov     r14, [rdi+58h]
.text:0000000000041BF3                 mov     r15, [rdi+60h]
.text:0000000000041BF7                 mov     rcx, [rdi+0A8h]
.text:0000000000041BFE                 push    rcx
.text:0000000000041BFF                 mov     rsi, [rdi+70h]
.text:0000000000041C03                 mov     rdx, [rdi+88h]
.text:0000000000041C0A                 mov     rcx, [rdi+98h]
.text:0000000000041C11                 mov     r8, [rdi+28h]
.text:0000000000041C15                 mov     r9, [rdi+30h]
.text:0000000000041C19                 mov     rdi, [rdi+68h]
.text:0000000000041C1D                 xor     eax, eax
.text:0000000000041C1F                 retn

0x05. Shellcode

Dump 出位於檔案 + 0xfc00shellcode:
https://gist.github.com/L4ys/fd2cb02417f21357ab83ddd1f412da78

經過分析後得知
會將 Ram + 0xa0 上的 Flag 透過 SSE 指令做 42 輪的神奇運算,之後跟特定值比對
若相等則將 spc 寫入到 /dev/shm/r12j2x ,並修改 spc 的 Entry Point0x06c2
接著透過 gme_player 播放出 Super Mario 的音樂

這裡不得不說,大量的 SSE 指令要單靠手動逆向真的非常困難…

z3KLEE 在這種情況也不太適用,最後嘗試用 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:>