Buffer Overflow Explained: Stack, Exploits, and Defenses thumbnail

Buffer Overflow Explained: Stack, Exploits, and Defenses

⏱ approx. 23 min views 88 likes 0 LOG_DATE:2026-05-10
TOC

Buffer Overflow #

Buffer Overflow (BOF) is the classic among classics of memory-safety vulnerabilities: writing past the end of an allocated buffer and corrupting adjacent memory. From the 1988 Morris Worm that pivoted on gets() in fingerd to take down the early Internet, through Code Red, Slammer, Heartbleed, and the 2024 glibc CVE-2024-2961, most of the major Internet-scale incidents of the past 35 years have involved this vulnerability class.

It's tempting to think "we live in the Rust/Go era — surely this is a solved problem?" — but published statistics from the Microsoft Security Response Center and Google Project Zero report that about 70% of their CVEs are still memory safety bugs. Linux kernel / Windows internals / Chrome / Firefox / OpenSSL / FFmpeg / postfix — most of the foundational code carrying modern infrastructure is still C/C++, and new memory-safety CVEs ship monthly.

The goal of this article: dissect how a BOF actually happens in one diagram, and lay out the mitigation/bypass arms race in another. The order is stack BOF mechanics → heap BOF overview → shellcode injection → mitigations vs. bypasses → historical incidents → legitimate places to learn.

1. Why BOF still load-bearing today #

The reason BOF is still a live vulnerability class traces back to the foundational design of C/C++:

  • No bounds checking on array access — neither compiler nor runtime stops buf[1000000]
  • strcpy, gets, sprintf, memcpy, etc. take no "size of the other side" parameter — APIs that trust input length
  • Pointers can address anything — out-of-range reads/writes and allocator-bypassing access are both possible
  • No compile-time tracking of memory ownership — use-after-free, double-free aren't statically prevented

Memory-safe languages (Rust / Go / Swift / Java / Python / Ruby / C# / JavaScript) structurally prevent these by having the compiler or runtime check. But the vast majority of code is still C/C++, and the rewrite cost is enormous, so the realistic choice was to keep them alive with mitigations. Long-running rewrite projects — Rust adoption in the Linux kernel (2022→), the TypeScript→Rust migration at Microsoft, "new Android native code goes in Rust" — are all in motion. Even so, complete replacement is widely understood to take 20+ more years.

2. Stack-based BOF — what's actually happening behind a function call #

The most classic case, stack-based buffer overflow, requires dissecting the stack frame at function-call time. The relative position of local-variable buffers and the saved return address is the ignition point.

Stack-based Buffer Overflow — overwriting the saved return address to seize control x86_64 Linux / stack during a vulnerable() call / high address → low address ▼ Normal stack (during vulnerable()) caller's args / environment high address (e.g., 0x7fff...ff00) ★ saved RIP (return address) caller's next instruction = 0x401200 saved RBP (previous base pointer) stack-frame chain other locals (int, ptr, ...) small variables in this frame char buf[64] a 64-byte buffer on the stack [buf] [buf+1] ... [buf+63] low address (e.g., 0x7fff...fe00) ← RSP ▼ Attack: strcpy(buf, attacker_input) overruns caller's args / environment typically not overwritten above this ★ RIP = 0xDEADBEEF (attacker's choice!) on return, jump goes here = control hijacked saved RBP = AAAAAAAA (overwritten) collateral damage on the way other locals = AAAA... collateral damage on the way buf[64] = "AAAA...AAAA" attacker's input (e.g., 80 bytes) last 8 bytes land on saved RIP strcpy copies "all of it" up to NULL ▼ Result: function's `ret` instruction → RIP = 0xDEADBEEF → arbitrary code execution (1) Attacker input 80 bytes: [64 bytes AAAA...] + [8 bytes AAAA RBP] + [8 bytes attacker address] (2) strcpy(buf, input) — strcpy has no size check; it copies until NULL (3) vulnerable()'s closing `ret` pops saved RIP from the stack into PC (4) PC = attacker-chosen address → executes "the attacker's shellcode" or "ROP gadget" living there (5) End result: a shell pops; if the binary was setuid root, you have root ▼ Dangerous C standard functions (no size argument, or commonly misused) - gets() — no size argument. Removed in C11 (still alive in legacy code) - strcpy(), strcat(), sprintf() — no size; even strncpy/strncat/snprintf have NULL-termination footguns - scanf("%s", buf) — unbounded %s is effectively gets; you must write %63s - memcpy(dst, src, attacker_controlled_len) — same issue if len comes from the attacker These functions have been "don't use" warnings for 30+ years and still haven't fully left the codebase

