The bug is one character of carelessness:
printf walks its format string looking for conversions, and pulls each
argument from where the calling convention says the next argument should be —
registers, then the stack. But you only passed one argument. So every %p,
%x, %n you smuggle in reads (or writes) memory that was never meant to be an
argument at all. That's not a info-leak or a write bug; it's both.
- arch
- amd64-64-little
- RELROpartial
- canaryon
- NXon
- PIEoff
step 1 — read the stack with %p#
On x86-64 the first arguments live in registers: the format string is in rdi,
then rsi rdx rcx r8 r9 would hold conversions 1–5, and conversion 6 onward
comes off the stack. So %6$p prints the first stack quadword, %7$p the next,
and so on — %N$ is direct parameter access, no need to walk every slot:
Spray %ps, find the one that prints your own bytes, and you've learned your
input's offset — the number you'll aim %n through later. The other leaks are
gifts: a 0x55…/0x56… value is a PIE/code pointer, 0x7f… is libc or the
stack. One format string, and ASLR is usually gone.
find the offset without counting
Send AAAAAAAA%6$p%7$p%8$p… and look for 0x4141414141414141 in the output.
The position where it appears is the offset to the start of your buffer.
pwntools' FmtStr autodetects it for you, but do it by hand once.
step 2 — %n, the write#
%n is the conversion everyone forgets exists: it writes the number of bytes
printed so far to the int * argument. Its cousins write narrower: %hn a
short, %hhn a single byte. So to write a value you control the count — print
exactly that many characters, then fire %n at a pointer you placed in the
argument area.
Writing a full 8-byte address with one %n would mean printing billions of
chars, so you split it into byte-sized writes with %hhn and width specifiers,
writing each target byte by padding to the right cumulative count:
Hand-assembling that is miserable, so let pwntools compute it — give it the
offset from step 1 and an {address: value} map:
the flag
exit()'s GOT slot now points at win(), so the program's own clean-up hands
you the shell:
segfault{n0_f0rm4t_n0_party}
what the mitigations actually do#
- Partial RELRO (above) leaves the GOT writable — the easy target. Full
RELRO resolves and read-onlys the GOT at load, so you pivot to a writable
function pointer instead: a saved return address,
__free_hook/__malloc_hook(pre-2.34),_IO_2_1_stdout_'s vtable, or__exit_funcs. FORTIFY_SOURCErewritesprintfto__printf_chk, which rejects%nin a writable format string and requires positional args to be contiguous. It's a real speed-bump — but a read-only format string with%n(rare) or a different sink slips it.- Canary / PIE / NX don't touch this bug at all; the format string leaks exactly the canary and PIE base you'd otherwise be missing. That's the cruel part — a format string is frequently the leak that arms a different overflow.
The lesson that sticks: printf(user_input) isn't a logging bug, it's a
read-anywhere + write-anywhere primitive handed to you in one line. Treat
every uncontrolled format string as full compromise until proven otherwise.