Back to Blog
critical SEVERITY7 min read

How Stack Buffer Overflows Happen in C with sprintf() and How to Fix Them

A critical stack buffer overflow was discovered in `libuv/Learn-libuv/docs/code/tty-gravity/main.c` where `sprintf()` wrote ANSI escape sequences and user-controlled variables into a fixed 500-byte buffer without any bounds checking. An attacker controlling the `pos`, `width`, or `message` variables could overflow the stack, overwrite return addresses, and potentially achieve arbitrary code execution. The fix replaces `sprintf()` with `snprintf()` and adds explicit length validation to ensure wr

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a stack buffer overflow vulnerability (CWE-120) in C, found in `libuv/Learn-libuv/docs/code/tty-gravity/main.c` at line 19. The `sprintf()` function wrote formatted terminal escape sequences into a fixed 500-byte `data` buffer using three user-influenced variables (`pos`, `width`, `message`) without any length check, allowing an attacker to overflow the stack. The fix replaces `sprintf()` with `snprintf(data, sizeof(data), ...)` and caps the resulting `buf.len` to `sizeof(data) - 1` when truncation occurs, ensuring the write is always bounded.

Vulnerability at a Glance

cweCWE-120
fixReplace sprintf() with snprintf() and validate the returned length before use
riskStack memory corruption, potential arbitrary code execution
languageC
root causesprintf() writes into a fixed-size buffer without enforcing a length limit
vulnerabilityStack Buffer Overflow via unbounded sprintf()

Introduction

The tty-gravity/main.c file in the libuv/Learn-libuv example suite drives a simple terminal animation — it positions a colored message on screen using ANSI escape sequences written to a TTY stream. It looks harmless. But buried inside the update() callback at line 19, a single sprintf() call was silently building a string that could overflow a 500-byte stack buffer, corrupt adjacent memory, and hand an attacker control of the program's execution flow.

This post walks through exactly how that happened, what the vulnerable code looked like, and how replacing sprintf() with snprintf() — plus one careful length check — closes the door on the attack.


The Vulnerability Explained

The Dangerous Code

Here is the vulnerable section inside the update() function (the uv_timer_t callback that fires on every animation tick):

// BEFORE — vulnerable code
uv_buf_t buf;
buf.base = data;
buf.len = sprintf(data, "\033[2J\033[H\033[%dB\033[%luC\033[42;37m%s",
                        pos,
                        (unsigned long) (width-strlen(message))/2,
                        message);

data is a fixed-size stack-allocated buffer (500 bytes). sprintf() formats three caller-influenced values into it:

Variable Role Attacker control
pos Vertical cursor position (%d) Controls integer size
width Horizontal centering calculation (%lu) Controls integer size
message The displayed string (%s) Controls string length directly

sprintf() has no concept of the destination buffer's size. It writes characters until the format string is exhausted, then appends a null terminator — regardless of how many bytes that requires. If the combined output of the ANSI escape prefix (\033[2J\033[H\033[...B\033[...C\033[42;37m) plus message exceeds 500 bytes, sprintf() cheerfully writes past the end of data and into whatever lives next on the stack.

Why This Is Exploitable

In a typical stack frame, the memory layout above a local buffer includes saved frame pointers and the function's return address. Overwriting the return address with an attacker-chosen value is the textbook path to arbitrary code execution.

Concrete attack scenario: An attacker who can supply a message string longer than ~480 characters (accounting for the escape sequence prefix) will overflow data. With enough control over the overflow content, they can overwrite the saved return address of update(). When the timer callback returns, execution jumps to attacker-controlled code instead of back into libuv's event loop.

Even without a full exploit, an oversized message will reliably crash the process — a denial-of-service condition that is trivially reproducible.

Real-World Impact

This file is flagged as production code (not test-only). Any deployment that exposes pos, width, or message to external input — config files, environment variables, IPC messages, or network data — is vulnerable to both denial of service and potential remote code execution.


The Fix

The fix is surgical: two lines changed, zero behavior change for valid inputs.

Before vs. After

// BEFORE — no bounds check
buf.len = sprintf(data, "\033[2J\033[H\033[%dB\033[%luC\033[42;37m%s",
                        pos,
                        (unsigned long) (width-strlen(message))/2,
                        message);
// AFTER — bounded write with length validation
int len = snprintf(data, sizeof(data), "\033[2J\033[H\033[%dB\033[%luC\033[42;37m%s",
                        pos,
                        (unsigned long) (width-strlen(message))/2,
                        message);
