Back to Blog
critical SEVERITY8 min read

How buffer overflow happens in C TinyGSM sprintf and how to fix it

A critical buffer overflow vulnerability was discovered in `TinyGsmClientSequansMonarch.h` at line 515, where `sprintf` was writing a two-character hex string into a buffer only two bytes large — leaving no room for the null terminator. The fix replaces `sprintf` with `snprintf` and increases the buffer to three bytes, preventing a one-byte overflow that could corrupt adjacent memory in embedded firmware.

O
By Orbis AppSec
Published July 5, 2026Reviewed July 5, 2026

Answer Summary

This is a buffer overflow vulnerability (CWE-121: Stack-based Buffer Overflow) in C embedded firmware code within `TinyGsmClientSequansMonarch.h`. The root cause is a `sprintf` call writing a two-character hex string plus a null terminator into a `char[2]` buffer — one byte too small. The fix replaces `sprintf` with `snprintf(char_command, sizeof(char_command), "%02X", ...)` and resizes the buffer to `char[3]`, ensuring the null terminator has a valid destination and bounding the write to the buffer size.

Vulnerability at a Glance

cweCWE-121
fixResize buffer to char[3] and replace sprintf with snprintf bounded to sizeof(char_command)
riskMemory corruption in cellular modem firmware, potential code execution via crafted network data
languageC/C++ (embedded firmware)
root causesprintf("%02X") writes 3 bytes (2 hex chars + null) into a 2-byte char array
vulnerabilityStack-based Buffer Overflow via sprintf

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:

  1. 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.
  2. Each byte of that payload triggers one iteration of the loop, and each iteration corrupts one byte of adjacent stack memory.
  3. 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: sprintfsnprintf 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 a char[2] buffer is always one byte too small for this pattern in TinyGsmClientSequansMonarch.h.
  • The memset safety net was an illusion — zeroing the buffer before sprintf does nothing to prevent the overflow that sprintf itself causes on every iteration.
  • snprintf with sizeof(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.write to use 2 instead of sizeof(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 the buff parameter, iterated byte-by-byte in a loop
  • Sink: sprintf(&char_command[0], "%02X", ...) in TinyGsmClientSequansMonarch.h:515, writing into a char[2] stack buffer
  • Missing control: No output size limit on the sprintf call; 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] to char[3], sprintf replaced with snprintf(char_command, sizeof(char_command), "%02X", ...), and stream write count corrected to 2

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.


References

Frequently Asked Questions

What is a buffer overflow caused by sprintf?

sprintf writes formatted output including a null terminator without checking the destination buffer size. If the buffer is too small, sprintf overwrites adjacent memory, corrupting data or enabling code execution.

How do you prevent sprintf buffer overflows in C?

Replace sprintf with snprintf and always pass the destination buffer size as the second argument. Also ensure your buffer is large enough for the maximum formatted output plus the null terminator.

What CWE is a sprintf buffer overflow?

CWE-121 (Stack-based Buffer Overflow). The sprintf family writing past the end of a stack-allocated buffer is one of the most common instances of this CWE.

Is increasing the buffer size alone enough to prevent sprintf buffer overflows?

No. Increasing the buffer size fixes the immediate overflow but does not prevent future overflows if input sizes change. Using snprintf with the correct size limit is the complete fix.

Can static analysis detect sprintf buffer overflows?

Yes. Tools like Semgrep, clang-tidy, cppcheck, and commercial SAST scanners can flag unbounded sprintf calls. Orbis AppSec's multi-agent AI scanner detected this exact pattern automatically.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1019

Related Articles

critical

How buffer overflow happens in C tar header parsing and how to fix it

A critical buffer overflow vulnerability was discovered in `microtar/microtar.c` where the `raw_to_header()` and `header_to_raw()` functions used unbounded `strcpy()` and `sprintf()` calls to copy tar header fields. Malicious tar files with non-null-terminated name fields could overflow destination buffers, potentially leading to code execution. The fix replaces all unsafe string operations with bounded alternatives: `memcpy()` with explicit null-termination and `snprintf()` instead of `sprintf(

critical

How buffer overflow happens in C ieee80211_input() and how to fix it

A critical buffer overflow vulnerability was discovered in `src/firmware/src/net/ieee80211.c` at line 1584, where the `ieee80211_input()` function processed raw 802.11 data frames without verifying that the incoming frame was large enough to contain a valid `ieee80211_frame` header. An attacker within wireless range could craft undersized or malformed frames to trigger memory corruption, potentially leading to remote code execution on the firmware. The fix adds a single, targeted bounds check th

high

How buffer overflow from unsafe string copy functions happens in C network interface code and how to fix it

A high-severity buffer overflow vulnerability was discovered in `generic/eth-impl.c`, where unsafe `strncpy()` and `sprintf()` calls could write beyond buffer boundaries when handling network interface names and device filenames. The fix replaced these dangerous functions with bounded `snprintf()` calls that guarantee null-termination and prevent memory corruption.

critical

How buffer overflow in FuzzIxml.c sprintf() happens in C and how to fix it

A critical buffer overflow vulnerability was discovered in `fuzzer/FuzzIxml.c` where `sprintf()` wrote a PID-formatted filename into a fixed 256-byte stack buffer without any bounds checking. The fix replaces `sprintf()` with `snprintf()`, explicitly passing the buffer size to prevent any overflow. While exploitation in this specific fuzzer context requires local access, the pattern is a textbook example of CWE-120 that developers should recognize and eliminate everywhere it appears.

critical

How buffer overflow happens in C HTML parsing and how to fix it

A critical buffer overflow vulnerability in `include/html_parse.h` allowed attackers to overflow buffers by providing malicious HTML input exceeding buffer capacity. The fix adds proper bounds checking before memcpy() operations to prevent memory corruption and potential code execution.

critical

How buffer overflow in memcpy() happens in Node.js N-API bindings and how to fix it

A critical buffer overflow vulnerability was discovered in the GetBufferAsVector() function in examples_nodejs/src/zupt_napi.cpp, where memcpy() copied data from JavaScript Uint8Array buffers without proper bounds validation. This vulnerability could allow attackers to trigger memory corruption by providing maliciously crafted input arrays to the native Node.js module, potentially leading to crashes or arbitrary code execution.