How Buffer Overflow Happens in C sprintf/strcpy and How to Fix It
Introduction
The mupen64plus-rsp-cxd4/module.c file is the heart of a Reality Signal Processor (RSP) plugin for the mupen64plus N64 emulator. Its step_SP_commands() function processes RSP microcode instructions — decoding them, formatting their hex representations, and logging diagnostic output. Sounds benign. But buried inside that logging logic, three consecutive unsafe string calls created a critical memory safety vulnerability that could let an attacker execute arbitrary code just by convincing a user to open a crafted ROM file.
The specific offenders were sprintf() at line 294, sprintf() again at line 295, and strcpy() at line 296 — all writing into fixed-size stack buffers with no bounds checking whatsoever.
The Vulnerability Explained
Here is the vulnerable code as it existed before the fix:
// mupen64plus-rsp-cxd4/module.c, lines 291–298 (BEFORE)
endian_swap[01] = (u8)((inst >> 16) & 0xFF);
endian_swap[02] = (u8)((inst >> 8) & 0xFF);
endian_swap[03] = (u8)((inst >> 0) & 0xFF);
sprintf(&offset[0], "%03X", GET_RCP_REG(SP_PC_REG) & 0xFFF);
sprintf(&code[0], "%08X", inst);
strcpy(text, offset);
my_strcat(text, "\n");
my_strcat(text, code);
message(text); /* PC offset, MIPS hex. */
Three problems exist here simultaneously:
Problem 1: sprintf() into offset[]
sprintf(&offset[0], "%03X", GET_RCP_REG(SP_PC_REG) & 0xFFF) writes a hex-formatted program counter value into offset. The %03X format specifier suggests 3 characters, but sprintf() does not enforce this — if the format string or the value ever changes, or if the buffer is smaller than assumed, there is no safety net.
Problem 2: sprintf() into code[]
Similarly, sprintf(&code[0], "%08X", inst) writes an 8-character hex representation of the instruction word. Again, no size bound is passed to the function.
Problem 3: strcpy(text, offset) — the most dangerous call
strcpy() copies bytes from offset into text until it hits a null terminator. It has absolutely no knowledge of how large text is. If offset contains more data than text can hold, the overflow silently corrupts the stack.
Why This Is Exploitable
The inst parameter to step_SP_commands() ultimately derives from RSP microcode loaded from an N64 ROM. A maliciously crafted ROM can supply arbitrary instruction words. While the & 0xFFF mask on the PC register limits that specific value, the broader attack surface is clear: the code assumes fixed output sizes that are never actually enforced.
More critically, this vulnerability is part of a combined attack chain identified in the same PR:
- Stack buffer overflow via
sprintf/strcpyinmodule.c:294–298 - Out-of-bounds writes in
game_controller.c:64/77via malformed SI commands - Heap corruption via double-free/mismatched allocator in
module.c:390–396on Windows
Any single one of these paths can corrupt memory. Together, they form a realistic code execution exploit. The attack vector requires no network access, no authentication, no elevated privileges — just opening a .z64 file.
CWE-120 — "Buffer Copy without Checking Size of Input ('Classic Buffer Overflow')" — applies directly here. The CVSS profile for this kind of local file-triggered code execution in a widely-used emulator plugin is critical.
The Fix
The fix is surgical and correct: every unsafe string function is replaced with snprintf(), and the buffer size is passed explicitly using sizeof():
// mupen64plus-rsp-cxd4/module.c, lines 291–298 (AFTER)
endian_swap[01] = (u8)((inst >> 16) & 0xFF);
endian_swap[02] = (u8)((inst >> 8) & 0xFF);
endian_swap[03] = (u8)((inst >> 0) & 0xFF);
snprintf(&offset[0], sizeof(offset), "%03X", GET_RCP_REG(SP_PC_REG) & 0xFFF);
snprintf(&code[0], sizeof(code), "%08X", inst);
snprintf(text, sizeof(text), "%s", offset);
my_strcat(text, "\n");
my_strcat(text, code);
message(text); /* PC offset, MIPS hex. */
Before vs. After
| Location | Before | After |
|---|---|---|
| Line 294 | sprintf(&offset[0], "%03X", ...) |
snprintf(&offset[0], sizeof(offset), "%03X", ...) |
| Line 295 | sprintf(&code[0], "%08X", inst) |
snprintf(&code[0], sizeof(code), "%08X", inst) |
| Line 296 | strcpy(text, offset) |
snprintf(text, sizeof(text), "%s", offset) |
Why Each Change Matters
snprintf() vs sprintf(): snprintf() accepts a maximum number of bytes to write (including the null terminator). It will never write more than sizeof(buffer) bytes, regardless of what the format string or input data produces. The output is always null-terminated within the buffer boundary.
snprintf(text, sizeof(text), "%s", offset) vs strcpy(text, offset): This is the most important substitution. strcpy() is inherently unsafe — it will copy until it finds \0, with no regard for the destination size. Replacing it with snprintf() using sizeof(text) as the limit means even if offset is somehow longer than expected, text cannot overflow. The "%s" format also avoids a format string injection risk that a direct snprintf(text, sizeof(text), offset) would introduce.
Using sizeof(buffer) rather than a hardcoded constant: This is the right pattern because it ties the size limit directly to the actual allocation. If the buffer size is ever changed during refactoring, the sizeof() expression automatically reflects the new size — no manual constant to update and potentially get wrong.
Prevention & Best Practices
1. Ban sprintf(), strcpy(), and strcat() in new C code
These functions should be treated as deprecated in security-sensitive code. Modern replacements exist:
| Unsafe | Safe Replacement |
|---|---|
sprintf(buf, ...) |
snprintf(buf, sizeof(buf), ...) |
strcpy(dst, src) |
snprintf(dst, sizeof(dst), "%s", src) or strlcpy() |
strcat(dst, src) |
strncat() or snprintf() with remaining space |
gets() |
fgets() |
2. Use compiler warnings and hardening flags
# Enable warnings that catch unsafe string use
gcc -Wall -Wformat -Wformat-overflow -Wformat-truncation
# Enable AddressSanitizer during development/testing
gcc -fsanitize=address,undefined
3. Enable static analysis in CI/CD
Tools that would catch this pattern:
- Semgrep: Rules for sprintf and strcpy usage in C
- Coverity / CodeQL: Dataflow analysis that traces tainted input to unsafe sinks
- Clang Static Analyzer: Built-in checks for buffer overflow patterns
- Orbis AppSec: Automatically detected and patched this exact vulnerability
4. Follow OWASP and CWE guidance
- CWE-120: Avoid unbounded buffer copy operations
- CWE-121: Stack-based buffer overflows are especially dangerous because they can overwrite return addresses
- OWASP Input Validation Cheat Sheet: Validate and bound all input before processing
5. Audit the full call chain, not just the obvious sink
This vulnerability was part of a three-way attack chain. When auditing C code for buffer safety, trace the full data flow from ROM/file input through every intermediate buffer — not just the final output call.
Key Takeaways
strcpy(text, offset)instep_SP_commands()is the highest-risk call — it copies an unbounded string into a stack buffer with zero size enforcement, making it the most direct path to stack corruption.sprintf()without a size limit is never safe, even when the format string looks like it constrains output — format strings are not a substitute for actual buffer size parameters.- Using
sizeof(buffer)insnprintf()is safer than hardcoded constants — it automatically adapts if the buffer declaration changes during maintenance. - Emulator plugins are high-value targets because they process untrusted, attacker-controlled file content (ROM data) with the full privileges of the host process.
- A single unsafe string function in a logging path can anchor a full code execution chain — this vulnerability combined with two others in the same codebase to form a realistic exploit requiring only a malicious
.z64file.
How Orbis AppSec Detected This
- Source: Attacker-controlled RSP instruction data loaded from a crafted N64 ROM file, passed as the
instparameter tostep_SP_commands(uint32_t inst) - Sink:
strcpy(text, offset)andsprintf(&offset[0], "%03X", ...)inmupen64plus-rsp-cxd4/module.c:294–296— unbounded writes into fixed-size stack buffers - Missing control: No buffer size limit was passed to
sprintf()orstrcpy(), allowing writes beyond the allocated stack buffer boundaries - CWE: CWE-120 — Buffer Copy without Checking Size of Input ('Classic Buffer Overflow')
- Fix: All three unsafe calls were replaced with
snprintf()with explicitsizeof(buffer)size limits, preventing any write beyond the allocated buffer
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The step_SP_commands() function in mupen64plus-rsp-cxd4/module.c illustrates a pattern that appears constantly in legacy C codebases: diagnostic and logging code written quickly, without the same security scrutiny applied to core logic. Three lines of formatting code — sprintf, sprintf, strcpy — created a critical vulnerability that could be triggered by any user who opens a malicious ROM file.
The fix is three lines long and follows a principle every C developer should internalize: never use a string copy or format function that doesn't accept a destination size. snprintf() with sizeof(buffer) is not just a drop-in replacement — it's a fundamentally safer API that eliminates an entire class of memory corruption bugs.
If you maintain C code that handles untrusted input (files, network data, user input), audit every sprintf(), strcpy(), and strcat() call in your codebase today. The effort is small. The alternative — a working code execution exploit from a single file open — is not.
References
- CWE-120: Buffer Copy without Checking Size of Input
- CWE-121: Stack-based Buffer Overflow
- OWASP Input Validation Cheat Sheet
- OWASP C-Based Toolchain Hardening Cheat Sheet
- snprintf() — C Standard Library Reference (cppreference.com)
- Semgrep rules for unsafe C string functions
- fix: remove unsafe exec() in module.c