How Buffer Overflow Happens in C TinyGSM sprintf and How to Fix It
Summary
A critical buffer overflow vulnerability was discovered in lib/TinyGSM/src/TinyGsmClientSequansMonarch.h at line 515, where sprintf was writing a two-character hex string — plus its implicit null terminator — into a buffer declared as only two bytes wide. This one-byte overflow writes past the end of a stack-allocated array on every single loop iteration during data transmission. The fix replaces sprintf with snprintf and expands the buffer to three bytes, closing the overflow and making the bound explicit.
Introduction
The TinyGsmClientSequansMonarch.h file is the heart of the Sequans Monarch cellular modem driver in the TinyGSM library — it handles everything from AT command dispatch to raw data transmission over TCP/UDP. Deep inside the modemSend() function, a loop converts binary payload bytes into their ASCII hex representation before writing them to the modem's serial stream. It's a routine, low-level operation. But a subtle off-by-one error in the buffer declaration turned every transmission call into a memory corruption event.
The vulnerable pattern lives at line 515, inside this loop:
char char_command[2]; // <-- only 2 bytes!
for (size_t i = 0; i < len; i++) {
memset(&char_command, 0, sizeof(char_command));
sprintf(&char_command[0], "%02X", reinterpret_cast<const uint8_t*>(buff)[i]);
stream.write(char_command, sizeof(char_command));
}
The %02X format specifier produces exactly two characters (e.g., "FF", "0A", "3C"). But sprintf always appends a null terminator (\0), making the actual write three bytes: two hex characters plus \0. The destination buffer is only two bytes. The null terminator lands one byte past the end of char_command — on every iteration.
The Vulnerability Explained
What's Actually Happening
In C, char char_command[2] allocates exactly two bytes on the stack. When sprintf(&char_command[0], "%02X", byte_value) executes, it writes:
| Offset | Content |
|---|---|
char_command[0] |
First hex digit (e.g., 'F') |
char_command[1] |
Second hex digit (e.g., 'F') |
char_command[2] |
'\0' — one byte past the buffer end |
char_command[2] does not belong to this buffer. It belongs to whatever the compiler placed next on the stack — potentially a loop counter, a return address, another local variable, or stack canary data.
This is CWE-121: Stack-based Buffer Overflow. The overflow is deterministic and happens on every call to modemSend() that transmits any data at all.
Why the memset Doesn't Help
The original code called memset(&char_command, 0, sizeof(char_command)) at the top of each loop iteration. This zeros the two valid bytes before writing, but it does nothing to prevent sprintf from writing its null terminator to char_command[2]. The memset only operates within the declared buffer bounds; sprintf does not.
Exploitation Scenario
This is embedded firmware running on a cellular modem interface. The overflow occurs in the data transmission path, meaning it fires whenever the device sends data over the network. Consider this attack path:
- A compromised network peer or a server under attacker control sends a response that causes the firmware to re-enter
modemSend()with attacker-influenced payload data. - Each byte of that payload triggers one iteration of the loop, and each iteration corrupts one byte of adjacent stack memory.
- Over a sufficiently long transmission, an attacker with precise knowledge of the stack layout could overwrite a return address or function pointer, achieving arbitrary code execution on the embedded device.
Even without achieving code execution, repeated stack corruption can cause unpredictable crashes, silent data corruption, or bypass of stack canary protections depending on what occupies char_command[2] in different build configurations.
The Fix
The fix is precise and minimal — two targeted changes that together close the vulnerability completely.
Before
char char_command[2]; // Buffer too small: only holds 2 bytes
for (size_t i = 0; i < len; i++) {
memset(&char_command, 0, sizeof(char_command));
sprintf(&char_command[0], "%02X",
reinterpret_cast<const uint8_t*>(buff)[i]);
stream.write(char_command, sizeof(char_command));
}
After
char char_command[3]; // Now holds 2 hex chars + null terminator
for (size_t i = 0; i < len; i++) {
snprintf(&char_command[0], sizeof(char_command), "%02X",
reinterpret_cast<const uint8_t*>(buff)[i]);
stream.write(char_command, 2); // Write only the 2 hex chars
}
What Changed and Why
Change 1: char char_command[2] → char char_command[3]
The buffer now has room for two hex characters and the null terminator that snprintf will write. This is the minimum correct size for this format string.
Change 2: sprintf → snprintf with sizeof(char_command)
snprintf takes an explicit maximum byte count as its second argument. Even if a future developer changes the format string or the buffer size, snprintf will never write more than sizeof(char_command) bytes — including the null terminator. This makes the bound self-documenting and enforced at the call site.
Change 3: stream.write(char_command, sizeof(char_command)) → stream.write(char_command, 2)
Previously, sizeof(char_command) was 2, so this wrote both hex characters correctly. After the buffer resize, sizeof(char_command) would be 3, which would incorrectly transmit the null terminator to the modem. The fix hardcodes 2 to write exactly the two hex characters — the semantically correct behavior.
Change 4: Removal of memset
The memset call was a defensive measure that became unnecessary. Since snprintf always null-terminates within the buffer, and we write exactly 2 bytes to the stream, the buffer state before the call doesn't matter. Removing the memset also removes a small but real performance cost inside a tight loop.
Prevention & Best Practices
1. Treat sprintf as Banned in New Code
In any C or C++ codebase — especially embedded firmware — sprintf should be considered a legacy function. Replace it with snprintf everywhere, and enforce this with a linter rule or compiler warning.
// Never do this:
sprintf(buf, "%02X", byte_val);
// Always do this:
snprintf(buf, sizeof(buf), "%02X", byte_val);
2. Size Buffers for the Full Output Including the Null Terminator
When declaring a buffer for a format string, count carefully:
- "%02X" produces 2 characters + 1 null = 3 bytes minimum
- "%04d" produces up to 4 characters + 1 null = 5 bytes minimum
- "%s" with a 32-char string produces 32 characters + 1 null = 33 bytes minimum
3. Use sizeof(buf) as the snprintf Limit, Not a Magic Number
Passing sizeof(buf) instead of a hardcoded number ensures the limit stays correct if the buffer declaration ever changes:
char buf[3];
snprintf(buf, sizeof(buf), "%02X", val); // Self-consistent
4. Enable Compiler Warnings
Clang and GCC can catch some of these issues at compile time:
-Wall -Wformat -Wformat-overflow # GCC 7+
-fsanitize=address,undefined # AddressSanitizer + UBSan for testing
5. Use Static Analysis
Tools that can catch unbounded sprintf patterns:
- Semgrep: semgrep.dev/r?q=sprintf
- clang-tidy: cppcoreguidelines-pro-type-vararg
- cppcheck: --enable=warning flags sprintf size mismatches
- Orbis AppSec: Automatically detected and patched this exact pattern
Relevant Standards
- CWE-121: Stack-based Buffer Overflow — https://cwe.mitre.org/data/definitions/121.html
- OWASP Buffer Overflow: https://owasp.org/www-community/vulnerabilities/Buffer_Overflow
- SEI CERT C Coding Standard STR07-C: Use the bounds-checking interfaces for string manipulation
Key Takeaways
sprintf("%02X")always writes 3 bytes, not 2 — the null terminator is invisible but real, and achar[2]buffer is always one byte too small for this pattern inTinyGsmClientSequansMonarch.h.- The
memsetsafety net was an illusion — zeroing the buffer beforesprintfdoes nothing to prevent the overflow thatsprintfitself causes on every iteration. snprintfwithsizeof(buf)is the complete fix — it bounds the write and makes the size relationship between the format string and the buffer explicit and verifiable.- Embedded firmware overflows are high-impact — even a one-byte stack overflow in a modem driver can corrupt return addresses or canary values, especially across long transmissions with many loop iterations.
- Changing
stream.writeto use2instead ofsizeof(char_command)was essential — without this, the buffer resize would have introduced a protocol bug, transmitting null bytes to the modem.
How Orbis AppSec Detected This
- Source: Binary payload data passed into
modemSend()via thebuffparameter, iterated byte-by-byte in a loop - Sink:
sprintf(&char_command[0], "%02X", ...)inTinyGsmClientSequansMonarch.h:515, writing into achar[2]stack buffer - Missing control: No output size limit on the
sprintfcall; buffer declared one byte too small to hold the format string output plus null terminator - CWE: CWE-121 — Stack-based Buffer Overflow
- Fix: Buffer resized from
char[2]tochar[3],sprintfreplaced withsnprintf(char_command, sizeof(char_command), "%02X", ...), and stream write count corrected to2
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
A two-byte buffer and a format string that always produces three bytes of output — that's all it takes for a critical stack overflow in embedded C. The vulnerability in TinyGsmClientSequansMonarch.h is a textbook example of why sprintf is dangerous: it gives no indication that it's overflowing your buffer, makes no attempt to stop itself, and the resulting memory corruption is silent until it isn't.
The fix is clean: three bytes instead of two, snprintf instead of sprintf, and an explicit write count of 2. Each change is small, but together they transform an exploitable memory corruption bug into safe, self-documenting code. For developers working on embedded firmware or any C/C++ codebase, this is a reminder to treat every sprintf call as a potential vulnerability waiting to be triggered — and to reach for snprintf by default.