Stack Buffer Overflow in nvme-print.c: How sprintf() Threatened NVMe Device Security
Introduction
The nvme-print.c file is responsible for formatting and displaying diagnostic information from NVMe storage devices — a critical piece of infrastructure that processes data fed directly from hardware. But a subtle, dangerous flaw lurked in several of its formatting functions: every call to sprintf() wrote into a fixed-size stack buffer with zero bounds checking. This is the story of how a single missing function argument — the buffer size — created a critical, exploitable vulnerability, and how a targeted fix closed the door on potential arbitrary code execution.
The Vulnerability Explained
What Was Wrong
At the heart of the issue is a deceptively simple pattern. In C, sprintf() formats a string and writes it into a destination buffer — but it has no idea how large that buffer is. It will keep writing until it runs out of input, blissfully overwriting whatever sits adjacent in memory.
Here's the specific vulnerable code from nvme_pel_event_to_string() at line 224:
// VULNERABLE CODE — nvme-print.c:224
const char *nvme_pel_event_to_string(int type)
{
static char str[STR_LEN];
sprintf(str, "%s(%#x)", pel_event_to_string(type), type);
return str;
}
The format string "%s(%#x)" combines two components:
1. The return value of pel_event_to_string(type) — a string whose length depends entirely on the type value passed in
2. The hex representation of type itself (e.g., 0xffffffff)
If pel_event_to_string(type) returns a string longer than STR_LEN minus the overhead of the hex suffix, the write overflows the stack buffer.
The same unsafe pattern appeared in nvme_degrees_string() at lines 907 and 909:
// VULNERABLE CODE — nvme-print.c:907-909
if (nvme_is_output_format_json())
sprintf(str, "%ld %s", val, "Celsius");
else
sprintf(str, "%ld °%s", val, "C");
Why This Is Exploitable
The threat model here is concrete: a malicious or malfunctioning NVMe device controls the input. NVMe devices communicate structured data back to the host system, including event type identifiers. An attacker with physical access, or one who has compromised firmware on a connected NVMe device, could craft device responses that return unexpected type values.
Here's what a 2-step exploit chain looks like:
-
Step 1 — Craft the device response: The attacker programs an NVMe device (or emulates one) to return an event type value that, when passed to
pel_event_to_string(), produces an abnormally long string — or causes the function to fall through to a default case that returns a very long unrecognized-type string. -
Step 2 — Trigger the overflow: When the host system calls
nvme_pel_event_to_string()to display or log the event,sprintf()writes the oversized string into the fixed stack bufferstr[STR_LEN], overflowing into adjacent stack frames. On systems without stack canaries or with bypassed mitigations, this enables control of the instruction pointer — arbitrary code execution.
This is classified as CWE-120: Buffer Copy Without Checking Size of Input, and the PR assessment confirms it as confirmed exploitable.
The Fix
Replacing sprintf() with snprintf()
The fix is surgical and precise: every sprintf() call in the affected functions was replaced with snprintf(), which takes an explicit maximum byte count as its second argument.
Before (vulnerable):
// nvme-print.c:224 — BEFORE
sprintf(str, "%s(%#x)", pel_event_to_string(type), type);
After (fixed):
// nvme-print.c:224 — AFTER
snprintf(str, sizeof(str), "%s(%#x)", pel_event_to_string(type), type);
The critical addition is sizeof(str) as the second argument. This tells snprintf() the maximum number of bytes it is permitted to write, including the null terminator. No matter how long pel_event_to_string(type) returns, the output is hard-capped at the declared buffer size.
The same fix was applied to nvme_degrees_string():
Before (vulnerable):
// nvme-print.c:907-909 — BEFORE
if (nvme_is_output_format_json())
sprintf(str, "%ld %s", val, "Celsius");
else
sprintf(str, "%ld °%s", val, "C");
After (fixed):
// nvme-print.c:907-909 — AFTER
if (nvme_is_output_format_json())
snprintf(str, sizeof(str), "%ld %s", val, "Celsius");
else
snprintf(str, sizeof(str), "%ld °%s", val, "C");
Why sizeof(str) Is the Right Choice
Using sizeof(str) rather than a hardcoded constant like 256 is intentional and important. If STR_LEN is ever changed, sizeof(str) automatically tracks the new value — a hardcoded constant would silently become wrong. This idiom ties the size argument directly to the actual buffer declaration, making future maintenance safer.
Additional Lines Flagged for Review
The PR also notes that similar patterns appear at nvme-print.c:920, 922, 937, and at least 7 additional locations in the same file. These represent the same vulnerability class and should be reviewed and fixed using the same snprintf() pattern. Defense in depth requires addressing all instances, not just the highest-severity one.
Prevention & Best Practices
1. Ban sprintf() in New Code
In any C codebase that processes external input — especially from hardware devices — sprintf() should be treated as a legacy function and replaced with snprintf() everywhere. Many projects enforce this with compiler warnings or static analysis rules:
// In a compiler warning configuration or lint rule:
// -Wno-deprecated — won't help here, but tools like:
// cppcheck --enable=warning
// will flag sprintf() as potentially unsafe
2. Use sizeof() With the Buffer Variable, Not a Magic Number
// GOOD — tracks buffer size automatically
char buf[STR_LEN];
snprintf(buf, sizeof(buf), "%s(%#x)", event_str, type);
// RISKY — silently wrong if STR_LEN changes
snprintf(buf, 64, "%s(%#x)", event_str, type);
3. Validate Input Lengths Before Formatting
When possible, check the length of strings returned by functions like pel_event_to_string() before using them in format operations:
const char *event_str = pel_event_to_string(type);
if (strlen(event_str) + 12 > STR_LEN) {
// Handle gracefully — truncate, log, or return an error
}
4. Enable Compiler and Linker Mitigations
While not a substitute for fixing the root cause, these mitigations raise the bar for exploitation:
- Stack canaries (-fstack-protector-strong): Detect stack corruption at runtime
- FORTIFY_SOURCE (-D_FORTIFY_SOURCE=2): Replaces some sprintf() calls with checked versions at compile time
- ASLR: Makes it harder to predict memory layout for exploitation
5. Static Analysis Tools
Tools that would catch this class of vulnerability:
- Coverity — detects unsafe format string operations
- cppcheck — flags sprintf() with external input
- CodeQL — can trace tainted data from device I/O to format functions
- clang-tidy with cert-err34-c and cppcoreguidelines-* checks
6. Reference Standards
- CWE-120: Buffer Copy without Checking Size of Input ("Classic Buffer Overflow")
- CERT C Coding Standard STR07-C: Use the bounds-checking interfaces for string manipulation
- OWASP: Buffer Overflow — https://owasp.org/www-community/vulnerabilities/Buffer_Overflow
Key Takeaways
-
sprintf()innvme_pel_event_to_string()trusted hardware-controlled input: Thetypeparameter ultimately came from NVMe device responses — external, attacker-influenced data. Never use unbounded string functions on hardware-derived input. -
The fix is one word:
snprintf(): Addingsizeof(str)as the second argument to everysprintf()call in this file is all it takes to eliminate the overflow risk. The change is minimal, but the security improvement is fundamental. -
Static buffers on the stack are high-value overflow targets:
str[STR_LEN]declared as astatic charin a function is adjacent to return addresses and saved registers. Overflowing it is a classic path to code execution. -
One vulnerable pattern, multiple locations: The PR identified the same
sprintf()issue at lines 224, 907, 909, 920, 922, 937, and more. When you find one instance of an unsafe pattern, search the entire file — it's rarely isolated. -
sizeof(buffer_variable)is safer than hardcoded sizes: Usingsizeof(str)instead of a literal number ensures the size argument stays correct even ifSTR_LENis refactored in the future.
Conclusion
The vulnerability in nvme-print.c is a textbook example of why sprintf() has no place in code that processes external input. The function nvme_pel_event_to_string() at line 224 — and its siblings throughout the file — trusted that the strings it formatted would always fit in their destination buffers. A malicious NVMe device could have violated that assumption, triggering a stack buffer overflow and potentially achieving arbitrary code execution on the host system.
The fix is elegantly simple: snprintf(str, sizeof(str), ...) instead of sprintf(str, ...). That single additional argument is the difference between a function that blindly trusts its inputs and one that enforces a hard boundary. For developers working in C, especially on code that interfaces with hardware or external devices, this fix is a reminder that buffer safety is not optional — it must be explicitly enforced at every formatting call.
Automated security scanning caught this issue before it could be exploited. The regression test included in the PR ensures it stays fixed.
This vulnerability was identified and fixed by OrbisAI Security. For automated security scanning and remediation for your codebase, visit orbisappsec.com.