The minimal vulnerable program and the exploit flow:

// vulnerable.c — classic stack BOF
#include <string.h>
#include <stdio.h>

void vulnerable(char *input) {
    char buf[64];           // 64 bytes on the stack
    strcpy(buf, input);     // ★ no size check → over 64 bytes corrupts saved RIP
    printf("%s\n", buf);
}

int main(int argc, char **argv) {
    if (argc > 1) vulnerable(argv[1]);
    return 0;
}
# Build with mitigations off (modern OSes still apply runtime mitigations on top)
gcc -fno-stack-protector -no-pie -z execstack -O0 vulnerable.c -o vulnerable
# Distance to saved RIP: typically buf + saved RBP = 64 + 8 = 72 bytes
./vulnerable $(python3 -c 'print("A"*72 + "BBBBBBBB")')   # → segfault at RIP=0x4242424242424242

The classification of "what the attacker stuffs into the input":

  • Shellcode injection — fill buf with shellcode (a few dozen bytes of asm that spawns /bin/sh) and put "buf's own address" at the saved-RIP slot. Defeated by NX (DEP)
  • ret2libc — point saved RIP at libc's system() and pass the address of /bin/sh as the argument. Defeated by ASLR
  • ROP (Return-Oriented Programming) — chain together pre-existing code fragments (gadgets) ending in ret to build arbitrary behavior. The mainstream of modern BOF exploits
  • JOP / SROP / COOP — variants of ROP

3. Heap-based BOF — corrupting chunk metadata #

A BOF in a buffer allocated with malloc() / new (the heap) is heap-based BOF. It can't seize PC as directly as a stack BOF, but corrupting the heap allocator's bookkeeping metadata can be promoted to arbitrary memory write (write-what-where).

In glibc's malloc (ptmalloc2), each chunk carries a [size | prev_size | data...] header, and free chunks are linked in a doubly linked list. A heap BOF rewriting the fd / bk pointers of the next chunk causes a subsequent unlink() to perform a write at an attacker-chosen address (the classic "unlink attack").

Modern glibc piles consistency checks on unlink(), but House of Force / House of Spirit / fastbin dup / tcache poisoning / large bin attacka different technique per generation — keep being researched and published. The glibc 2.35+ tcache family is an especially active target.

Beyond heap BOF, important neighboring memory-safety bugs:

  • Use-After-Free (UAF) — using a pointer after free() → the memory has been reallocated, so writes corrupt other data
  • Double Freefree()-ing the same pointer twice → free list corruption
  • Type Confusion — treating an object as the wrong type (frequent in C++ vtables)
  • Out-of-Bounds ReadHeartbleed (CVE-2014-0160) is this. Not a write, but a read leaks adjacent memory

All trace back to the same root: the compiler doesn't check pointer validity.

4. Mitigations vs. bypasses — the arms race #

BOF can't be completely eliminated, so OSes, compilers, and CPUs have layered mitigations on top. Each mitigation has a bypass — knowing the chain is the key to understanding why modern exploits look so complex.

