Buffer overflows are the oldest and most consequential vulnerability class in computing. The Morris Worm in 1988 used a buffer overflow. Code Red in 2001 — buffer overflow. Heartbleed in 2014 — buffer over-read. EternalBlue (WannaCry ransomware) in 2017 — buffer overflow.
After 35+ years, we’re still writing code that lets attackers corrupt memory and take over systems. This article explains exactly how that happens, at the memory level, with real code.
What Is a Buffer Overflow?
A buffer is a contiguous block of memory allocated to hold data — an array, a string, a struct. A buffer overflow occurs when a program writes more data to a buffer than it can hold. The excess data spills into adjacent memory, corrupting whatever lives there.
In C and C++, there are no automatic bounds checks. If you allocate 64 bytes and write 128, the language lets you. The hardware doesn’t stop you. The OS doesn’t stop you (usually). You just wrote over whatever came after your buffer in memory.
What lives “after your buffer” depends on where the buffer was allocated — the stack or the heap. That determines what gets corrupted and how an attacker can exploit it.
The Stack — How Functions Work in Memory
To understand stack buffer overflows, you need to understand how the stack works.
Every time a function is called, a stack frame is created containing:
- Arguments passed to the function
- Return address — where to jump back when the function returns
- Saved base pointer — the caller’s frame pointer
- Local variables — including any buffers declared in the function
The stack grows downward in memory (from high addresses to low). But buffers fill upward (from low to high). This means a buffer overflow in a local variable writes upward toward the return address.
Stack Buffer Overflow — Step by Step
Here’s a vulnerable C program:
#include <stdio.h>
#include <string.h>
void vulnerable_function(char *input) {
int authenticated = 0;
char buffer[64];
// VULNERABLE: no bounds checking
strcpy(buffer, input);
if (authenticated) {
printf("Access granted!\n");
// ... give admin access ...
}
}
int main(int argc, char *argv[]) {
if (argc > 1) {
vulnerable_function(argv[1]);
}
return 0;
}The stack layout when vulnerable_function is called:
High addresses
┌──────────────────────────┐
│ argv[1] (pointer) │ ← function argument
├──────────────────────────┤
│ Return Address │ ← where to go after function returns
├──────────────────────────┤
│ Saved EBP │ ← caller's base pointer
├──────────────────────────┤
│ authenticated (int = 0) │ ← local variable
├──────────────────────────┤
│ buffer[64] │ ← local buffer (64 bytes)
│ ... │
│ (grows upward →) │
└──────────────────────────┘
Low addressesAttack 1: Variable Overwrite
If the attacker provides 65+ bytes of input, strcpy writes past buffer[64] into authenticated:
# 64 bytes of 'A' fill the buffer, 65th byte overwrites 'authenticated'
./vulnerable $(python3 -c "print('A' * 68)")
# Output: Access granted!The authenticated variable (which was 0) is now overwritten with 0x41414141 (“AAAA”), which is non-zero, so the if (authenticated) check passes.
Attack 2: Return Address Overwrite
More dangerous: overflow enough to reach the return address.
buffer[64] → 64 bytes of padding
authenticated → 4 bytes of padding
saved EBP → 4 bytes of padding
return addr → 4 bytes the attacker controlsTotal: 64 + 4 + 4 = 72 bytes of padding, then the next 4 bytes overwrite the return address.
# Overwrite return address with 0xDEADBEEF
./vulnerable $(python3 -c "print('A' * 72 + '\xef\xbe\xad\xde')")
# Segfault — but the CPU tried to jump to 0xDEADBEEFIf the attacker points the return address to their own shellcode (also injected via the buffer), they get arbitrary code execution.
Classic Shellcode Injection
In the pre-mitigation era, the attack was straightforward:
┌─────────────────────────────────────────────────┐
│ NOP sled (0x90 × N) │ Shellcode │ Padding │ RET │
└─────────────────────────────────────────────────┘
│
▼
Points back to NOP sled// Classic x86 Linux shellcode — spawns /bin/sh (45 bytes)
// This is the payload that goes into the buffer
"\x31\xc0\x50\x68\x2f\x2f\x73\x68"
"\x68\x2f\x62\x69\x6e\x89\xe3\x50"
"\x53\x89\xe1\xb0\x0b\xcd\x80"The NOP sled (0x90 = no-operation) is a landing pad — the return address doesn’t need to be exact, just anywhere in the NOP sled, and execution slides down to the shellcode.
Heap Buffer Overflow
The heap is where dynamically allocated memory lives (malloc, new, calloc). Heap overflows are harder to exploit but equally dangerous.
#include <stdlib.h>
#include <string.h>
struct user {
char name[64];
int is_admin;
};
void process_user(char *input) {
// Heap-allocated struct
struct user *u = malloc(sizeof(struct user));
u->is_admin = 0;
// VULNERABLE: no bounds check
strcpy(u->name, input);
if (u->is_admin) {
printf("Admin access granted to %s\n", u->name);
}
free(u);
}Because name and is_admin are adjacent in the struct, overflowing name overwrites is_admin:
# 64 bytes fill name, next bytes overflow into is_admin
./heap_vuln $(python3 -c "print('A' * 68)")
# Output: Admin access granted to AAAA...Heap Metadata Corruption
More advanced heap exploits target the heap allocator’s internal metadata. malloc uses linked lists to track free chunks:
Heap layout:
┌──────────────────┐
│ chunk header │ ← size, prev_size, fd/bk pointers
├──────────────────┤
│ user data (64B) │ ← your buffer
├──────────────────┤
│ chunk header │ ← next chunk's metadata
├──────────────────┤
│ user data │ ← next allocation
└──────────────────┘If an overflow corrupts the next chunk’s header — particularly the fd (forward) and bk (back) pointers — the attacker can trick free() into writing an arbitrary value to an arbitrary address. This is the classic unlink exploit.
Integer Overflow Leading to Buffer Overflow
Some of the most subtle memory bugs come from integer overflows in size calculations:
void process_items(int count, char *data) {
// Integer overflow: if count = 1073741824 (2^30)
// then count * 4 = 4294967296 = 0 (wraps on 32-bit)
size_t total_size = count * sizeof(int);
// Allocates 0 bytes (or very small buffer)
int *buffer = malloc(total_size);
// But copies count * 4 bytes — massive overflow
memcpy(buffer, data, count * sizeof(int));
}count = 1073741824 (0x40000000)
count * 4 = 4294967296 (0x100000000) → wraps to 0 on 32-bit
malloc(0) returns a small valid pointer
memcpy copies 4GB of data into a tiny buffer → catastrophic overflowSafe pattern:
// Check for overflow BEFORE the multiplication
if (count > SIZE_MAX / sizeof(int)) {
// Overflow would occur — reject
return -1;
}
size_t total_size = count * sizeof(int);
int *buffer = malloc(total_size);
if (!buffer) return -1;Or use compiler builtins:
size_t total_size;
if (__builtin_mul_overflow(count, sizeof(int), &total_size)) {
return -1; // Overflow detected
}Format String Attacks
Not technically a buffer overflow, but a closely related memory corruption bug. When user input is passed directly as the format string to printf:
// VULNERABLE — user controls the format string
printf(user_input);
// SAFE — user input is an argument, not the format
printf("%s", user_input);If user_input is "%x %x %x %x", printf reads values off the stack (information leak). If user_input contains %n, printf writes to memory — the number of bytes printed so far is written to an address on the stack.
Input: "AAAA%08x.%08x.%08x.%08x.%n"
printf reads 4 stack values with %08x
Then %n writes the number of bytes printed to the address 0x41414141 (AAAA)
With careful construction, attacker gets arbitrary memory write.Modern Defenses
The exploit techniques above work on unprotected systems. Modern operating systems and compilers implement multiple layers of defense.
Defense 1: Stack Canaries (Stack Protector)
The compiler inserts a random value (“canary”) between the buffer and the return address. Before the function returns, it checks if the canary was modified.
Stack with canary:
┌──────────────────┐
│ Return Address │
├──────────────────┤
│ Saved EBP │
├──────────────────┤
│ CANARY (random) │ ← checked before return
├──────────────────┤
│ Local variables │
├──────────────────┤
│ Buffer[64] │
└──────────────────┘
If buffer overflow corrupts the canary:
→ Function detects it before returning
→ Process aborts with "*** stack smashing detected ***"
→ Attacker never gets code executionEnabled by default in GCC and Clang:
# Compile with stack protection (default in most distros)
gcc -fstack-protector-strong -o program program.c
# Compile WITHOUT protection (for testing/education only)
gcc -fno-stack-protector -o program program.c
# Maximum protection
gcc -fstack-protector-all -o program program.cBypass: If the attacker can leak the canary value (via a separate info leak vulnerability or a format string bug), they can include the correct canary in their overflow payload.
Defense 2: NX Bit / DEP (Data Execution Prevention)
The CPU marks memory pages as either writable or executable — never both. The stack and heap are marked non-executable.
Without NX:
Stack: READ + WRITE + EXECUTE → Attacker injects shellcode, executes it
With NX:
Stack: READ + WRITE → Attacker injects shellcode, CPU refuses to execute
Code: READ + EXECUTE → Legitimate code runs normally# Check NX status on Linux
readelf -l ./program | grep "GNU_STACK"
# RW = NX enabled (no E flag)
# RWE = NX disabled (executable stack)Bypass: Return-Oriented Programming (ROP)
ROP is the modern technique for exploiting buffer overflows on NX-protected systems. Instead of injecting new code, the attacker chains together small fragments (“gadgets”) of existing executable code — from libc, the program itself, or other loaded libraries.
Normal stack overflow:
buffer → [shellcode] → [return to shellcode]
ROP chain:
buffer → [padding] → [addr of gadget1] → [addr of gadget2] → [addr of gadget3] → ...
Each gadget ends with a RET instruction, which pops the next address
off the stack and jumps to it — chaining gadgets together.Example: calling system("/bin/sh") via ROP:
1. Find gadget: pop rdi; ret (loads argument into rdi register)
2. Find address of "/bin/sh" string in libc
3. Find address of system() in libc
ROP chain:
[padding × 72]
[addr of "pop rdi; ret"] ← gadget 1
[addr of "/bin/sh"] ← argument loaded into rdi
[addr of system()] ← called with rdi = "/bin/sh"Tools like ropper and ROPgadget automate finding gadgets in binaries.
Defense 3: ASLR (Address Space Layout Randomization)
ASLR randomizes the base addresses of the stack, heap, and shared libraries on each execution:
# Run the same program twice — addresses differ each time
$ ./show_addresses
Stack: 0x7ffd3a821000
Heap: 0x55a8c2f41000
libc: 0x7f8b3c200000
$ ./show_addresses
Stack: 0x7ffc91a05000
Heap: 0x561e8f320000
libc: 0x7f2a4d100000The attacker can’t hardcode addresses in their exploit because they change every run.
# Check ASLR status on Linux
cat /proc/sys/kernel/randomize_va_space
# 0 = disabled
# 1 = stack and libraries randomized
# 2 = full randomization (stack, heap, mmap, libraries)Bypass: ASLR is defeated by information leaks. If the attacker can read a single pointer from memory (via format string, partial overwrite, or side channel), they can calculate the base address of libc and build their ROP chain dynamically.
Defense 4: PIE (Position-Independent Executable)
ASLR randomizes libraries but not the main executable by default. PIE makes the executable itself position-independent, so its base address is also randomized.
# Compile with PIE (default on modern Linux)
gcc -pie -fPIE -o program program.c
# Check if binary is PIE
file ./program
# ... ELF 64-bit LSB pie executable ... ← PIE enabled
# ... ELF 64-bit LSB executable ... ← not PIEDefense 5: RELRO (Relocation Read-Only)
Marks the Global Offset Table (GOT) as read-only after linking, preventing GOT overwrite attacks:
# Full RELRO
gcc -Wl,-z,relro,-z,now -o program program.c
# Check RELRO status
checksec --file=./programThe Dangerous Functions
These C functions are the primary source of buffer overflows. Know them, avoid them, and flag them in code reviews:
// NEVER USE — no bounds checking
gets(buffer); // Reads unlimited input
strcpy(dest, src); // Copies until null terminator
strcat(dest, src); // Appends until null terminator
sprintf(dest, fmt, ...); // Formats without size limit
scanf("%s", buffer); // Reads unlimited string
// USE INSTEAD — with explicit size limits
fgets(buffer, sizeof(buffer), stdin);
strncpy(dest, src, sizeof(dest) - 1); // Still needs manual null termination!
strncat(dest, src, sizeof(dest) - strlen(dest) - 1);
snprintf(dest, sizeof(dest), fmt, ...);
scanf("%63s", buffer); // Width specifier limits inputEven the “safe” versions have gotchas:
// strncpy does NOT null-terminate if src is too long
char dest[64];
strncpy(dest, user_input, 64);
// If user_input >= 64 chars, dest is NOT null-terminated!
dest[63] = '\0'; // Always do this manually
// Better: use strlcpy (BSD/macOS) or explicit logic
size_t len = strlen(src);
if (len >= sizeof(dest)) len = sizeof(dest) - 1;
memcpy(dest, src, len);
dest[len] = '\0';Real-World Buffer Overflow Exploits
Morris Worm (1988)
The first internet worm exploited a buffer overflow in fingerd on Unix systems. The gets() function in the finger daemon had no bounds checking. The worm overflowed a 512-byte buffer, overwrote the return address, and injected code that spread the worm to other machines.
Code Red (2001)
Exploited a buffer overflow in Microsoft IIS’s Index Service. A long URL request overflowed a buffer in idq.dll, giving the worm control of the web server. Infected 359,000 servers in under 14 hours.
Heartbleed (2014)
Technically a buffer over-read (not overflow). OpenSSL’s heartbeat extension didn’t validate the payload length field. An attacker could request up to 64KB of server memory per heartbeat, leaking private keys, session tokens, and passwords. Affected ~17% of all HTTPS servers.
// Simplified Heartbleed bug
// Client sends: "I'm sending 5 bytes: HELLO"
// But lies: "I'm sending 65535 bytes: HELLO"
// Vulnerable code allocates response based on CLAIMED length
unsigned char *response = malloc(claimed_length);
memcpy(response, payload, claimed_length);
// Copies 65535 bytes but only 5 were real payload
// The other 65530 bytes are whatever was adjacent in memoryEternalBlue / WannaCry (2017)
Buffer overflow in Windows SMBv1 (srv.sys). The NSA-developed exploit was leaked and weaponized as the WannaCry ransomware. Infected 200,000+ systems across 150 countries, causing billions in damage.
Writing Overflow-Resistant C Code
If you must write C/C++ (kernel modules, embedded systems, performance-critical code), follow these patterns:
// 1. Always validate buffer sizes before copy
int safe_copy(char *dest, size_t dest_size, const char *src, size_t src_len) {
if (src_len >= dest_size) {
return -1; // Would overflow
}
memcpy(dest, src, src_len);
dest[src_len] = '\0';
return 0;
}
// 2. Use safe integer arithmetic for sizes
#include <stdint.h>
void *safe_alloc_array(size_t count, size_t element_size) {
if (count > 0 && element_size > SIZE_MAX / count) {
return NULL; // Integer overflow
}
return malloc(count * element_size);
// Or just use calloc() — it does this check internally
}
// 3. Prefer calloc over malloc for arrays
// calloc checks for integer overflow internally
int *array = calloc(count, sizeof(int)); // Safe
int *array = malloc(count * sizeof(int)); // Might overflow
// 4. Zero buffers after use (prevents info leaks)
void handle_password(const char *password) {
char local_copy[128];
strncpy(local_copy, password, sizeof(local_copy) - 1);
local_copy[sizeof(local_copy) - 1] = '\0';
// ... use the password ...
// Clear sensitive data before returning
explicit_bzero(local_copy, sizeof(local_copy));
// Do NOT use memset — compiler may optimize it away
}Compiler Flags for Maximum Protection
# GCC/Clang — production hardening flags
gcc \
-fstack-protector-strong \ # Stack canaries
-D_FORTIFY_SOURCE=2 \ # Buffer overflow checks in libc
-fPIE -pie \ # Position-independent executable
-Wl,-z,relro,-z,now \ # Full RELRO (read-only GOT)
-Wl,-z,noexecstack \ # Non-executable stack
-Wall -Wextra -Werror \ # Treat all warnings as errors
-Wformat=2 \ # Format string warnings
-Wformat-security \ # Format security warnings
-Warray-bounds \ # Array bounds warnings
-fsanitize=address \ # AddressSanitizer (dev/test only)
-o program program.c-D_FORTIFY_SOURCE=2 is particularly powerful — it replaces unsafe functions like strcpy, memcpy, and sprintf with checked versions at compile time:
// With -D_FORTIFY_SOURCE=2, this:
char buf[64];
strcpy(buf, user_input);
// Becomes (approximately):
char buf[64];
__strcpy_chk(buf, user_input, 64); // Aborts if copy exceeds 64 bytesMemory-Safe Languages — The Real Fix
All the mitigations above are defense-in-depth for an inherently unsafe memory model. The actual solution is to stop using languages that allow raw memory access without bounds checking.
// Rust — buffer overflow is impossible
fn main() {
let mut buffer = vec![0u8; 64];
// This would panic at runtime — index out of bounds
// buffer[100] = 42;
// Safe: Rust checks bounds automatically
if let Some(element) = buffer.get_mut(100) {
*element = 42;
} else {
println!("Index out of bounds — handled safely");
}
// Strings are always bounds-checked
let name = String::from("Hello");
// name.as_bytes()[100]; // Would panic — caught at runtime
}// Go — runtime bounds checking
func main() {
buffer := make([]byte, 64)
// This panics with: "runtime error: index out of range"
// buffer[100] = 42
// Safe: check length first
if index < len(buffer) {
buffer[index] = 42
}
// Or use append — automatically grows
buffer = append(buffer, moreBytesFromUser...)
}The industry is moving this direction. Microsoft reports that 70% of their CVEs are memory safety bugs. Google reports similar numbers for Chrome. The Linux kernel is gradually adopting Rust for new modules. Android rewrites in Rust have had zero memory safety vulnerabilities.
Buffer Overflow Security Checklist
CODE LEVEL:
[ ] No use of gets(), strcpy(), strcat(), sprintf(), scanf("%s")
[ ] All buffer operations have explicit size limits
[ ] Integer arithmetic checked for overflow before allocation
[ ] Format strings are never user-controlled
[ ] Buffers zeroed after holding sensitive data (explicit_bzero)
[ ] Arrays bounds-checked before access
[ ] Strings explicitly null-terminated after truncated copy
COMPILE TIME:
[ ] -fstack-protector-strong enabled
[ ] -D_FORTIFY_SOURCE=2 enabled
[ ] PIE enabled (-fPIE -pie)
[ ] Full RELRO (-Wl,-z,relro,-z,now)
[ ] Non-executable stack (-Wl,-z,noexecstack)
[ ] All warnings enabled and treated as errors
RUNTIME:
[ ] ASLR enabled (randomize_va_space=2)
[ ] AddressSanitizer used in testing/CI
[ ] Fuzzing with AFL or libFuzzer in CI pipeline
[ ] Static analysis (Coverity, CodeQL, clang-tidy)
STRATEGIC:
[ ] New components written in memory-safe languages (Rust, Go)
[ ] C/C++ modules isolated via sandboxing (seccomp, namespaces)
[ ] Critical parsing code fuzz-tested continuouslyBuffer overflows have been the single most exploited vulnerability class for 35 years. The mitigations are good. The tooling is better than ever. But the only complete fix is to stop giving programs unchecked access to raw memory. Write new code in Rust or Go. Audit existing C/C++ with fuzzing and static analysis. And know how these attacks work — because the software you depend on was almost certainly written before anyone cared.