buf.len = (len >= (int)sizeof(data)) ? sizeof(data) - 1 : (size_t)len;

What Changed and Why

1. sprintfsnprintf(data, sizeof(data), ...)

snprintf() accepts an explicit maximum byte count as its second argument. It will write at most sizeof(data) - 1 characters into data, always null-terminating the result. No matter how large pos, width, or message become, the write is bounded to the buffer's actual capacity.

2. Return-value check: (len >= (int)sizeof(data)) ? sizeof(data) - 1 : (size_t)len

snprintf() returns the number of characters that would have been written if the buffer were unlimited. If that value is greater than or equal to sizeof(data), truncation occurred. The fix detects this condition and sets buf.len to sizeof(data) - 1 (the number of valid bytes actually written, excluding the null terminator). If no truncation occurred, len is used directly. This ensures uv_write() is never told to send more bytes than were actually written.

3. sizeof(data) instead of a magic number

Using sizeof(data) ties the limit directly to the buffer declaration. If the buffer size is ever changed, the limit automatically tracks it — no risk of the two values drifting apart.


Prevention & Best Practices

Never Use sprintf() with Variable-Length Input

The C standard library's sprintf(), strcpy(), strcat(), and gets() are inherently unsafe when any input is not fully controlled and bounded at compile time. Treat them as deprecated in new code.

Unsafe function Safe replacement
sprintf() snprintf()
strcpy() strlcpy() (BSD) or strncpy() + manual null
strcat() strlcat() (BSD) or strncat()
gets() fgets()

Validate Input Length Before Formatting

Before passing a string like message into any format function, check its length:

if (strlen(message) > MAX_MESSAGE_LEN) {
    // reject or truncate before formatting
}

Enable Compiler Hardening Flags

Modern compilers can catch and mitigate buffer overflows at compile and runtime:

# GCC / Clang
-D_FORTIFY_SOURCE=2   # replaces sprintf with checked variant at compile time
-fstack-protector-strong  # inserts stack canaries
-fsanitize=address    # AddressSanitizer for development/testing

_FORTIFY_SOURCE=2 in particular will replace sprintf() with a checked version that aborts on overflow — a useful safety net even before you fix the source.

Use Static Analysis in CI

Tools that can catch this pattern automatically:
- Semgrep — rules for sprintf without size argument
- clang-analyzer (scan-build) — tracks buffer sizes through the call graph
- Coverity / CodeQL — enterprise-grade taint analysis
- Orbis AppSec — detected and fixed this exact issue automatically

Reference Standards

  • CWE-120: Buffer Copy without Checking Size of Input ("Classic Buffer Overflow")
  • OWASP: Buffer Overflow
  • SEI CERT C Coding Standard: Rule STR07-C — Use the bounds-checking interfaces for string manipulation

Key Takeaways

  • sprintf() with %s and an unbounded input string is always a buffer overflow waiting to happen — in tty-gravity/main.c, the message variable alone was sufficient to overflow the 500-byte data buffer.
  • The return value of snprintf() must be checked — a return value ≥ sizeof(buffer) signals truncation; ignoring it means buf.len could misrepresent the actual data written to the TTY stream.
  • All three variables (pos, width, message) contributed to the exploitable surface — even without a long string, extreme integer values for pos or width produce long numeric fields in the escape sequence.
  • sizeof(buffer) is safer than a magic constant — hardcoding 500 in the snprintf call would create a maintenance hazard; using sizeof(data) keeps the limit coupled to the declaration.
  • Stack buffer overflows in TTY/terminal code are often overlooked — the "it's just a display function" assumption leads developers to skip input validation that they would apply in network-facing code.

How Orbis AppSec Detected This

  • Source: The message, pos, and width variables passed into the update() uv_timer callback — values that can be influenced by external configuration or input.
  • Sink: The sprintf(data, "\033[2J\033[H\033[%dB\033[%luC\033[42;37m%s", pos, ...) call at main.c:19, writing into the fixed 500-byte stack buffer data.
  • Missing control: No length check on message before formatting, and no size argument to sprintf() to cap the write.
  • CWE: CWE-120 — Buffer Copy without Checking Size of Input.
  • Fix: Replaced sprintf() with snprintf(data, sizeof(data), ...) and added a return-value check to set buf.len safely, bounding all writes to the actual buffer capacity.

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 single function swap — sprintf() to snprintf() — plus a two-line length check is all it took to close a critical stack buffer overflow in tty-gravity/main.c. The vulnerable pattern (sprintf into a fixed buffer with a %s argument) is one of the oldest and most well-documented mistakes in C programming, yet it continues to appear in real codebases. The lesson is not just about this one file: every place in your C code where sprintf(), strcpy(), or gets() touches data that isn't provably bounded at compile time is a potential overflow. Audit those call sites, enable _FORTIFY_SOURCE, and let static analysis tools catch what code review misses.


