Published on

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

  • 題目給了個 SNES 遊戲使用的 SPC700 格式音訊檔,跟播放用的 Emulator gme_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.

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


0x02 - Reverse

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

  • PC, A, X, Y 4 個 Register
  • +0x0025PC Register: 得知 Entry Point 位於 0xf100
  • +0x0100 開始為 64KB RAM ,因此執行代碼應在檔案 0xf200
遺憾的是 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 做 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 指令會將 YA 相乘,得到 16bit 的結果,並將結果分為高低位各 8bit 存回 YA, 其中 Y 的結果並沒有被限制在 8bit 內,正常情況下兩個 8bit 的值相乘的最大結果為 0xff * 0xff == 0xfe01, 並不會發生 overflow 的問題,但如果我們可以把 out-of-bound 的 X assign 給 YA,再透過 MUL 指令就可以在有限的 CPU Cycle 中產生出比 X 更大的 Y:

  • 透過 MOV (X)+,A 指令將 X 遞增至 512
  • 透過 MOV A,X, MOV Y,A 指令將 AY 也設為 512
  • 執行 MUL 指令,將 Y = (512*512) >> 8 = 1024,存進 X
  • 再將 Y, A 設為 XMUL 後, 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)+,AX 遞增至 511,將 Y, A 設為 511
  • 執行 MULY = (511 * 513) >> 8 = 0x3ff
  • 重複以上兩步, 2*^n+1 * 2^n-1Y 會從 0x3ff 變為 0xfff, 0xffff,最後變成 0xffffff
  • 執行 DIV 來產生一個負數,A = 0, X = 257, Y = 16777215,得到 Y = -131583
  • 再執行一次 DIVA = 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) ,得到 libcfree() 的 address
  • 讀取 Spc_Emu.vtable + 16 + 0x738 (fread@got) ,得到 libcfread() 的 address
  • 計算 free() - fread() 的 offset
  • RAM + 0xa0 上的 Flag 每三個 byte 一組,加上上一步的 offset
  • 檢查 RAM + 0xa0 上的 Flag 前五位是否為 0x92, 0x6b, 0x44, 0x92, 0x97 (以這五位減去 "33C3_" 可以得到 offset 為 0x01385F )
  • 計算出 libcsetcontext() + 0x35 的 address,寫到 Ram + 0x80
  • 計算出 libcmprotect() 的 address
  • 將 spc 中的一段 shellcode ( 位於檔案 offset + 0xfc00 ) 的 address 寫到 RAM + 0x70
  • 寫入一些值到 Spc_Emu Object 中,並修改 Spc_Emu.vtable 指向 RAM
  • Spc_Emu::play_() 執行時將會 call RAM + 0x80 ( setcontext gadget )
  • 最後執行 mprotect(&ram & 0xffffff00, 7, 0x100000) ,並跳轉到 RAM + 0x70 指向的 shellcode 上

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:>