hacking track
week 14 · day 39

Anatomy of a Stack Buffer Overflow

Overwrite the return address, hijack execution

Insane 42 min 180 xp
After this you can
  • 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.

vuln.c, a program that trusts its input too muchc
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 stack
10 printf("Input: ");
11 gets(buffer); // <-- reads UNBOUNDED input into a 64-byte buffer
12 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.

vulnerable()'s stack frame (higher address at top)
 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 address
Key idea
Here is the whole vulnerability in one sentence: the buffer sits at a lower address than the return address, and `gets` writes upward toward it. Write 64 bytes and you fill the buffer exactly. Write more and you start overwriting the saved rbp, then the return address itself. Overwrite the return address with a value you choose, and when vulnerable 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.

build the target in your lab (mitigations disabled for learning)sh
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 predictable
4gcc -fno-stack-protector -z execstack -no-pie -g -o vuln vuln.c
5
6# a normal, short input: behaves fine
7echo "hello" | ./vuln
8
9# now overflow: 100 bytes into a 64-byte buffer
10python3 -c "print('A'*100)" | ./vuln
11# -> 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:

prove you control rip with gdbsh
1gdb ./vuln
2(gdb) run <<< $(python3 -c "print('A'*100)")
3# Program received signal SIGSEGV
4(gdb) info registers rip
5# rip 0x4141414141414141 <- YOUR bytes are now the instruction pointer

Finding 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.

find the offset with pwntools' cyclic patternsh
1# generate 200 unique bytes, feed them in
2python3 -c "from pwn import *; print(cyclic(200).decode())" > pattern.txt
3gdb ./vuln
4(gdb) run < pattern.txt
5(gdb) info registers rip # e.g. rip = 0x6161616161616166
6# feed that value back to get the exact offset:
7(gdb) quit
8python3 -c "from pwn import *; print(cyclic_find(0x6161616161616166))"
9# -> 72 (64-byte buffer + 8-byte saved rbp = 72 before the return address)
Note
So the layout is: 72 bytes of filler, then the next 8 bytes overwrite the return address. An exploit payload becomes 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.
Watch out
On a modern system this exact attack is blocked by several mitigations, stack canaries detect the overwrite, ASLR randomizes addresses, NX makes injected stack code non-executable. We disabled them deliberately to see the pure mechanism. Days 12-13 turn them back on and show the techniques (ROP, ret2libc, info leaks) that real exploitation uses to defeat them. Master the mechanism first; the bypasses only make sense once you understand what they're bypassing.
finished reading?
Your task, you write the code

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.

deliverable: vuln.c and exploit.py
build & run
$ 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
self-review before running
  • 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
stretchSolve pwnable.kr 'bof' or picoCTF's classic buffer-overflow challenges. They are this exact technique against a remote (authorized) target, your first 'real' pwn.

Self-check

01Why does overflowing a stack buffer let an attacker control execution?
02The offset from the buffer to the return address is 72 for a 64-byte buffer because:
03In the payload `b'A'*72 + p64(win_addr)`, why p64 (little-endian packing)?
04We compiled with -fno-stack-protector, -z execstack, -no-pie because:
0/4 correct · 0/4 checked