Chip8 Emulator - 0xFUN CTF Write Up

My team's write up for 0xFUN contest

Problem link: https://ctf.0xfun.org/challenges#Chip8%20Emulator-70 Category: Reverse Engineering Points: 250 Level: Medium

Problem Description

Ever wondered how emulators tick under the hood? I built one — the simplest of all, a CHIP-8 emulator. Alongside it, I’ve dropped 100+ games and programs for you to play… or are they really only for playing?

Somewhere deep in this virtual silicon, a flaw hides. Uncover it, and in just quad cycles, the flag is yours. Miss it, and you’ll be stuck endlessly.

Overall

Challenge này cung cấp một CHIP-8 emulator được viết bằng C++ và player cần tìm flag ẩn trong emulator.

Kiểm tra chương trình này bằng command file, ta có kết quả như sau:

$ file chip8Emulator
chip8Emulator: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=fb6d85ef675a37aec6b2457e8a8f18d303362bb2, for GNU/Linux 3.2.0, not stripped

Ta nhận thấy đây là một file PIE, tức address sẽ bị thay đổi trong mỗi lần chạy, và file này chưa bị strip.

Tiếp tục kiểm tra các thư viện được sử dụng, ta có phát hiện sau:

$ ldd chip8Emulator
    linux-vdso.so.1 (0x00007ffec57f7000)
    libSDL2-2.0.so.0 => /lib/x86_64-linux-gnu/libSDL2-2.0.so.0 (0x00007ac008a2b000)
    libcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3 (0x00007ac008400000)
    libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007ac008000000)
    libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007ac008942000)
    ......

Từ kết quả trả về, ta nhận thấy binary có sử dụng libcrypto.so.3 (OpenSSL). Điều này cho thấy quá trình encode/decode có diễn tra trong binary này. Tiếp tục sử dụng command strings để kiểm tra, ta có được kết quả như sau:

strings chip8Emulator | grep -i "flag\|key\|encrypt\|decrypt\|aes"
EVP_DecryptFinal_ex
EVP_DecryptInit_ex
BIO_set_flags
EVP_DecryptUpdate
EVP_aes_256_cbc
11KeyboardSDL
8Keyboard
keyboardsdl.cpp
key.cpp
_ZN11KeyboardSDL6updateEPhPb
_ZN11KeyboardSDLD1Ev
_ZTS8Keyboard
_ZN8KeyboardC2Ev
BIO_set_flags@OPENSSL_3.0.0
emu_key
_ZN3Cpu29skip_next_inst_if_key_pressedEv
_ZN8Emulator11setKeyboardEP8Keyboard
_ZTV11KeyboardSDL
_ZTI11KeyboardSDL
_ZN11KeyboardSDLC1Ev
EVP_DecryptInit_ex@OPENSSL_3.0.0
_ZN3Cpu14wait_key_pressEv
_ZN8KeyboardC1Ev
_ZN11KeyboardSDLD2Ev
EVP_aes_256_cbc@OPENSSL_3.0.0
EVP_DecryptFinal_ex@OPENSSL_3.0.0
_ZTI8Keyboard
_ZN11KeyboardSDL6deinitEv
_ZN11KeyboardSDLC2Ev
_ZTV8Keyboard
_ZTS11KeyboardSDL
_ZN11KeyboardSDL4initEv
EVP_DecryptUpdate@OPENSSL_3.0.0
keyaSEr
baeS6

Ta có thể tìm thấy nhiều hàm OpenSSL như: EVP_aes_256_cbc, EVP_DecryptInit_ex,….

Analyzing

Thực hiện decompile binary gốc với IDA và sub lại, ta có flow của binary này như sau:

#include <iostream>
#include <string>
#include <memory>
#include "Emulator.h"
#include "DisplaySDL.h"
#include "KeyboardSDL.h"
#include "SoundSDL.h"
#include "CmdLineParser.h"
#include "Logger.h"

