Gameboi VM — Part 1

Ditto
6 min readAug 2, 2024

--

In July, I played Deadsec CTF where I solved 2 pwn challenges:

  1. User Management
  2. Gameboi

Its my first time reversing and doing a challenge on the gameboy VM, and I enjoy it a lot. In this challenge, I got to play with more fuzzing tools, and get more familiar with them. I always find fuzzing to be magical, and I am looking at how this can complement with code review!

Also, at the same time, the exploitation process is pretty cool because it involves understanding the gameboy VM and opcodes! I will be writing about the exploitation process in the part 2 series of this blog.

Finding the bug

After reading the source a little, I thought that it could be worth it to do some fuzzing. This is because the size of the program is pretty moderate, containing a number of .c files.

user@ubuntu:~/Desktop/deadsec/new/public/gb/gb$ tree
.
├── build
├── compile_commands.json
├── gameboi
├── include
│ ├── cart.h
│ ├── common.h
│ ├── cpu.h
│ ├── emulator.h
│ ├── gb_debugger.h
│ ├── io_ports.h
│ ├── lcd.h
│ ├── log.h
│ ├── main_bus.h
│ ├── main.h
│ ├── mapper.h
│ ├── opcodes.h
│ ├── ppu.h
│ ├── tests.h
│ └── timer.h
├── main.c
├── main.o
├── Makefile
└── src
├── cart.c
├── common.c
├── cpu.c
├── emulator.c
├── gb_debugger.c
├── io_ports.c
├── lcd.c
├── log.c
├── main_bus.c
├── mapper.c
├── opcodes.c
├── ppu.c
├── tests.c
└── timer.c

3 directories, 47 files

The main.c file takes in a ROM file, creates an emulator and runs with the ROM file.

user@ubuntu:~/Desktop/deadsec/new/public/gb/gb$ cat main.c 
#include "main.h"


int main(int argc, char* argv[]){
//reserve this section for parsing global arguments from argv
if(argc < 2)
LOG(ERROR, "Please specify ROM");

setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);

//argv[1] contains the rom file name
create_emulator(argv[1]);
#ifdef TEST
test_cpu();
#else
run();
#endif


return 0;
}

This is great for fuzzing as a ROM file is just a binary file!

Libfuzzer

To compile the program with libfuzzer and asan, I did some modifications to the harness and Makefile as follows:

Harness and Makefile

I started with just fuzzing the create_emulator function only, as can be seen in line 80 in main.cc. However this had no results, because the function is merely parsing the ROM file and initializing some CPU values. I added the run() function to be fuzzed subsequently. Upon adding that, the crashes immediately came!

