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 heap buffer overflow happens in C dupstr() and how to fix it

A critical heap buffer overflow vulnerability was discovered in the `dupstr()` function inside `tools/strliteral.c`, where `strcpy()` was called on a heap-allocated buffer without first verifying that `malloc()` had succeeded. If `malloc()` returned `NULL`, the subsequent `strcpy()` would write into address zero — corrupting memory and potentially enabling arbitrary code execution. The fix replaces the unsafe `strcpy()` call with a `NULL` check followed by a bounds-safe `memcpy()`, closing the v

medium

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

A stack buffer overflow in `PMenu_Do_Update()` within `src/menu/menu.c` allowed repeated unchecked `sprintf()` calls to overflow a fixed 1400-byte stack buffer when menu entries contained long text strings. The fix replaces every `sprintf(string + strlen(string), ...)` call with `snprintf(string + len, sizeof(string) - len, ...)`, tracking remaining buffer space and preventing writes beyond the buffer boundary.

critical

How out-of-bounds array access happens in C kernel modules and how to fix it

A high-severity out-of-bounds array access vulnerability was discovered in the natflow_conntrack.c kernel module where the `ct->proto.tcp.state` value was used directly as an array index without bounds validation. An attacker capable of manipulating TCP connection state could trigger reads beyond the `tcp_conntrack_names[]` array, potentially leaking kernel memory or causing system crashes. The fix adds a simple bounds check using `ARRAY_SIZE()` before array access.

critical

How buffer overflow in strcpy() happens in C configuration parsing and how to fix it

A critical buffer overflow vulnerability in `src/rpconfig.h` allowed attackers to corrupt memory by providing configuration values exceeding buffer size limits. The `rpcSetText()` function used `strcpy()` to copy user-controlled data into a fixed 256-byte buffer without bounds checking, enabling stack corruption and potential code execution. Replacing `strcpy()` with `strncpy()` and enforcing a 255-byte limit eliminated the overflow risk.

critical

How buffer overflow happens in C gdb-server and how to fix it

A critical buffer overflow vulnerability was discovered in `src/st-util/gdb-server.c` where unbounded `memcpy()` and `strcpy()` calls could write beyond allocated buffer boundaries when processing user-supplied command-line arguments. The fix replaces all unsafe string operations with bounds-checked alternatives like `snprintf()` and `memcpy()` with explicit length validation.

critical

How argument injection happens in Node.js Copilot tool bridge and how to fix it

A high-severity argument injection vulnerability was discovered in the Copilot tool bridge (`bridge.ts`) where user-controlled `request.args` were passed directly to `tool.execute()` without any validation or sanitization. The fix introduces Zod schema validation at line 108, ensuring that tool arguments are parsed against a declared `inputSchema` before execution. This prevents malformed or malicious payloads — including prototype pollution attempts — from reaching the underlying tool implement