Security review

Issue #381 · the untrusted inputs, the verifiers, and every vulnerability that was found and fixed

This page is the report for issue #381: the emulator's sources were audited for vulnerabilities reachable from the data it loads, every confirmed defect was fixed, and the checks that were missing are now a small, tested layer (src/verify.h).

The audit and the fixes were done with this source tree alone (no other emulator was consulted for either the findings or the repairs).

Threat model

An emulator is a program that parses files. A GameCube emulator parses files that come from third parties (game images, homebrew, save files), runs code written by those third parties, and is normally run with the rights of the user. The realistic attacker is therefore:

A crash is the common outcome of the defects below; several of them were heap or stack buffer overflows with attacker-chosen content, which is worse than a crash.

What the emulator takes from the outside

ArtifactRead byEntry point
Settings JSON (Data/DefaultSettings*.json, Data/Settings*.json)src/config.cpp -> src/json.cppGetConfig* (first use, i.e. startup)
Bootrom image (Data/bootrom.bin)src/bootrtc.cppBootROM() on every load
DSP IROM/DROM (Data/dsp_irom.bin, Data/dsp_drom.bin)src/flipper.cpp -> src/dspcore.cppFlipper::Flipper()
Executables (.dol, .elf)src/main.cppLoadFile() (command line, selector, load)
Disc images (.iso, .gcm, .rvz)src/dvd.cpp, src/rvz.cpp, src/dvddebug.cppDVD::MountFile(), dvd_fs_init()
Memory card savessrc/memcard.cppMCConnect() at startup, guest EXI transfers
Command linesrc/main.cppEMUParseCmdLine()
Emulated IPL ROM and the guest's device registerssrc/pi.cpp, src/mem.cpp, src/exi.cpp, src/bootrtc.cpp, src/dsparam.cpp, src/dspdma.cpp, src/cp.cppmemory traps, the CP FIFO and every DMA engine
Console scripts (autoexec.cmd, any script <file>.cmd)src/debug.cppCallJdi("script autoexec.cmd") on every load
Symbol maps (*.map, Data/makemap.dat)src/sym.cppAutoloadMap() on every load

Method

The verifiers

src/verify.h holds the rules, and every input path uses them:

VerifierWhat it decides
Verify::Range(offset, size, limit)the only place where the two untrusted values are combined; it subtracts instead of adding, so no pair of 32-bit fields can wrap the test
Verify::MainMemory(phys, size, ramSize)a window in main memory, with the address masked the way the MI decodes it (the mask allows 64 MB, the allocation is 24 or 48 MB)
Verify::ImageSection(...)a section of an executable image: present in the file and inside main memory
Verify::DiscRead(position, length, imageSize)a disc read, with the signed seek the guest can drive
Verify::FstRoot / FstEntry / FstNamethe disc file system table, whose entries come from the image
Verify::MemcardWindow(cardSize, offset, length)a memory card transfer, the length included
Verify::ScriptLine / Verify::ScriptTrimthe bounded reader for autoexec.cmd and the other console scripts

The memory interface gained length-aware accessors next to the old start-only ones (MIGetMemoryPointerForIO(phys, size), ...ForDSP, ...ForPI, ...ForDebug, MIGetMemorySize()), so a block copy can no longer start inside RAM and end outside it.

Findings and fixes

Settings JSON (src/json.cpp, src/config.cpp)

The settings parser is a hand-written JSON reader, and the settings file is the first thing the emulator reads.

DefectKindSeverityFix
String token copied into wchar_t str[0x1000], bound as an assert()stack overflowcriticala runtime limit; the terminator is written inside the array
Numeric token copied into char number[0x100], bound as an assert()stack overflowhigha runtime limit in both the integer and the float reader
No recursion depth limit while parsing; the depth limit in the writer was an assertstack exhaustionhigha depth counter in the parse context, enforced on both paths
DeserializeObject spun forever when the document ended after a commainfinite loophighthe missing end-of-stream/default cases report a syntax error
Literal look-ahead computed maxSize - 4 in size_t, which wraps for a 1-4 byte fileout-of-bounds readlowa non-wrapping form of the test
UTF-8 continuation bytes read past the end of a truncated stringout-of-bounds readmediuma real bound, including the escape fetch
The number readers recomputed "remaining bytes" and wrapped it after an overrunout-of-bounds readlowthe remaining length is computed once, the invalid state is rejected
Element-count cap was assert()-onlyresource exhaustionmediuma real runaway-memory guard (high enough that no shipped JDI specification is affected)
strtoull accepted -1 and silently wrapped out-of-range numbersinteger overflowlowcanonical form and range are validated before the value is stored
A member name could be null when a document was serialized backnull dereferencelowan absent name serializes as an empty name
Missing colon was an assert(), so a malformed document aborted a Debug buildabort / mis-parsehigha real check that rejects the document
Config accessors dereferenced a missing section (assert-only) and reinterpreted mistyped valuesnull dereference / wild pointerhigha real section lookup and a real type check, failing closed with a report
A corrupt *user* settings file aborted the emulatoravailabilitymediumit is reported and ignored; the shipped defaults are kept

