Skip to content

Stack Buffer Overflows Explained for Defenders

Why writing past a stack buffer is dangerous, the C patterns that cause it, how compilers and sanitizers catch it, and the fixes and mitigations that contain it.

Published on 6 min read

A stack buffer overflow happens when a program writes more data into a stack-allocated array than the array can hold. It is one of the oldest memory-safety bugs, catalogued as CWE-121, and it remains common in C and C++ code that handles text, network packets and file formats. This guide explains why the bug is dangerous, the code patterns that produce it, and the layered defences that prevent, detect and contain it. If the stack frame layout is new to you, start with how a process lays out memory.

Why a few extra bytes matter

A function's local variables share a stack frame with data the CPU needs to resume the caller: the saved frame pointer and the return address. Arrays are filled from lower to higher addresses, and saved data sits above them. A write that runs past the end of the array therefore overwrites whatever the compiler placed next, and eventually the saved return address.

x86-64 stack frame with a canary between the local buffer and the saved rbp and return address

The consequences range across a spectrum:

What gets overwrittenTypical symptomSeverity for a defender
Padding or a dead variableNothing visibleLatent bug, still must be fixed
Another local variableWrong behaviour, logic bypass (a flag, a length, a pointer)Can be serious without any crash
The stack canary*** stack smashing detected ***, SIGABRTMitigation worked, bug is real
Saved frame pointer or return addressCrash at ret, corrupted backtraceControl-flow hijack risk if mitigations are missing

That last row is why the bug class earned its reputation: control over the return address means control over what the program executes next. Every mitigation discussed below exists to break that step.

The patterns that cause it

Most stack overflows come from a handful of recurring shapes. Recognizing them in code review is the cheapest defence.

Unbounded copies

/* Vulnerable: no bound on the copy. */
void greet(const char *name) {
    char buf[32];
    strcpy(buf, name);           /* name may be longer than 31 bytes */
    printf("Hello, %s\n", buf);
}

strcpy, strcat, sprintf, gets (removed from C11) and scanf("%s", ...) without a width all copy until the source ends, regardless of the destination size.

Trusting a length from the input

/* Vulnerable: the length comes from the packet, not from sizeof(buf). */
int parse_record(const uint8_t *pkt, size_t pkt_len) {
    uint8_t buf[64];
    size_t n = pkt[0];               /* attacker-controlled, up to 255 */
    if (n > pkt_len - 1) return -1;  /* checks the source, not the destination */
    memcpy(buf, pkt + 1, n);
    return process(buf, n);
}

The check validates that the source has enough bytes, but never that the destination has enough room. This is the most common shape in binary protocol parsers.

Off-by-one

/* Vulnerable: writes the terminator one byte past the end. */
char path[256];
size_t n = strlen(dir);
if (n > sizeof(path)) return -1;   /* should be >= */
memcpy(path, dir, n);
path[n] = '\0';                    /* n == 256 writes path[256] */

A single byte sounds harmless, but it can clobber the low byte of an adjacent pointer or saved frame pointer.

Variable-length arrays and alloca

char buf[n] with an attacker-influenced n can make the frame so large that it jumps over the guard page into another mapping. GCC and Clang's -fstack-clash-protection probes the stack page by page to close that gap, and -Wvla flags the construct so you can remove it.

Fixing the code

The fix is always the same idea: bound every write by the size of the destination, and check lengths before copying.

/* Fixed: explicit destination bound, truncation detected. */
int greet(const char *name) {
    char buf[32];
    int n = snprintf(buf, sizeof buf, "%s", name);
    if (n < 0 || (size_t)n >= sizeof buf) return -1;  /* too long */
    printf("Hello, %s\n", buf);
    return 0;
}

/* Fixed: validate against the destination, not just the source. */
int parse_record(const uint8_t *pkt, size_t pkt_len) {
    uint8_t buf[64];
    if (pkt_len < 1) return -1;
    size_t n = pkt[0];
    if (n > sizeof buf || n > pkt_len - 1) return -1;
    memcpy(buf, pkt + 1, n);
    return process(buf, n);
}

