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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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. 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.


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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #14

Related Articles

critical

Aardvark.Cef.Process.Core Shared Memory Handler: Unvalidated memcpy

The shared memory handler in Aardvark.Cef.Process.Core failed to validate that the `length` parameter passed from JavaScript did not exceed the destination buffer size before calling `memcpy`. This allowed a compromised Chromium renderer process to corrupt heap memory by supplying an oversized `handle->length` value to the `openMapping` function. The fix adds explicit bounds checking using `GetArrayBufferByteLength()` before the copy operation.

high

XShmGetImage Heap Corruption: Unvalidated Image Height in streamproxy

The `XShmGetImage` function in the X11 shared memory image path copies pixel data row-by-row using `memcpy` without validating that the source image height matches the destination buffer height. An attacker or compromised server could provide an oversized source image, causing writes beyond the allocated heap buffer and triggering heap corruption or code execution.

critical

SGX Enclave ecall_store_data memcpy Buffer Overflow in 256-Byte

The Intel SGX enclave's trusted bridge functions `ecall_store_data` and `ecall_retrieve_data` used `memcpy()` to move data into and out of a fixed 256-byte `secure_storage` buffer without validating that `data_len` fit within destination boundaries. An attacker providing oversized `data_len` values could corrupt enclave memory, breaking SGX's confidentiality guarantees.

critical

How Buffer Overflow via strcpy() Happens in C++ XML Parsers and How to Fix It

A critical buffer overflow vulnerability was discovered in `buildroot-external/package/libxmlparser/xmlParser.cpp`, where the `toXMLString` function used `_tcscpy()` to write XML escape sequences into a destination buffer without any bounds checking. An attacker supplying a crafted XML document could overflow the buffer and potentially execute arbitrary code. The fix replaces all five unsafe `_tcscpy()` calls with `memcpy()` calls that copy only the exact number of bytes required for each escape

critical

How Heap Buffer Overflows Happen in C++ ZIP Extraction and How to Fix Them

A critical heap buffer overflow vulnerability was discovered in `TKLiveSync/unzip.cpp`, where ZIP archive entry names were copied into a `PATH_MAX`-sized heap buffer using `strcpy()` without any length validation. Since the ZIP specification allows entry names up to 65,535 bytes — far exceeding typical `PATH_MAX` values of 1,024 to 4,096 bytes — a crafted archive could overflow the buffer and corrupt heap memory. The fix replaces the unsafe `strcpy`/`dirname` pattern with `std::string` operation

medium

How Integer Overflow happens in C++ image processing and how to fix it

A signed integer overflow in OpenCV's `bilateralFilter.cpp` allowed the buffer size calculation `cal_width * cal_height * cn` to wrap around to a small or negative value, causing `padding.resize()` to allocate far less memory than needed. Subsequent `memcpy` operations would then write beyond the allocated buffer, creating a heap corruption primitive. The fix is a single targeted cast to `size_t` that promotes the multiplication to unsigned 64-bit arithmetic before any overflow can occur.