Smashing the Stack: A Deep Dive into Buffer Overflows
Deconstruct the anatomy of a stack frame. Learn how uncontrolled memory writes hijack the instruction pointer and grant absolute control over CPU execution.
The C language has no runtime bounds checking. Declare a 16-byte array, write 100 bytes into it, and the CPU will comply without complaint β overwriting whatever happens to occupy the adjacent memory. In most cases that means a crash. In specific, controllable cases, it means an attacker chooses what the CPU executes next.
Buffer overflows have been understood since the Morris Worm in 1988. They remain relevant today not because the underlying bug is subtle β it isnβt β but because the techniques required to exploit them against modern mitigations have become increasingly sophisticated. Understanding the basic mechanism is the prerequisite for understanding everything that came after.
The Stack Frame
When a function is called, the compiler arranges a stack frame: a region of the stack holding the functionβs local variables, saved registers, and the address the CPU should return to when the function completes.
On x86_64, the stack grows downward β from higher addresses toward lower. Local variables are allocated by subtracting from the stack pointer. Buffers, however, are written to from lower addresses upward. This directional opposition is what makes overflows dangerous: writing past the end of a buffer writes toward the saved return address.
A typical x86_64 stack frame layout after the function prologue (with frame pointer, compiled without optimization):
High addressesβββββββββββββββββββββββββββββββββ ... caller's stack frame βββββββββββββββββββββββββββββββββ€ β rbp + 16β Return Address (8 bytes) β β the address of the instruction after callββββββββββββββββββββββββββββββββ€ β rbp + 8 (wait β see note below)β Saved RBP (8 bytes) β β rbpββββββββββββββββββββββββββββββββ€β Local buffer[16] (16 bytes) β β rbp - 16ββββββββββββββββββββββββββββββββ€β ... more locals if any β β rspLow addressesOne clarification on what βallocates the stack frameβ: the OS determines the total size and base address of the stack segment for the process. The frame itself is created by the function prologue β compiler-generated instructions that run at function entry β not by the OS. The OS has no visibility into individual function calls.
Modern compilers also donβt always use a frame pointer. With -O2 or higher, GCC omits rbp as a frame pointer (it becomes a general-purpose register), making the frame layout flatter but harder to read in a debugger. For this walkthrough, we assume -fno-omit-frame-pointer.
The Vulnerable Code
#include <stdio.h>#include <string.h>
void check_authentication(char *input) { char buffer[16];
/* * strcpy copies bytes from input until it hits a null byte. * It has no knowledge of the destination buffer's size. * Other functions with the same problem: gets(), scanf("%s"), * sprintf() with an unbounded format string. */ strcpy(buffer, input);
printf("Access Denied.\n");}
int main(int argc, char **argv) { if (argc > 1) { check_authentication(argv[1]); } return 0;}The safe alternative isnβt strncpy β it doesnβt null-terminate when the source is longer than the limit, which trades an overflow for a read of uninitialized memory. Use snprintf(buffer, sizeof(buffer), "%s", input) or strlcpy (available on BSD/macOS and as a glibc extension) instead.
The Assembly: Prologue and Epilogue
What the compiler emits for check_authentication (AT&T syntax, simplified):
Prologue (executed at function entry):
push rbp ; save caller's base pointer onto the stackmov rbp, rsp ; set current stack top as new frame basesub rsp, 16 ; allocate 16 bytes for buffer[]Epilogue (executed at function exit):
leave ; equivalent to: mov rsp, rbp; pop rbpret ; pop 8 bytes from stack top into RIP and jump thereThe ret instruction trusts whatever 8 bytes sit at the current stack pointer to be the legitimate return address. It doesnβt validate them. If those bytes have been overwritten, the CPU jumps to wherever they point.
Reproducing the Overflow
Modern compilers enable several mitigations by default. To observe the raw overflow behavior, compile with protections disabled:
gcc -o vuln vuln.c \ -fno-stack-protector \ # disable stack canaries -no-pie \ # disable position-independent executable -z execstack \ # mark stack as executable (for shellcode demo) -g # include debug symbols for GDBRun with a 24-byte input to confirm it reaches the return address:
python3 -c "import sys; sys.stdout.buffer.write(b'A'*24 + b'B'*8)" | \ xargs ./vuln# Segmentation fault (core dumped)In GDB, you can watch the overwrite happen:
gdb ./vuln(gdb) run $(python3 -c "print('A'*24 + 'B'*8)")(gdb) info registers rip# rip: 0x4242424242424242 β 'BBBBBBBB' in hexThe stack layout at the time of ret:
- Bytes 0β15:
buffer[16] - Bytes 16β23: saved
rbp(8 bytes) - Bytes 24β31: return address β overwritten with
0x4242424242424242
Total distance from buffer start to return address: 16 + 8 = 24 bytes. This is the offset β the exact number of padding bytes needed before the address you want to inject.
Shellcode Injection
With the return address under control, the question becomes: what address do you inject, and whatβs there?
The classic technique places machine code directly in the buffer, then overwrites the return address to point back into it. The payload structure:
[ NOP sled ] [ shellcode ] [ padding ] [ &buffer on stack ]NOP sled (0x90): The x86 NOP instruction does nothing and advances to the next byte. Prepending shellcode with a sequence of NOPs creates a landing zone β if the injected address points anywhere into the sled, execution slides into the shellcode. This compensates for slight stack address variation between runs.
Shellcode: Raw machine bytes that exec /bin/sh or open a reverse shell connection. A minimal execve("/bin/sh") shellcode on x86_64 is around 27 bytes.
Target address: The approximate address of the NOP sled on the stack β found by examining $rsp in GDB when the buffer is in scope.
One common misconception: this gives you a shell running as the current user, not root. Privilege escalation requires a separate step β typically chaining this into a SUID binary or a kernel vulnerability.
Modern Mitigations
Compiling without protections is necessary for the demo above but not representative of what youβd face on a production system. Three mitigations are standard:
Stack Canaries (-fstack-protector-all): The compiler inserts a random value (the canary) between the local buffer and the saved rbp, then checks it before executing ret. If the overflow has modified it, the program calls __stack_chk_fail() and aborts. The canary value is read from _dl_random at process startup β random per execution but not cryptographically generated in the sense of a CSPRNG output.
NX / W^X (-z noexecstack, default on most Linux systems): The stackβs page table entries have the NX (No-Execute) bit set. Jumping to shellcode on the stack triggers a fault before any shellcode byte executes. This breaks the classic injection technique entirely.
ASLR + PIE: ASLR randomizes the base addresses of the stack, heap, and loaded libraries at process start. But without PIE (Position-Independent Executable), the main binary itself loads at a fixed address β making ASLR only partially effective. Compiling with -pie -fPIE randomizes the binaryβs own load address. Together, ASLR + PIE means you canβt hardcode addresses for any part of the process memory.
These three together close the door on the technique described above. The return address youβd want to inject is at an unknown location, the stack isnβt executable, and the canary will fire before ret if youβve overwritten through it.
Return-Oriented Programming (ROP)
Modern exploits work around NX by not injecting any code at all. Instead, they hijack the return address to point to gadgets: short sequences of legitimate instructions already present in the binary or its loaded libraries, each ending in ret.
By chaining gadgets, an attacker can construct arbitrary computation using code that was already marked executable β so NX provides no defense. A minimal example:
gadget 1: pop rdi; ret β load a value into rdi (first argument register)gadget 2: <address of "/bin/sh" string in libc>gadget 3: ret2libc β call system() or execve() in libcThe attack called ret2libc was the first widely understood ROP variant: overwrite the return address to jump directly into system() in libc (which is executable, non-randomized without ASLR, and already loaded). Pass it the address of the string /bin/sh (also in libc) as an argument. No shellcode, no NX violation.
Against full ASLR + PIE, ROP requires an additional information leak β a separate vulnerability that reveals a runtime address, from which the attacker can compute the positions of gadgets. This is why modern exploitation is almost never a single bug: itβs a chain where one vulnerability provides a leak and another provides control flow hijack.
Tools like ROPgadget and pwntools automate gadget discovery and chain construction. Understanding the stack frame mechanics above is the prerequisite for understanding why those tools work.
Author
Varkin Academy