C's snprintf Return Value Trap: The Buffer Chain That Overflows Itself

2026-07-01

This function builds a URL by chaining snprintf calls into a fixed-size buffer, advancing a cursor after each write. It looks defensive — every return value is checked, and there's a final overflow guard.

#include <stdio.h>
#include <string.h>

/* Append base + "?key=value" into buf. Return 0 on success, -1 on overflow. */
int build_url(char *buf, size_t bufsize,
              const char *base,
              const char *key, const char *value) {
    size_t used = 0;
    int n;

    n = snprintf(buf + used, bufsize - used, "%s", base);
    if (n < 0) return -1;
    used += n;

    n = snprintf(buf + used, bufsize - used, "?%s=", key);
    if (n < 0) return -1;
    used += n;

    n = snprintf(buf + used, bufsize - used, "%s", value);
    if (n < 0) return -1;
    used += n;

    if (used >= bufsize) return -1;   /* the "safety net" */
    return 0;
}

Fuzz it with a 32-byte buffer and a 100-byte value, and you get a heap corruption crash — sometimes. Other times it silently writes a mile past the buffer. The final if (used >= bufsize) check does fire, but only after the damage is done.

The Bug

snprintf returns the number of characters that would have been written if the buffer were large enough, excluding the null terminator — not the number actually written. C99 was explicit about this to let callers size buffers by probing. But it means used += n can advance the cursor past bufsize.

Now watch what happens on the next call: bufsize - used. Both are size_t, which is unsigned. Subtracting a larger unsigned from a smaller one wraps around to a value near SIZE_MAX. So the next snprintf is told: "you have 18 quintillion bytes at buf + used, go wild." It obliges, writing far past the end of the allocation. By the time the trailing used >= bufsize check runs, you've already scribbled across the heap.

The seductive part: on small inputs everything works. Tests pass. The overflow only surfaces when the middle field truncates — the first-and-last-field cases behave. And the checked return values feel like belt-and-suspenders safety, which makes the reviewer's eye slide right past.

The Fix

Detect truncation at each step by comparing the return value against the remaining space before advancing:

int build_url(char *buf, size_t bufsize,
              const char *base,
              const char *key, const char *value) {
    size_t used = 0;
    int n;
    const char *parts[] = { base, "?", key, "=", value };

    for (size_t i = 0; i < 5; i++) {
        size_t remaining = bufsize - used;
        n = snprintf(buf + used, remaining, "%s", parts[i]);
        if (n < 0) return -1;
        if ((size_t)n >= remaining) return -1;  /* truncated — bail */
        used += n;
    }
    return 0;
}

The key line is (size_t)n >= remaining. snprintf writes at most remaining - 1 real characters plus a null, so any n >= remaining means truncation occurred — stop before used can overshoot.

Static analyzers rarely catch this because both individual calls look correct. Compile with -fsanitize=address and fuzz the field lengths; ASan will flag the heap-buffer-overflow on the very first oversized input.

Key Takeaway: snprintf returns the length it wanted to write, not what it did — blindly adding that to a cursor lets your buffer walk off a cliff on the very next call.

All newsletters