Memory Corruption and Exploit Mitigations
- Memory Corruption and Exploit Mitigations
Why this whole class of bug exists
Most of the security topics in this course are about policy — who is allowed to do what (access control), how we prove identity (authentication), how we keep data secret (cryptography). Memory corruption is different. It is a failure of the machine model itself: the gap between how a programmer thinks their code behaves and what the CPU will actually do when fed hostile input.
Two design decisions, made decades ago for good reasons, combine to make it possible:
- The von Neumann architecture stores code and data in the same memory. There is no hardware tag that says “these bytes are instructions” and “these bytes are user input.” If an attacker can get their bytes into memory and convince the CPU’s instruction pointer to land on them, the CPU will happily execute data as code.
- Memory-unsafe languages. C and C++ trade safety for control: the programmer is responsible for every array bound, every buffer length, every
malloc/free. The language will not stop you from writing past the end of a buffer — that is undefined behavior, and “undefined” in practice means “exploitable.” Decades of operating systems, browsers, and firmware are written in C/C++, so this is not a legacy concern.
The result is a 40-year arms race: a new corruption technique appears, defenders add a mitigation, attackers find a way around it, defenders add another. This page walks that race in order, because each mitigation only makes sense as an answer to the attack before it. Understanding the sequence is the whole point — it is the clearest worked example of defense in depth in the course.
This is the conceptual foundation behind the binary bomb in Lab #5: that lab has you reverse engineer a binary to understand its logic. This page is about what happens when a binary mishandles input badly enough that you can take control of it. Crashes are found by fuzzing; this page is about what an attacker does with the crash once it exists.
The process memory layout
When the kernel loads a program, it lays the process out in virtual memory roughly like this (addresses grow downward in this diagram):
High addresses
┌───────────────────────────┐
│ Stack │ ← local variables, return addresses; grows DOWN
│ │ │
│ ▼ │
│ (unused gap) │
│ ▲ │
│ │ │
│ Heap │ ← malloc/new; grows UP
├───────────────────────────┤
│ BSS / Data │ ← global and static variables
├───────────────────────────┤
│ Text (code) │ ← the machine instructions (read-only, executable)
└───────────────────────────┘
Low addresses
Almost all classic corruption happens on the stack or the heap, because those hold attacker-influenced data sitting right next to data the CPU trusts — like the address it will jump to when a function returns.
The stack and the return address
Every time a function is called, the CPU pushes a stack frame: space for the function’s local variables, the saved base pointer, and — critically — the return address, the location execution resumes at when the function finishes (ret). On 32-bit x86 the instruction pointer is $eip; on 64-bit it is $rip.
Low address
┌─────────────┐
│ buf[64] │ ← a local buffer; user input gets copied in here
├─────────────┤
│ saved ebp │ ← saved base pointer
├─────────────┤
│ return addr │ ← the CPU jumps here on `ret`. Overwrite it = control execution
└─────────────┘
High address (the stack grows toward lower addresses, but a buffer
is filled from low → high, so overflowing buf writes
UP into saved ebp and the return address)
This adjacency is the entire vulnerability. A buffer is filled upward in memory, directly toward the saved return address. If the program copies more bytes into buf than it can hold, the extra bytes overwrite the return address — and whatever value lands there is where the CPU goes next.
The bug classes
| Class | Root cause | Classic primitive |
|---|---|---|
| Stack buffer overflow | Unbounded copy into a fixed stack buffer (gets, strcpy, scanf("%s")) |
Overwrite the saved return address |
| Heap overflow | Overflowing a heap chunk | Corrupt heap metadata or an adjacent object’s pointers |
| Use-after-free (UAF) | Using a pointer after free() |
Attacker re-allocates the freed slot with controlled data |
| Format string | User input used as the format string: printf(user) |
%n writes memory; %x/%p leak it |
| Integer overflow | A size calculation wraps around | Under-sized allocation → later overflow |
| Off-by-one / OOB write | Loop bound or index error | A single byte overwrite, sometimes enough |
The rest of this page follows the stack buffer overflow because it is the simplest to see end-to-end, but every mitigation below applies across the whole table.
Worked example: from crash to control of $eip
Here is the canonical vulnerable program. gets() reads input with no length limit — it was so dangerous it was removed from the C11 standard.
/* vuln.c — compile 32-bit with every mitigation OFF for teaching */
#include <stdio.h>
void win(void) { puts("you redirected execution!"); }
void vulnerable(void) {
char buf[64];
gets(buf); /* no bounds check — the whole bug */
}
int main(void) { vulnerable(); return 0; }
Compile it with the protections disabled so we can study the bare mechanism. Each flag turns off a mitigation we will re-enable later — keep an eye on them, they are the lesson:
$ gcc -m32 -fno-stack-protector -z execstack -no-pie -g vuln.c -o vuln
│ │ │ │
│ │ │ └ no PIE: fixed code addresses
│ │ └ executable stack (lets injected shellcode run)
│ └ no stack canary
└ build 32-bit (simpler: 4-byte addresses, like the course bomb)
For a reproducible demo we also disable address randomization for this one process (more on ASLR below):
$ setarch -R ./vuln # -R = disable ASLR for this process only
Now find exactly how many bytes it takes to reach the return address. Instead of guessing, feed a cyclic De Bruijn pattern — a string in which every 4-byte window is unique — and see which 4 bytes land in $eip when it crashes. pwndbg generates and reverses these:
$ pwndbg ./vuln
pwndbg> cyclic 200 # generate a 200-byte De Bruijn pattern
aaaabaaacaaadaaaeaaaf... # (copy this)
pwndbg> run
Starting program: /home/student/vuln
aaaabaaacaaadaaaeaaaf... # paste the pattern as input
Program received signal SIGSEGV, Segmentation fault.
EIP 0x6161616c ('laaa') # the CPU tried to "return" into our pattern
$eip is now 0x6161616c. Because every window in the pattern is unique, that value names a single offset. Ask pwndbg:
pwndbg> cyclic -l 0x6161616c
Finding cyclic pattern of 4 bytes: b'laaa' (hex: 0x6c616161)
Found at offset 76
76 bytes of padding land us exactly on the return address. We now control $eip: send 76 bytes of filler followed by the 4-byte little-endian address of win():
pwndbg> print win
$1 = {void (void)} 0x80491b6 <win>
$ python3 -c 'import sys; sys.stdout.buffer.write(b"A"*76 + b"\xb6\x91\x04\x08")' | setarch -R ./vuln
you redirected execution!
We made the program run a function it never called. Redirecting into win() is a stand-in for the real attacker goal: redirecting into shellcode (bytes that exec a shell) placed in that same buffer — which works precisely because we compiled with -z execstack. Take that flag away and the injected bytes are data the CPU refuses to execute. That refusal is the first mitigation.
The mitigation arms race
Each defense below answers the attack above it; each attack answers the defense above it. This back-and-forth is the real content of the topic.
1. Stack canaries — detect the overwrite
Idea: the compiler places a random secret value (the canary, after the coal-mine canary) on the stack between the local buffers and the saved return address. Before the function returns, it checks the canary is unchanged. A linear overflow that reaches the return address must have clobbered the canary first, so the check fails and the program aborts (*** stack smashing detected ***) before ret ever runs.
Enabled by gcc -fstack-protector-strong (the modern default on every mainstream distro).
Attacker answers: leak the canary first (via a separate info-leak bug, e.g. a format-string read) and include the correct value in the overflow; or overwrite a pointer without crossing the canary; or, for forking servers, brute-force it byte-by-byte. Canaries raise the bar; they do not close the door.
2. NX / DEP — stop data from executing
Idea: mark the stack and heap non-executable (the CPU’s NX bit; “W^X” — memory is writable xor executable, never both). Now the shellcode an attacker injected into buf is just inert data; jumping to it faults. This is why our demo needed -z execstack to disable it.
Attacker answers — code reuse: if you cannot inject new code, reuse code already in the process and already executable.
- ret2libc: overwrite the return address with the address of an existing function like
system(), and arrange"/bin/sh"as its argument. - Return-Oriented Programming (ROP): chain together dozens of tiny existing instruction sequences — gadgets, each ending in
ret— laid out as a list of addresses on the stack. Each gadget does a sliver of work (pop eax; ret) and returns into the next. With enough gadgets you have a Turing-complete “program” built entirely from fragments of the victim’s own code, executing no new instructions, so NX never triggers. ROP is the reason NX alone is not enough, and the reason the next two mitigations exist.
3. ASLR + PIE — hide the addresses
Idea: ROP and ret2libc both require the attacker to know addresses — of system, of gadgets, of the stack. Address Space Layout Randomization (ASLR) randomizes the base address of the stack, heap, and shared libraries on every run; compiling the executable itself as a PIE (Position-Independent Executable, gcc -fPIE -pie, now the default) randomizes the program’s own code too. Guess wrong and you crash instead of exploit.
Linux ASLR is controlled by /proc/sys/kernel/randomize_va_space (2 = full, the default; 0 = off — which is what setarch -R forced above for our demo).
Attacker answers: ASLR randomizes a base; offsets within a library stay fixed. So leak one address (again, via an info-leak bug) and you can compute every other address in that region. On 32-bit there is also little entropy — sometimes you can simply brute-force it. ASLR’s strength is entirely dependent on there being no information leak, which is why memory-disclosure bugs are valued so highly.
4. CFI and shadow stacks — protect the control flow directly
The mitigations above protect data on the stack or hide addresses. The newest layer protects the control transfer itself.
- Control-Flow Integrity (CFI): the compiler computes the set of legitimate targets for each indirect call/jump and inserts a runtime check that the actual target is in that set. A ROP chain jumps to gadget addresses that are not valid function entries, so CFI rejects them. Clang’s
-fsanitize=cfi, and Windows Control Flow Guard (CFG), implement this. - Shadow stacks keep a second, protected copy of return addresses; on
retthe CPU compares the real return address against the shadow copy and faults on mismatch. This is now hardware-backed: Intel CET (SHSTKfor shadow stack,IBTfor indirect-branch tracking) and ARM’s pointer authentication / BTI. pwndbg’schecksecreportsSHSTKandIBTstatus on binaries built for it.
Attacker answers: data-only attacks that corrupt values without ever diverting control flow (e.g., flipping an is_admin flag), JIT-spraying, and gaps in coarse-grained CFI policies. The race continues — but each round costs the attacker more.
5. The supporting cast
- RELRO (Relocation Read-Only): makes the GOT (Global Offset Table) read-only after startup so attackers cannot hijack library-call resolution.
PartialvsFull RELROinchecksec. - FORTIFY_SOURCE (
-D_FORTIFY_SOURCE=2): replaces unbounded libc calls with length-checked variants where the compiler knows the buffer size. -Wall -Wextraand static analysis: catch many of these at compile time, for free.
Reading a binary’s defenses: checksec
Before attacking (or auditing) a binary, the first question is which mitigations are even on. pwndbg’s built-in checksec answers it:
pwndbg> checksec
File: /home/student/vuln
Arch: i386
RELRO: Partial RELRO
Stack: No canary found ← overflow won't be detected
NX: NX disabled ← injected shellcode will run
PIE: No PIE (0x8048000) ← code is at a fixed, known address
That output is our deliberately-weakened demo — every protection a real binary should have is missing. Compare it to a system binary:
pwndbg> checksec
File: /usr/bin/bash
Arch: amd64
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Reading checksec first tells you which techniques from the arms race are even on the table.
The real fix: don’t write the bug
Every mitigation above is damage control for a bug that already exists — they make exploitation harder and more expensive, not impossible. The structural fix is to stop the corruption from being possible at all:
- Memory-safe languages. Rust enforces memory safety at compile time (ownership + borrow checking) with no garbage collector, giving C-like performance without the bug class. Go, Java, C#, Python, and others are memory-safe via runtime checks and/or GC. In 2023–2024, CISA, NSA, and the White House ONCD issued guidance urging a shift to memory-safe languages for new code, citing that ~70% of serious vulnerabilities in large C/C++ codebases (Microsoft, Chromium) are memory-safety bugs.
- Safer C practices where C is unavoidable: bounded functions (
strncpy,snprintf, nevergets/strcpy/sprintf),_FORTIFY_SOURCE, all warnings on, and continuous fuzzing to find the bugs before attackers do.
Defense-in-depth summary
No single mitigation is sufficient — each one closed a door, and attackers found a window. Their value is cumulative, which is exactly the defense-in-depth lesson:
| Mitigation | Stops / raises cost of | Bypassed by |
|---|---|---|
| Stack canary | Detects linear stack smashing | Info leak of the canary; non-linear writes |
| NX / DEP (W^X) | Executing injected shellcode | ret2libc, ROP (code reuse) |
| ASLR / PIE | Knowing where gadgets/libc/stack are | A single address leak; low 32-bit entropy |
| CFI / shadow stack (CET) | Diverting control flow / ROP | Data-only attacks; policy gaps |
| RELRO | GOT-overwrite hijacks | n/a for that vector |
| Memory-safe language | The bug existing at all | (eliminates the class) |
Key takeaways
- Memory corruption is a failure of the machine model: von Neumann (code = data) plus memory-unsafe languages (no automatic bounds checks) means hostile input can overwrite the data the CPU trusts — above all, return addresses.
- The canonical primitive is the stack buffer overflow: enough bytes past a local buffer overwrite the saved return address and hand the attacker the instruction pointer. You find the offset with a cyclic pattern (
cyclic/cyclic -lin pwndbg). - Defenses arrived as an arms race: canaries → leak them; NX → ROP/ret2libc; ASLR → leak an address; CFI/shadow stacks → data-only attacks. Each is necessary, none is sufficient — textbook defense in depth.
- Read what is enabled with
checksec. - The durable fix is memory-safe languages; mitigations buy time for the code that must stay in C/C++.
References
- A. One (Elias Levy), Smashing the Stack for Fun and Profit, Phrack 49, 1996 — the foundational paper. http://phrack.org/issues/49/14.html
- H. Shacham, The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls, CCS 2007 — the paper that introduced ROP. https://hovav.net/ucsd/papers/s07.html
- C. Cowan et al., StackGuard: Automatic Adaptive Detection and Prevention of Buffer-Overflow Attacks, USENIX Security 1998 — stack canaries. https://www.usenix.org/legacy/publications/library/proceedings/sec98/cowan.html
- PaX Team, Address Space Layout Randomization, 2003. https://pax.grsecurity.net/docs/aslr.txt
- M. Abadi et al., Control-Flow Integrity, CCS 2005. https://users.soe.ucsc.edu/~abadi/Papers/cfi-ccs-final.pdf
- Intel, Control-flow Enforcement Technology (CET) — Shadow Stack & IBT. https://www.intel.com/content/www/us/en/developer/articles/technical/technical-look-control-flow-enforcement-technology.html
- pwndbg documentation —
cyclicandchecksec. https://pwndbg.re/pwndbg/latest/commands/ - CISA/NSA, The Case for Memory Safe Roadmaps (2023). https://www.cisa.gov/resources-tools/resources/case-memory-safe-roadmaps
gcc(1)security flags:-fstack-protector-strong,-fPIE/-pie,-D_FORTIFY_SOURCE,-z noexecstack. https://man7.org/linux/man-pages/man1/gcc.1.html
Related course pages: Access Control and Authorization · Network-Based Fuzzing · Application Security · DevSecOps Fundamentals · Lab #5: Binary Bomb
🛠️ Maintenance note: the
checksecfield names and theSHSTK/IBTlines track the installed pwndbg version — re-verify against the course VM’s pwndbg each term. Compiler defaults drift toward more protection (PIE,-fstack-protector-strong, and full RELRO are now on by default on most distros), so the explicit “disable” flags in the worked example are what keep the demo reproducible; confirm they still produce aNo canary / NX disabled / No PIEbinary on the current GCC.