Some practical rules:

  • Prefer APIs that take the destination size: snprintf, strlcpy/strlcat (available in glibc since 2.38), memcpy with an explicitly validated length.
  • Treat every length field from outside the process as hostile until checked against sizeof the destination.
  • In C++, use std::string, std::vector and std::span instead of raw arrays, and enable library hardening (-D_GLIBCXX_ASSERTIONS for libstdc++, the hardening modes of libc++) so operator[] is bounds-checked.
  • Where rewriting is realistic, move parsers to a memory-safe language. See memory-safe languages.

Catching it before release

Compiler warnings and FORTIFY_SOURCE

GCC's -Wstringop-overflow and -Warray-bounds catch many overflows when sizes are known at compile time. -D_FORTIFY_SOURCE=2 or =3 (with optimization enabled) goes further: it replaces calls such as memcpy, strcpy and sprintf with checked variants whenever the compiler can determine the destination size, and aborts at runtime with *** buffer overflow detected *** if the bound is exceeded. Level 3 (GCC 12+ with glibc 2.34+, and recent Clang) also handles sizes that are only known at runtime.

AddressSanitizer

ASan places poisoned redzones around every stack array and checks each memory access. The overflow above is reported at the exact faulting instruction, with the variable name and frame:

==4121==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc3a1e0a60
WRITE of size 40 at 0x7ffc3a1e0a60 thread T0
    #0 0x4f3c21 in __interceptor_strcpy
    #1 0x5270b4 in greet greet.c:6
    #2 0x527183 in main greet.c:14
Address 0x7ffc3a1e0a60 is located in stack of thread T0 at offset 64 in frame
    #0 0x52701f in greet greet.c:4
  This frame has 1 object(s):
    [32, 64) 'buf' (line 5) <== Memory access at offset 64 overflows this variable

The sanitizers guide explains how to read every line of that report, and fuzzing with libFuzzer and AFL++ shows how to generate the inputs that trigger it.

Static analysis

Tools such as CodeQL, clang-tidy (with the bugprone-* and cert-* checks), Coverity and the Clang static analyzer find unbounded copies and unchecked length fields across function boundaries. They produce false positives, but a banned-function check for strcpy, sprintf and gets in CI costs nothing.

Containing what slips through

No process catches every bug, so production binaries carry mitigations that make a surviving overflow much harder to turn into code execution:

MitigationBuild flagWhat it does against stack overflows
Stack canary-fstack-protector-strongDetects linear overwrites of saved data before ret
Variable reorderingPart of stack protectorPlaces arrays above scalars so overflows cannot reach them
Non-executable stackDefault, -Wl,-z,noexecstackData written to the stack cannot execute
ASLR + PIE-fPIE -pieCode and stack addresses differ on every run
Shadow stack-fcf-protection=full (x86 CET)Hardware keeps a protected copy of return addresses
Pointer authentication-mbranch-protection=standard (AArch64)Return addresses are cryptographically signed
Stack clash protection-fstack-clash-protectionPrevents large frames from jumping the guard page

Each has limits, which is why they are stacked. The binary hardening flags and control-flow integrity guides cover them in depth, including how to verify that a binary actually has them.

Detection in production

When a canary fires, glibc prints *** stack smashing detected ***: terminated and raises SIGABRT. Treat that message in logs or crash reports as a confirmed memory-safety bug, not as noise: something wrote past a stack buffer. Collect the core dump, identify the function from the backtrace (the frame that called __stack_chk_fail), and reproduce with an ASan build. The crash report guide walks through that workflow.

Checklist

  • Ban unbounded string functions (strcpy, strcat, sprintf, gets) in new code and flag them in CI.
  • Validate every externally supplied length against the destination size, not only against the source.
  • Build with -O2 -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fstack-clash-protection -fPIE -pie and the relevant control-flow protection for your architecture.
  • Run tests and fuzzers under ASan; fix every report, even when the program "seemed fine".
  • Treat stack smashing detected in production as a security bug with a core dump to analyse.

Related guides

0x1000 · Vulnerability Classes

Integer Overflows and Format-String Bugs, Explained

How integer overflow, truncation and signedness errors turn into memory corruption, why user-controlled format strings are dangerous, and the checks and warnings that prevent both.