int main(int argc, char** argv) {
    // Quản lý Logger qua shared_ptr (v10 trong decompile)
    std::shared_ptr<Logger> logger = Logger::getInstance();
    
    // Khởi tạo bộ phân tích tham số dòng lệnh
    CmdLineParser parser;
    parser.parseCmdLine(argc, argv);

    // Thiết lập mức độ Log (LogLevel) nếu được người dùng chỉ định
    if (parser.isLogLevelSet()) {
        int level = parser.getLogLevel();
        switch (level) {
            case 0: logger->setLogLevel(LogLevel::TRACE); break;
            case 1: logger->setLogLevel(LogLevel::DEBUG); break;
            case 2: logger->setLogLevel(LogLevel::INFO);  break;
            case 3: logger->setLogLevel(LogLevel::WARN);  break;
            case 4: logger->setLogLevel(LogLevel::ERROR); break;
            default: break;
        }
    }

    // Kiểm tra xem đường dẫn ROM có được cung cấp hay không
    if (!parser.isRomFileNameSet()) {
        logger->log("No rom path provided", LogLevel::DEBUG);
        exit(1);
    }

    // Khởi tạo các thành phần phần cứng qua giao diện SDL
    DisplaySDL display;   // Quản lý hiển thị màn hình
    KeyboardSDL keyboard; // Quản lý nhập liệu bàn phím
    SoundSDL sound;       // Quản lý âm thanh

    // Khởi tạo lõi giả lập và kết nối các thiết bị ngoại vi
    Emulator emulator;
    emulator.setDisplay(&display);
    emulator.setKeyboard(&keyboard);
    emulator.setSound(&sound);

    // Lấy tên file ROM và bắt đầu quá trình giả lập
    std::string romPath = parser.getRomFileName();
    
    if (emulator.init(romPath)) {
        emulator.run();     // Vòng lặp chính của giả lập
        emulator.deinit();  // Giải phóng tài nguyên
    }

    // Các đối tượng tự động gọi Destructor khi ra khỏi phạm vi hàm main
    return 0;
}

Từ mã nguồn assembly, ta xác định được các class chính của binary này. Cụ thể:

  • Class emulator tại 0x5CA2:
      .text:0000000000005CA2 ;   try {
      .text:0000000000005CA2                 call    _ZN8EmulatorC2Ev ; Emulator::Emulator(void)
      .text:0000000000005CA2 ;   } // starts at 5CA2
    
  • Class cpu (các hàm thuộc class cpu có dạng _ZN3Cpu....()):
      LOAD:0000000000000000 ; Source File : 'cpu.cpp'
    
  • Class RomLoader (các hàm thuộc class RomLoader có dạng _ZN9RomLoader...()):
      LOAD:0000000000000000 ; Source File : 'romLoader.cpp'
    
  • Còn lại là các class I/O Interfaces:
      .text:0000000000005C75 ;   try {
      .text:0000000000005C75                 call    _ZN10DisplaySDLC2Ev ; DisplaySDL::DisplaySDL(void)
      .text:0000000000005C75 ;   } // starts at 5C75
      .text:0000000000005C7A                 lea     rax, [rbp+var_1980]
      .text:0000000000005C81                 mov     rdi, rax        ; this
      .text:0000000000005C84 ;   try {
      .text:0000000000005C84                 call    _ZN11KeyboardSDLC1Ev ; KeyboardSDL::KeyboardSDL(void)
      .text:0000000000005C84 ;   } // starts at 5C84
      .text:0000000000005C89                 lea     rax, [rbp+var_1960.resource_mask]
      .text:0000000000005C90                 mov     rdi, rax        ; this
      .text:0000000000005C93 ;   try {
      .text:0000000000005C93                 call    _ZN8SoundSDLC1Ev ; SoundSDL::SoundSDL(void)
      .text:0000000000005C93 ;   } // starts at 5C93
    

Process of creating flag.txt

Thực tế trong CHIP-8 chuẩn, các instruction bắt đầu bằng Fxxx xử lý timer, keyboard, BCD, memory trong CPU. Tuy nhiên, khi phân tích hàm decode_F_instruction tại địa chỉ 0xE716:

.text:000000000000E744                 cmp     eax, 0FFh
.text:000000000000E749                 jz      loc_E84C
...


.text:000000000000E84C loc_E84C:                               ; CODE XREF: Cpu::decode_F_instruction(void)+33↑j
.text:000000000000E84C                 mov     rax, [rbp+var_68]
.text:000000000000E850                 mov     rdi, rax        ; this
.text:000000000000E853                 call    _ZN3Cpu16superChipRendrerEv ; Cpu::superChipRendrer(void)
.text:000000000000E858                 jmp     short loc_E8D1