References

Frequently Asked Questions

What is a stack buffer overflow?

A stack buffer overflow occurs when a program writes more data into a stack-allocated buffer than it can hold, overwriting adjacent memory such as saved registers and return addresses, which can lead to crashes or code execution.

How do you prevent buffer overflows in C?

Always use length-limited string functions like snprintf(), strlcpy(), or strncat() instead of their unbounded counterparts (sprintf(), strcpy(), strcat()), and validate input lengths before processing.

What CWE is a buffer overflow?

Classic buffer overflows are classified as CWE-120 (Buffer Copy without Checking Size of Input), with stack-specific variants under CWE-121.

Is compiler stack protection (stack canaries) enough to prevent buffer overflows?

Stack canaries detect many overflows at runtime but are not a substitute for fixing the root cause. They add latency to detection and can sometimes be bypassed; fixing the vulnerable call site is always preferred.

Can static analysis detect sprintf() buffer overflows?

Yes. Static analysis tools like Semgrep, Coverity, and clang-analyzer can flag unsafe sprintf() calls. Orbis AppSec automatically detected this exact pattern and generated the fix.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #14

Related Articles

high

How insecure string copy functions happen in C and how to fix them

A high-severity buffer overflow risk was discovered in `login/main.c` where `strcpy()` was used to copy the `HOME` environment variable into a fixed-size 512-byte buffer without any bounds checking. An attacker controlling the `HOME` environment variable could overflow `pwd_file_name`, potentially corrupting memory or hijacking execution. The fix replaces the two-step `strcpy`/`strcat` pattern with a single, bounds-safe `snprintf` call.

critical

How buffer overflow in Intel SGX enclave ECALLs happens in C and how to fix it

A critical buffer overflow vulnerability was discovered in Intel SGX enclave functions `ecall_encrypt_data` and `ecall_decrypt_data` in `backend/sgx/enclave/enclave.c`. The functions performed memory operations without validating that the provided buffer lengths matched the actual allocated buffer sizes, allowing an attacker controlling the untrusted application to trigger heap corruption within the secure enclave by passing oversized length parameters.

critical

How buffer overflow happens in C SGX enclave memcpy and how to fix it

A critical buffer overflow vulnerability was discovered in `backend/sgx/enclave/enclave.c` where the `ecall_store_data` function performed `memcpy` operations without proper bounds checking against the actual destination buffer size. An attacker could supply a malicious `data_len` parameter to overflow the enclave's secure storage buffer, potentially corrupting trusted execution environment memory. The fix replaces a hardcoded magic number check with a precise size comparison against the actual

medium

How integer underflow in array splice operations happens in C and how to fix it

A critical integer underflow vulnerability was discovered in tree-sitter's array.h header file, where the `_array__splice()` function calculated array sizes without proper bounds checking. The vulnerable code relied on assert() statements that are disabled in release builds, allowing arithmetic underflow when `old_count > *size + new_count`, potentially causing memory corruption through out-of-bounds memcpy operations.

high

How buffer overflow via sprintf() happens in C string formatting and how to fix it

A high-severity buffer overflow vulnerability was discovered in `bench/strbuild/strbuild.c` where `sprintf()` wrote formatted output into a 64-byte stack buffer (`line[64]`) without any bounds checking. An attacker who could influence the values in the `NAMES[]`, `c[]`, or `v[]` arrays could overflow this buffer, potentially corrupting the stack and hijacking control flow. The fix replaces `sprintf()` with `snprintf(line, sizeof(line), ...)` to enforce a strict 64-byte write limit.

critical

How Missing Rate Limiting happens in Express.js and how to fix it

Two public API endpoints in `server.js` — `/api/health` and `/api/contact` — were exposed without any rate limiting middleware, allowing attackers to exhaust server resources or spam an SMTP server with unlimited requests. The fix adds rate limiting to both endpoints, with stricter controls on the resource-intensive `/api/contact` route that triggers email sending operations. This change closes a directly exploitable denial-of-service vector in a production web service.