Executable images and the command line (src/main.cpp, src/utils.cpp)

DefectKindSeverityFix
DOL section size was never checked against the end of the 24 MB RAM bufferheap overflowcriticalthe file range and the RAM window are both verified before the copy
DOL section destination pointer could be null (the address mask is 64 MB, the allocation 24 MB)null dereferencehighthe pointer is tested, and the RAM size comes from the memory interface
The same two defects in LoadDOLFromMemoryheap overflowcriticalthe same verifier, with the buffer size supplied by the caller
ELF p_filesz copied to p_vaddr with no RAM or file bound, and as a signed lengthheap overflowcriticalunsigned length, the program-header table is validated, every section is verified
wcsrchr result passed to _wcsicmp when the file name has no extensionnull dereferencehighthe extension is tested in both front ends
A disk image that failed to mount was ignored, so the boot sequence read zeroes from an empty drive (a hang)hang / availabilityhighthe mount result is checked and the load fails cleanly
A failed load left the half-built Flipper object allocated, so the shutdown path crashedcrash on startup failurehighEMUOpen releases the partially built machine and rethrows
Report/Halt formatted into char buf[0x1000] with unbounded vsprintfstack overflowhighvsnprintf with the real buffer size
sprintf into 4 KB/512 B stack buffers when the UI builds a console command from a file namestack overflowhighbounded formatting; an over-long value is refused, not truncated
Util::SplitPath copied path components with unsized copiesstack overflowlowthe copy takes the destination size; a long path fails the map autoload cleanly
Util::FileSave opened files read-only on Linux; FileLoad/FileSize ignored failureslogic errormediumthe open mode and the error paths are correct
File/dump commands read args[n] without checking the argument countout-of-bounds accessmediumevery handler validates its arguments

Disc images (src/dvd.cpp, src/rvz.cpp, src/dvddebug.cpp)

DefectKindSeverityFix
FST root nextOffset drove a byte-swap loop with no bound against the buffer read from the imageheap overflowcriticalthe table must fit in the buffer before any entry is touched
Negative DVD seek: the sign passed the start-only checks and the length wrapped into a multi-gigabyte freadheap overflowcriticalthe seek is rejected and the length is clamped in 64-bit arithmetic
FST size from the disc's boot info was read into an address near the end of RAM (up to ~255 MB past the allocation)heap overflowcriticalthe destination window is verified in the boot loader and the size is capped
FST entry walk and name-table pointer used image values with no boundout-of-bounds readmediumentry and name offsets are verified against the validated table size
RVZ: the compressor-data byte was read from a header of exactly minimum sizeout-of-bounds readlowthe field is tested for existence before it is read, like every other field
RVZ: the chunk size was never compared with the disc, so a crafted header could force a ~4 GB allocationresource exhaustionmediumthe chunk size is bounded by the disc size and by an absolute cap
RVZ: the image file handle leaked on a failed openresource leaklowevery failure path closes the handle
Host paths copied into fixed wchar_t[0x1000] fields with unbounded copiesstack/heap overflowmediumthe length is checked against the destination
DumpFst walked the image's FST byte-swapping 12 bytes per entry with no bound, and copied the name table into char name[0x200]heap and stack overflowhighentry indices are verified, the FST is capped, names are built from a bounded range

Memory cards and the EXI bus (src/memcard.cpp, src/bootrtc.cpp)

DefectKindSeverityFix
Page program tested offset >= size + size (a wrapped, start-only check) with an unbounded lengthheap overflowcriticalthe whole window is verified and the length is bounded by the card
Read array had the same check, copying host heap into guest DMA memoryout-of-bounds read (info leak)highthe same window check on the card and on the main-memory side
Sector erase checked only the start of an 8 KB blockheap overflowhighthe whole erased block is verified
The DMA pointer was used without a null test, and a valid start could still overrun main memorynull dereference / heap overflowhighthe length-aware accessor is used instead of the start-only one
Immediate read shifted by a negative countundefined behaviourlowthe same result without a negative shift
The "extra bytes" address form reported an error and then computed an offset anywaylogic errormediumthe helper fails closed and the callers reject the result
A card file larger than 4 GiB was truncated to 32 bits and accepted as validinteger overflowmediumthe size comparison is done in 64 bits
A failing slot A short-circuited the connect (and the flush) of slot Blogic errorlowboth slots are attempted
MX chip DMA used the transfer length raw for the font/ROM windows and for main memoryheap overflowcriticalboth windows are verified at every copy site
The SRAM DMA read validated the length and then always copied 64 bytesheap overflowmediumit copies exactly what was requested, inside the verified window
The SRAM immediate read indexed the 64-byte SRAM with an 8-bit maskout-of-bounds readlowthe same 6-bit index as the write path
The UART receive buffer index was never boundedout-of-bounds writehighthe index is clamped and the buffer is reported when full

