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.
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.
The consequences range across a spectrum:
| What gets overwritten | Typical symptom | Severity for a defender |
|---|---|---|
| Padding or a dead variable | Nothing visible | Latent bug, still must be fixed |
| Another local variable | Wrong behaviour, logic bypass (a flag, a length, a pointer) | Can be serious without any crash |
| The stack canary | *** stack smashing detected ***, SIGABRT | Mitigation worked, bug is real |
| Saved frame pointer or return address | Crash at ret, corrupted backtrace | Control-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),memcpywith an explicitly validated length. - Treat every length field from outside the process as hostile until checked against
sizeofthe destination. - In C++, use
std::string,std::vectorandstd::spaninstead of raw arrays, and enable library hardening (-D_GLIBCXX_ASSERTIONSfor libstdc++, the hardening modes of libc++) sooperator[]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:
| Mitigation | Build flag | What it does against stack overflows |
|---|---|---|
| Stack canary | -fstack-protector-strong | Detects linear overwrites of saved data before ret |
| Variable reordering | Part of stack protector | Places arrays above scalars so overflows cannot reach them |
| Non-executable stack | Default, -Wl,-z,noexecstack | Data written to the stack cannot execute |
| ASLR + PIE | -fPIE -pie | Code 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-protection | Prevents 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 -pieand 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 detectedin production as a security bug with a core dump to analyse.