Mitigation vs. bypass arms race — 1996 to today Goal / introduction year / main bypass technique for each mitigation ▼ Stack Canary (SSP) — gcc -fstack-protector / 1998 (StackGuard) → standardized 2000s Goal: place a "secret value (canary)" before saved RIP and verify before return → abort on tamper Bypass: leak the canary via info-leak and reuse it / reach the return address through a non-overflow path ▼ DEP / NX bit (Data Execution Prevention) — Intel/AMD CPU + OS / 2003-2005 Goal: mark "data" pages (stack, heap) as non-executable → injected shellcode can't run Bypass: ROP (chain existing code fragments) / ret2libc (call libc's system()) — only existing executable bytes ▼ ASLR (Address Space Layout Randomization) — Linux PaX 2001, mainline 2005 / Windows Vista 2007 Goal: randomize stack / heap / libc / executable load addresses per launch → attacker can't predict targets Bypass: info leak (one leaked address gives the base by offset) / partial overwrite / brute force (32-bit) / BROP ▼ PIE (Position Independent Executable) — gcc -fpie / standard from the 2010s Goal: randomize the executable image too (include it in ASLR) → ROP using fixed addresses no longer works Bypass: leak the binary's base, then rebuild ROP / GOT (Global Offset Table) overwrite ▼ CFI (Control Flow Integrity) / Intel CET / ARM PAC / 2018-2020s Goal: verify "function pointer targets" and "ret destinations" at runtime (shadow stack catches saved-RIP corruption) Bypass: data-only attacks along CFI-allowed paths / counterfeit object-oriented programming (COOP) ▼ Memory-Safe Languages — Rust 2015, Go 2009, Swift 2014 (structural fix) Goal: bake "no out-of-bounds pointer access" into the language statically + dynamically → BOF class fundamentally gone Bypass: bugs inside `unsafe` blocks / past an FFI call to a C library / logic bugs remain (memory-safe ≠ bug-free) Modern Linux distro binaries ship with SSP + DEP + ASLR + PIE + RELRO as a five-layer baseline Verify with checksec.sh / pwntools' checksec — even one missing layer drops exploit difficulty significantly
# Check a binary's mitigations at a glance
checksec --file=./vulnerable
# Sample output:
# RELRO         STACK CANARY   NX        PIE     RPATH    RUNPATH    Symbols    FORTIFY
# Full RELRO    Canary found   NX enab.  PIE en. No RPATH No RUNPATH No symbols Yes

# From Python via pwntools
python3 -c 'from pwn import *; print(checksec("./vulnerable"))'

"All mitigations on" ≠ "unexploitable." Combine one info leak (= an arbitrary-address read) with one BOF and modern exploits routinely punch through every defense. The best evidence: Pwn2Own sees full sandbox escapes against Chrome / Safari / iOS / Windows kernel several times a year. Mitigations make the wall taller; they don't make it impassable.

5. Historical incidents — the BOFs that changed the world #

Most of the Internet-scale incidents are BOF or its memory-safety neighbors:

Year Incident Mechanism Impact
1988 Morris Worm Stack BOF in fingerd's gets() + sendmail debug + rsh brute force Took down ~10% of the Internet. First Internet worm; led to the founding of CERT
1996 Aleph One "Smashing the Stack" (Phrack 49) The first textbook-grade explanation of stack BOF and shellcode The starting point of modern exploitation. Still required reading
2001 Code Red (CVE-2001-0500) BOF in IIS Index Server 350,000 Windows servers infected, designed to DDoS the White House
2003 Slammer (SQL Slammer) (CVE-2002-0649) A 376-byte UDP BOF packet against MS SQL Server 2000 75,000 hosts in 10 minutes, choked global Internet bandwidth, ATMs went down
2014 Heartbleed (CVE-2014-0160) TLS heartbeat out-of-bounds READ in OpenSSL (BOF cousin) 17% of HTTPS servers leaked private keys / sessions / passwords. A worldwide cert reissue event
2017 WannaCry / EternalBlue (CVE-2017-0144) Heap overflow during SMBv1 struct parsing 200,000+ Windows hosts hit by ransomware. NHS / rail / car factories went offline
2024 glibc CVE-2024-2961 (iconv ISO-2022-CN-EXT) Out-of-bounds write in glibc internal buffer Combined with PHP filter chains for RCE PoCs, exploitable in multiple web apps

