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. sprintf → snprintf(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%sand an unbounded input string is always a buffer overflow waiting to happen — intty-gravity/main.c, themessagevariable alone was sufficient to overflow the 500-bytedatabuffer.- The return value of
snprintf()must be checked — a return value ≥sizeof(buffer)signals truncation; ignoring it meansbuf.lencould 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 forposorwidthproduce long numeric fields in the escape sequence. sizeof(buffer)is safer than a magic constant — hardcoding500in thesnprintfcall would create a maintenance hazard; usingsizeof(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, andwidthvariables passed into theupdate()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 atmain.c:19, writing into the fixed 500-byte stack bufferdata. - Missing control: No length check on
messagebefore formatting, and no size argument tosprintf()to cap the write. - CWE: CWE-120 — Buffer Copy without Checking Size of Input.
- Fix: Replaced
sprintf()withsnprintf(data, sizeof(data), ...)and added a return-value check to setbuf.lensafely, 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.