user@ubuntu:~/Desktop/deadsec/public/gb/gb$ ./gameboi 
INFO: Seed: 347758992
INFO: Loaded 1 modules (646 inline 8-bit counters): 646 [0x4fcb30, 0x4fcdb6),
INFO: Loaded 1 PC tables (646 PCs): 646 [0x4cec48,0x4d14a8),
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: A corpus is not provided, starting from an empty corpus
#2 INITED cov: 2 ft: 2 corp: 1/1b exec/s: 0 rss: 24Mb
load_cart!!!
cart->cart_type 0
num_VRAM: 0x1
VRAM_Banks: 0x1c82a00
num_ROM: 0
map->ROM_banks[0] size :32768
ENTRY VALUE 0x0
ROM: 0x7fae483e8000
ROM SIZE: 0x8000
VRAM: 0x7fae483e6000
WRAM: 0x7fae483e4000
HRAM: 0x1c82ff0
Cart type is ROM_ONLY
UndefinedBehaviorSanitizer:DEADLYSIGNAL
==36432==ERROR: UndefinedBehaviorSanitizer: BUS on unknown address 0x7fae483e9000 (pc 0x0000004b1782 bp 0x7fff799eeab0 sp 0x7fff799eea90 T36432)
#0 0x4b1782 in read_rom_only(unsigned short) /home/user/Desktop/deadsec/public/gb/gb/src/mapper.c:126:13
#1 0x4b3580 in read_bus(unsigned short) /home/user/Desktop/deadsec/public/gb/gb/src/main_bus.c:178:15
#2 0x4ba480 in cpu_cycle() /home/user/Desktop/deadsec/public/gb/gb/src/cpu.c:1140:14
#3 0x4b4f41 in run() /home/user/Desktop/deadsec/public/gb/gb/src/emulator.c:135:28
#4 0x4b0ddf in LLVMFuzzerTestOneInput /home/user/Desktop/deadsec/public/gb/gb/main.cc:92:5
#5 0x441b41 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/home/user/Desktop/deadsec/public/gb/gb/gameboi+0x441b41)
#6 0x441285 in fuzzer::Fuzzer::RunOne(unsigned char const*, unsigned long, bool, fuzzer::InputInfo*, bool*) (/home/user/Desktop/deadsec/public/gb/gb/gameboi+0x441285)
#7 0x443527 in fuzzer::Fuzzer::MutateAndTestOne() (/home/user/Desktop/deadsec/public/gb/gb/gameboi+0x443527)
#8 0x444225 in fuzzer::Fuzzer::Loop(std::__Fuzzer::vector<fuzzer::SizedFile, fuzzer::fuzzer_allocator<fuzzer::SizedFile> >&) (/home/user/Desktop/deadsec/public/gb/gb/gameboi+0x444225)
#9 0x432bde in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/home/user/Desktop/deadsec/public/gb/gb/gameboi+0x432bde)
#10 0x45ba22 in main (/home/user/Desktop/deadsec/public/gb/gb/gameboi+0x45ba22)
#11 0x7fae47c7c082 in __libc_start_main /build/glibc-LcI20x/glibc-2.31/csu/../csu/libc-start.c:308:16
#12 0x40797d in _start (/home/user/Desktop/deadsec/public/gb/gb/gameboi+0x40797d)

UndefinedBehaviorSanitizer can not provide additional info.
SUMMARY: UndefinedBehaviorSanitizer: BUS /home/user/Desktop/deadsec/public/gb/gb/src/mapper.c:126:13 in read_rom_only(unsigned short)
==36432==ABORTING
MS: 2 ChangeByte-CrossOver-; base unit: adc83b19e793491b1c6ea0fd8b46cd9f32e592fc
artifact_prefix='./'; Test unit written to ./crash-9899d82147d513749801c39b2a57a32bda76138a

After analyzing this crash, it just seems to be accessing invalid memory. And running libfuzzer repeatedly just yield a lot of similar SIGBUS crashes which are not interesting! I suppose, this is because the ROM format is wrong. Without understanding more of the gameboy VM, it will be hard to fuzz this with libfuzzer as libfuzzer will keep finding the same crash!

If I had integrated my libfuzzer add-on for detecting unique crashes, I could still continue to use libfuzzer (probably an experiment next time). Due to time constraints, I decided to check out honggfuzz which is also another very easy fuzzer to set up.

The beauty of honggfuzz is that it will only save unique crashes! After following the instructions to set up, running it is as easy as this:

user@ubuntu:~/Desktop/deadsec/public/honggfuzz-oss-fuzz$ 
./honggfuzz --timeout 10 -i input_dir/ -- /home/user/Desktop/deadsec/public/gb/gb/orig_gameboi ___FILE___
Running honggfuzz

After running for less than 5minutes:

Unique crashes
ASAN

I spoted some segfault crashes!!! Thats good news!

Analyzing the crash further, I noticed that the crash case triggered the function write_VBK.

void write_VBK(byte data){
printf("data: %d\n", data);
// no checks of data hence,
// bus->mapper->VRAM_banks[data] goes OOB
bus->VRAM = bus->mapper->VRAM_banks[data];

printf("bus->VRAM: 0x%x\n", bus->mapper->VRAM_banks[data]);
bus->mapper->cur_VRAM = data;
}