DMA engines and the command processor (src/dsparam.cpp, src/dspdma.cpp, src/pi.cpp, src/cp.cpp)

DefectKindSeverityFix
ARAM DMA checked the start of the transfer only, against a 16 MB bufferheap overflowcriticalboth the ARAM and the main-memory window are verified
DSP DMA clipped the DSP side but not the main-memory sideheap overflowhighthe main-memory window is verified with the length-aware accessor
The CP FIFO write pointer was compared with nothing; a 32-byte burst could land anywhere in 64 MBheap overflowcriticalthe burst target and the other FIFO pointers are verified, reads included
The CP read pointer was used without any bound when feeding the GXout-of-bounds readhighthe 32-byte burst is verified against main memory
A CALL_DL display list used the guest address and length with no boundout-of-bounds readhighthe window is verified and clamped to what the memory holds
Vertex array fetches used the guest base/stride with no bound, and a null pointer was dereferencedout-of-bounds read / null dereferencehighthe whole component window is verified; an indirect attribute outside memory is skipped

Console scripts and debugger commands (src/debug.cpp, src/jdiserver.cpp, src/gekkodebug.cpp)

DefectKindSeverityFix
The script line buffer was filled with no bound, and a script without a trailing newline ran off the end of the filestack overflowcriticalVerify::ScriptLine: bounded, NUL-safe, and an over-long line is skipped
Trimming an empty or all-blank line walked a pointer below the bufferout-of-bounds read/writemediumVerify::ScriptTrim handles the empty line first
A script could re-enter itself through script/load without limitstack exhaustionmediuma recursion depth guard
An unterminated quote threw out of the tokenizer with no handler anywhere up to the loaderavailabilitymediumtokenizing reports the bad line and the script continues
The same throw in the JDI server for a console-supplied lineavailabilitymediumthe tokenizer reports the failure; the callers answer with an empty reply
The control-character pre-pass normalised a local copy instead of the bufferlogic errormediumthe normalisation writes back
Profiler interval clamp was invertedlogic errorlowthe documented 2-50 ms range is honoured
Register commands indexed gpr/fpr/ps1/spr/sr with an unvalidated argumentout-of-bounds accesshighthe index is verified against the register file
r r0 << 40 shifted by an unvalidated countundefined behaviourlowthe count is masked to the operand width
Disassembly commands dereferenced absent hardware after unloadnull dereferencemediumthey return early when no machine is loaded
sprintf of a 64-bit register/parameter number into char def[0x10]/def[8]stack overflowhigha wide enough buffer and snprintf

Symbol maps and banner text (src/sym.cpp, src/ui.cpp, src/uisdl.cpp)

DefectKindSeverityFix
RAW map reader copied an unbounded line into char line[0x1000]stack overflowcriticalthe copy is bounded and an over-long line is skipped
Empty/comment-only map lines trimmed below the bufferout-of-bounds read/writehighthe empty case is handled before the walk
A map line with an address but no symbol name walked past the end of the lineout-of-bounds readlowthe walk stops at the terminator and the line is skipped
CodeWarrior and GCC map readers used sscanf("%s") without field widthsstack overflowhighfield widths on every conversion
makemap.dat's function count was used to index the file with no size validationout-of-bounds readmediumthe count and the name offsets are validated against the loaded file
Function names from makemap.dat were widened into wchar_t[0x100] with no boundstack overflowmediumthe copy is bounded; the dead status block was removed
Saving a map cast a wide path to char*, creating a wrongly named file or failing silentlylogic errorlowthe path stays wide through the save
The DVD banner title was copied with an unbounded loop into fixed buffers in both front endsstack/heap overflowhighthe copies are bounded by the destination
The SJIS-to-Unicode conversion kept reading past a title that ended with a lead byteout-of-bounds readmediumthe second byte is checked before it is consumed; malloc failure is handled
Recent-file names shorter than three characters underflowed a lengthout-of-bounds readhighthe short case is handled explicitly

Catching startup crashes

A startup failure used to look the same from the outside as any other crash: the process disappeared. Two things were added so that it can be seen and reproduced:


  $ ./pureikyubu --selftest
  [..] emulator core, debug interface specifications
  [ok] emulator core, debug interface specifications
  [..] settings
  [ok] settings
  [..] emulated hardware, ROM and memory card files
  [ok] emulated hardware, ROM and memory card files
  [..] shutdown
  [ok] shutdown
  selftest: the emulator starts, 0 failed step(s)

For the unit tests, vstest.console <test.dll> /Blame names the test that crashed or hung instead of leaving the run without a summary.

Tests

testing/security_test.cpp is part of the normal unit-test suite (scripts/VS2026/pureikyubu_test.slnx) and covers:

What is not covered

Reproducing the review