Ở đây, binary sẽ thực hiện kiểm tra nếu lower_byte == 0xFF, máy tính sẽ thực hiện nhảy tới loc_E84C để gọi hàm đặc biệt _ZN3Cpu16superChipRendrerEv(). Thực hiện phân tích hàm _ZN3Cpu16superChipRendrerEv():

  • Hàm này sẽ nhận data và thực hiện Decode Base64:
      .text:000000000000E9D7 ;   try {
      .text:000000000000E9D7                 call    _ZN3Cpu12base64DecodeERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE ; Cpu::base64Decode(std::string const&)
      .text:000000000000E9D7 ;   } // starts at E9D7
    
  • Sau khi decode, hàm sẽ lấy IV (Initialization Vector) bằng cách lấy 16 bytes đầu của dữ liệu Decode Base64:
      .text:000000000000EA01                 call    _ZNK9__gnu_cxx17__normal_iteratorIPhSt6vectorIhSaIhEEEplEl ; __gnu_cxx::__normal_iterator<uchar *,std::vector<uchar>>::operator+(long)
    
    • Tại step này, toán tử cộng con trỏ sẽ được sử dụng (operator + 0x10/16) để tách phần IV và ciphertext
    • Lúc này, pointer sẽ trỏ tới vị trí sau 16 byte đầu (tức là bắt đầu của ciphertext).
    • IV sẽ là 16 byte đầu tiên của mảng đã decode.
  • Sau khi lấy được IV, binary sẽ thực hiện giải mã AES-256-CBC với key emu_key:
      .text:000000000000EA99                 call    _EVP_CIPHER_CTX_new      // call function to get context of AES
      ...
      .text:000000000000EAB7                 call    _EVP_aes_256_cbc     // call init function
      ...
      .text:000000000000EAD8                 call    _EVP_DecryptInit_ex      // call init function
      ...
      .text:000000000000EB91                 call    _EVP_DecryptUpdate       // decoding
      ...
      .text:000000000000EC2D                 call    _EVP_DecryptFinal_ex     // decoding
      ...
    
    • Sau khi đã có IV (16 byte đầu) và ciphertext (phần còn lại), chương trình sử dụng hàm của thư viện OpenSSL để giải mã AES-256-CBC.
    • Key dùng để giải mã là emu_key (được lấy hoặc tính toán từ trước).
    • IV truyền vào là 16 byte đầu vừa tách ra.
    • Ciphertext là dữ liệu sau IV.
  • Cuối cùng, kết quả sẽ được ghi ra file .txt (khả năng là flag.txt):
      .text:000000000000ED3E                 call    __ZStlsIcSt11char_traitsIcESaIcEERSt13basic_ostreamIT_T0_ES7_RKNSt7__cxx1112basic_stringIS4_S5_T1_EE ; std::operator<<<char>(std::ostream &,std::string const&)
    

Thực hiện kiểm tra hàm encode/decode, ta tìm được chuỗi Base64 trong binary tại địa chỉ 0x13100:

.rodata:0000000000013100 aSmr85ltQh8wbgb db 'SMr85LT/QH8WBgB7FAHDJ+RDYEOzmc+8Hq+2HKyaEbwR0DN9BaUFpMgRyi3p9HBHr'
.rodata:0000000000013100                                         ; DATA XREF: __static_initialization_and_destruction_0(void)+2C↑o
.rodata:0000000000013141                 db 'a+5Hz13INUh5jEc/TSPvAHnbxbmKYQSukvmjEG8Jpb76Qfnv28GvW5Puov9jab0SF'
.rodata:0000000000013182                 db 'JVoZMDrHYlfzz7xcxpXRkYiQMElRMEm3MXLyqok/KpRB65upKUMtC20YMG02TnJAe'
.rodata:00000000000131C3                 db '63deizlhJWmwYn2UbMR4tU6WCHSF8Il7ShvC9hOOTXFRjOY1bHlutv4dYydyqTB3i'
.rodata:0000000000013204                 db '7XP5rZiaK20tUfp5LGF/f+pQkqx4gVfXl2O2Vs1jDcjesb3ezbJT0VJfEreJZbtJJ'
.rodata:0000000000013245                 db 'XyWTwybo/3BoBKfD11bf17/6LZg6Z4PEH8FHXUDZV52uLbpMvt3ZrWU5t7p',0

Sau khi quá trình giải mã kết thúc, file output flag.txt sẽ được tạo để ghi dữ liệu. Cụ thể:

  • Phần filename sẽ được XOR với 0x2A:
      .text:000000000000EAF6                 mov     rax, 5E525E044D4B464Ch
      .text:000000000000EB00                 mov     [rbp+var_39], rax
    
      // XOR each bytes with 0x2A loops
      .text:000000000000EBC7 loc_EBC7:                               ; CODE XREF: Cpu::superChipRendrer(void)+2C7↓j
      .text:000000000000EBC7                 mov     eax, [rbp+var_2F0]
      .text:000000000000EBCD                 cdqe
      .text:000000000000EBCF                 movzx   eax, byte ptr [rbp+rax+var_39]
      .text:000000000000EBD4                 xor     eax, 2Ah
      .text:000000000000EBD7                 mov     edx, eax
      .text:000000000000EBD9                 mov     eax, [rbp+var_2F0]
      .text:000000000000EBDF                 cdqe
      .text:000000000000EBE1                 mov     byte ptr [rbp+rax+var_39], dl
      .text:000000000000EBE5                 add     [rbp+var_2F0], 1
    
  • Sau khi XOR, chuỗi "5E525E044D4B464C" sẽ thành flag.txt.

