Format string bugs: when %n writes where you point
A single printf(user_input) is a read and a write primitive in disguise. How %x leaks the stack, %s dereferences arbitrary pointers, and %n turns a logging bug into a controlled memory write - the whole ladder from leak to code execution.
The format string bug is one of the most elegant vulnerabilities in the pwn canon. It comes from a single mistake - passing user input as the format argument of a printf-family function, printf(user_input) instead of printf("%s", user_input) - and from that one mistake you get, in sequence, an arbitrary read, an information leak, and an arbitrary write. Few bugs hand you that much from so little.
The reason it is worth understanding rather than copying is that it gives you an arbitrary write, and what you do with one is the subject of the heap post as much as this one. The exploit is built directly out of how printf works. Once you see why %x leaks and %n writes, you can construct the exploit for any specific binary instead of hoping a template fits.
Why it happens
printf reads its format string looking for conversion specifiers, and for each one it finds, it fetches the next argument. It has no way to know how many arguments were actually passed - it trusts the format string to tell it. When you control the format string, you control how many arguments printf thinks it has, and it will happily walk right off the end of the real arguments and start reading whatever is on the stack next.
// The bug, in one line:
printf(user_input); // should have been printf("%s", user_input)
// You send: %x %x %x %x
// printf has no arguments to match those, so it prints four stack words.Rung 1: leak the stack with %x
Sending a row of %x (or %p for pointer-width, which is cleaner on 64-bit) dumps successive stack words. This is immediately useful: the stack holds saved return addresses, canary values, and libc pointers, and leaking any of them defeats a mitigation. A leaked libc address breaks ASLR; a leaked canary lets a companion overflow through.
# Find where your own input sits on the stack by planting a marker:
AAAA %p %p %p %p %p %p %p %p
# When one of the leaked values is 0x41414141 (AAAA), that position is
# your buffer - call it argument N. Now you can both read and write there.
# Positional specifiers jump straight to argument N without padding:
%7$p # print the 7th argument directlyRung 2: read arbitrary memory with %s
%s treats its argument as a pointer and prints the string it points to. If you can place an address into the stack at the position printf will read, %s dereferences it and leaks the memory there. Since your input is on the stack, you place the target address in your input and use a positional %s to reach it.
# Put the target address in your buffer, then %s that slot:
# [target address bytes][%7$s]
# printf reads slot 7 (your address) and prints the bytes it points to.
# This reads any readable address - a GOT entry, a global, the flag in .data.Rung 3: write memory with %n
This is the specifier that turns a read bug into a write bug. %n does not print anything - it writes the number of characters printed so far into the address given by its argument. Control the argument (an address in your buffer) and control the count (by printing that many characters first), and you have written a chosen value to a chosen address.
Writing a full 64-bit value by printing that many characters is impossible - you cannot print four billion bytes. The standard trick is to write in pieces: %hn writes two bytes at a time, %hhn writes one, so you split the target value into short or byte chunks and write each with its own %n, adjusting the running count between them. Field width (%100x) inflates the count cheaply without printing real data.
# Conceptually, to write value V to address A:
# [address A in the buffer]
# %<width to make count = low half of V>x %k$hn # write low 2 bytes
# %<more width to reach high half>x %(k+1)$hn # write high 2 bytes
# pwntools' fmtstr_payload() computes all the widths and ordering for you.What to overwrite
An arbitrary write is only as good as your target. The classic destinations, roughly in order of how often they work:
- A GOT entry. Overwrite the Global Offset Table entry of a function the program calls after your write - point printf or exit at system, then trigger the call with '/bin/sh' in reach. The archetypal format-string-to-shell.
- The return address. If your write can reach a saved return address on the stack, redirect execution to a one-gadget or a ROP chain.
- __malloc_hook / __free_hook. On older glibc, overwriting these hooks redirects the next allocation call - a reliable path when the GOT is read-only.
- A loop counter or auth flag. Sometimes the challenge does not need code execution at all - flipping one global from 0 to 1 unlocks the flag directly.
The ladder
- Confirm the bug: does %p or %x in your input print stack data?
- Find your offset: plant a marker and see which %N$p prints it.
- Leak: %p rows for canary/libc/stack; %s for arbitrary reads to break ASLR.
- Compute libc base from a GOT leak.
- Write with %n (via fmtstr_payload): overwrite a GOT entry, hook, or return address.
- Trigger the overwritten call and catch your shell.