The fact that memory-safety vulnerabilities haven't gone away in 30 years is the strong reason Microsoft and Google are moving new code to Rust. Rust adoption in the Linux kernel (2022), partial Windows kernel rewrites in Rust, "new Android native code defaults to Rust" — all consequences of the same realization.

6. Where to learn — legitimate practice grounds #

Trying it on someone else's system = a crime under unauthorized-access law. Learn on environments deliberately built to be vulnerable — that's the right entry point:

Platform Contents Difficulty
pwn.college Free university course from ASU. Curriculum from stack BOF → ROP → kernel exploit Beginner → Advanced
pwnable.kr Korean veteran pwn site. Per-level vulnerable binaries, get a shell Beginner → Advanced
pwnable.tw Taiwanese advanced pwn site. Heavy on heap and kernel exploit Intermediate → Expert
picoCTF Carnegie Mellon's beginner CTF. Past problems live forever in PicoGym, lots of pwn Beginner
HackTheBox General challenges → Pwn category Beginner → Advanced
OverTheWire (Narnia, Behemoth, Vortex) Classic BOF / format string wargames Beginner → Intermediate
Microcorruption Matasano's ARM-based embedded BOF wargame Beginner → Intermediate
Exploit-Education (Phoenix, Nebula) Successor to exploit-exercises. Series that ratchets up protections incrementally Beginner → Intermediate

The tools (all in Kali Linux):

# Dynamic analysis / debuggers
gdb + pwndbg / GEF / peda    # Extended GDB (essential for modern pwn)
strace -f ./vulnerable        # Syscall trace
ltrace ./vulnerable           # Library call trace

# Static analysis / binary tooling
checksec --file=./bin         # Show mitigations
ROPgadget --binary ./bin      # Enumerate ROP gadgets
ropper --file ./bin           # Same idea, different impl
objdump -d ./bin | less       # Disassembly
radare2 ./bin / r2 ./bin      # Lightweight reverse-engineering platform
ghidra                        # NSA's GUI decompiler

# Exploit development
python3 + pwntools            # The de facto exploit-script library
                              # = I/O + auto ROP-chain + shellcode + transport wrapper
one_gadget ./libc.so.6        # List "single-address execve('/bin/sh')" sites in libc

A minimal pwntools exploit template:

from pwn import *

elf  = ELF("./vulnerable")
libc = ELF("./libc.so.6")
p    = process("./vulnerable")        # locally / remote("host", port) for remote

# 1. Info-leak the libc base
p.sendline(b"A" * 64 + p64(elf.plt["puts"]))
leak = u64(p.recvline().strip().ljust(8, b"\x00"))
libc_base = leak - libc.sym["puts"]

# 2. Build a ROP chain
rop = ROP(libc)
rop.raw(b"A" * 72)                    # padding to saved RIP
rop.system(next(libc.search(b"/bin/sh\x00")) + libc_base)
p.sendline(rop.chain())

p.interactive()                       # got shell

Don't run this outside of CTFs and practice platforms. Trying it against a live service is illegal the moment you do it. Internal pentesting at your employer requires written authorization (RoE). The same principles as the Kali Linux article's "law and ethics" apply.


Buffer Overflow is the direct consequence of a 1970s language design choice — "C/C++ has no memory bounds checking" — that has remained one of the principal Internet-scale attack surfaces for 30+ years. Holding stack-BOF mechanics (strcpy clobbers saved RIP → ret hijacks control) and its heap-side evolution (chunk-metadata corruption → write-where) in your head, alongside the layered mitigations (SSP / DEP / ASLR / PIE / CFI) and the bypass evolution (ROP / info leak / heap grooming) as a single map of the arms race, lets you read any modern memory-safety CVE writeup with a map already in hand.

The endgame answer is "rewrite into a memory-safe language," but with complete replacement taking 20+ more years, the honest 2026-era posture is layered defense: enable the mitigation stack correctly (verified with checksec), find memory-safety bugs early via fuzzing, start new projects in Rust / Go, and prefer bounds-checked replacement APIs (strncpy_s / snprintf / Rust wrappers / Google's bounds-checking patches) inside existing C/C++.