Về quá trình tạo emu_key, đây là một process khá phức tạp. Cụ thể key sẽ được tính trong Emulator::init(std::string) với thuật toán rất phức tạp sử dụng các magic constant nên rất khó để implement chính xác. Flow tạo key:

uint32_t constants[4] = {
    0xDEADBEEF,  // Dead Beef
    0xCAFEBABE,  // Cafe Babe  
    0x8BADF00D,  // Bad Food
    0xFEEDFACE   // Feed Face
};

// Các constants khác
0xF0F0F0F0
0x9E3779B1  // Golden ratio constant
0xA5A5A5A5

// Charset cho key generation
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"

Anti-debugging in RomLoader

Tuy nhiên, trong RomLoader của binary này có cơ chế check anti-debugging. Cụ thể:

call    _ptrace          ; ptrace(PTRACE_TRACEME, 0, 0, 0)
cmp     rax, 0FFFFFFFFh ; Nếu return -1 = đang bị debug
jnz     short continue
; Exit nếu bị debug
call    _exit

Do đó, ta cần tạo shared library để bypass ptrace. Cụ thể:

# Tạo file bypass
gcc -shared -fPIC -o bypass_ptrace.so -xc - << 'EOF'
long ptrace() { return 0; }
EOF

# Usage
LD_PRELOAD=./bypass_ptrace.so ./chip8Emulator ...

Khi có shared lib này:

  • LD_PRELOAD inject thư viện trước khi load binary
  • Hàm ptrace() của chúng ta luôn return 0 (thành công)
  • Binary nghĩ không bị debug và tiếp tục chạy bình thường

Create ROM Trigger and get flag

Đối với CHIP-8, để trigger được instruction F0FF, ta cần các step sau:

  • 60FF = LD V0, 0xFF - Load giá trị 0xFF vào register V0
  • F0FF = Hidden instruction - Trigger decryption với X=0 (V0)

Từ đây ta tạo ROM file:

# Create ROM file
echo "60FFF0FF12021200" | xxd -r -p > trigger.ch8

# Check
hexdump -C trigger.ch8
# 00000000  60 ff f0 ff 12 02 12 00

Giải thích các bytes trong ROM file: | Offset | Bytes | Instruction | Ý nghĩa | |——–|——-|————-|———| | 0x200 | 60 FF | LD V0, 0xFF | Set V0 = 255 | | 0x202 | F0 FF | FXFF (X=0) | Trigger superChipRendrer | | 0x204 | 12 02 | JP 0x202 | Jump back (optional) | | 0x206 | 12 00 | JP 0x200 | Padding |

Có đầy đủ các file cần thiết, chạy emulator với ROM Trigger để lấy flag

# Chạy với bypass ptrace
LD_PRELOAD=./bypass_ptrace.so ./chip8Emulator -r trigger.ch8

# Get flag
cat flag.txt

Flag cần tìm là 0xfunCTF2025{N0w_y0u_h4v3_clear_1dea_H0w_3mulators_WoRK}

Conclusion

Tóm tắt các bước giải

  1. Reconnaissance:
    • Phân tích binary với file, ldd, strings
    • Phát hiện sử dụng OpenSSL (libcrypto)
  2. Static Analysis:
    • Load vào IDA Pro để disassemble
    • Tìm thấy hidden instruction FXFF
    • Phát hiện hàm superChipRendrer làm AES decryption
  3. Bypass Anti-Debug:
    • Tạo bypass_ptrace.so với LD_PRELOAD
  4. Trigger Exploitation:
    • Tạo ROM file với opcodes: 60FF F0FF
    • Chạy emulator để decrypt và ghi flag

Kiến thức áp dụng

  • CHIP-8 architecture: Hiểu instruction set để phát hiện instruction không chuẩn
  • Reverse Engineering: Đọc x86-64 assembly, trace function calls
  • Cryptography: Nhận biết AES-256-CBC, Base64, IV extraction
  • Anti-debugging bypass: LD_PRELOAD technique