// Lets look at how VRAM_banks is created
mapper_t* create_mapper(uint8_t num_ROM, uint8_t num_VRAM, uint8_t num_EXRAM, uint8_t num_WRAM, char* filename){
int rom_fd = 0;
map = (mapper_t *)Malloc(sizeof(mapper_t));

rom_fd = open(filename, O_RDONLY);

...

//VRAM_banks is specified in num_VRAM, which is a provided argument to create_mapper
map->VRAM_banks = (byte **)Malloc(sizeof(size_t)*num_VRAM);

...

}

// and num_VRAM is usually only 1 or 2,
// so triggering write_VBK with larger than 2 will go OOB
main_bus_t* create_bus(uint8_t num_ROM, uint8_t val_RAM, bool is_CGB, char* filename){
bus = (main_bus_t *)malloc(sizeof(main_bus_t));
uint8_t num_VRAM, num_EXRAM, num_WRAM;
if(is_CGB){
num_VRAM = 2; // [1]
num_WRAM = 8;
} else {
num_VRAM = 1; // [2]
num_WRAM = 2;
}
num_EXRAM = parse_ram(val_RAM);
bus->mapper = create_mapper(num_ROM, num_VRAM, num_EXRAM, num_WRAM, filename);

...

}

As can be seen, write_VBK modifies the bus->VRAM without any bounds check of data, and bus->VRAM is subsequently used in multiple other functions:

byte read_bus_generic(address addr){
byte ret = 0;
if(addr >= VRAM_START && addr < EXRAM_START){
ret = bus->VRAM[addr-VRAM_START]; // OOB read
}
...

}

void write_bus_generic(address addr, byte data){
if(addr >= VRAM_START && addr < EXRAM_START){
if(bus->mem_perms == OAM_VRAM_BLOCKED)
return;
bus->VRAM[addr-VRAM_START] = data; // OOB write
}
...
}

After understanding the code more, I understood that the VBK address is a special address:

void init_io(){
...

init_io_reg(VBK, read_VBK, write_VBK); // specific to this register

...
}

, where VBK is 0xFF4F.

To hit write_VBK, we have to write to 0xFF4F. We can do that by crafting opcodes that reach LD_MEM_A:

        case LD_MEM_A: // EA
// tmp_word = VBK address
write_bus(/*addr=*/ tmp_word, /*value=*/cpu.A);
break;
case LD_A_MEM: // FA
cpu.A = read_bus(tmp_word);
break;

Then write_bus:

void write_bus(address addr, byte chr){

printf("Writing 0x%x to 0x%x\n", chr, addr);

if((addr >= 0x8000 && addr < 0xA000) || addr >= 0xC000){
write_bus_generic(addr, chr); // calls write_bus_generic
} else {
bus->mapper->write(addr, chr);
}
return;
}

write_bus_generic:

void write_bus_generic(address addr, byte data){    

...

}
else if((addr >= IO_START && addr < HRAM_START) || addr == IE){
// find that special register, in this case VBK
io_reg* reg = check_io_reg(addr);
if(!reg){
LOGF(ERROR, "{WRITE} register 0x%x not mapped", addr);
return;
}
if(reg->write_callback == NULL){
LOG(ERROR, "register not writable");
return;
}

printf("{write} register 0x%x with data 0x%x\n", addr, data);

// VBK->write_callback = write_VBK
reg->write_callback(data);
}

...

}

In summary, the bug allows us to OOB in bus->mapper->VRAM_banks[data] and change the VRAM. Subsequently, in read_bus_generic and write_bus_generic, we can read and write to those addresses. The next part will cover the exploitation process.

Fuzzing is helpful and should complement code review whenever possible! In the next part of the blog, I will write about the exploitation process.

--

--

No responses yet