Security Engineering · Binary Exploitation
How a Format String Becomes an Arbitrary Write
The %n conversion stores a count through a pointer the caller supplies on the stack, and variadic argument handling makes that pointer reachable from the format itself.
A format string is a program that the printf family interprets. It contains literal text and conversions, and each conversion consumes an argument from a list whose length the format determines. When the format is supplied by a user and the arguments are not, the interpreter reads and writes whatever the calling convention put in the argument slots. That is the whole vulnerability: printf cannot tell how many arguments it was given, because the C variadic interface does not carry a count.
The variadic calling convention on x86-64
The System V ABI passes the first six integer or pointer arguments in rdi, rsi, rdx, rcx, r8, and r9. A variadic function does not know how many of those registers hold meaningful values, so the caller sets the low bit of al to the number of vector registers used, and the callee spills all six integer registers into a register save area in its own frame before walking the format. Arguments beyond the sixth are read from the stack, at increasing addresses above the return address.
The upshot is that a call to printf with one argument has five integer registers that the callee will save and treat as argument slots if the format asks for them. Those registers contain whatever the caller left there: pointers to the format string, values from earlier computation, and addresses of the caller’s own data. A format containing %p %p %p %p %p %p prints them. The values are not random; they are the caller’s live state, and they are stable enough within a single build to be used as the basis of a leak.
On 32-bit x86 there is no register save area. Every argument is on the stack, so %x walks upward through the caller’s frame: saved registers, the return address, and the caller’s locals. The first few conversions read the arguments the function actually received, and subsequent ones read whatever follows. The distinction between the two architectures is only how many conversions are consumed before the walk leaves the arguments and enters unrelated memory.
Reading with a position specifier
The n$ syntax selects an argument by index: %6$p prints the sixth. This is what makes the leak controllable rather than a sequential scan. On x86-64 the sixth slot is the first stack-passed argument, and it is usually the format string’s own address when printf was called with the format and nothing else, because the caller had to load that address into rdi and the callee spilled rsi through r9 from whatever the caller left.
An attacker who supplies the format controls which slots are read. If the input is itself on the stack, at a position the input can discover by trying successive indices, then the attacker can point a conversion at the buffer holding the input and read bytes the program never intended to print. This is the standard way a stack address is recovered, and it feeds directly into the randomisation problem: one leaked pointer defeats the layout defence.
The scan is cheap because a wrong index produces a printed value that is obviously not an address, and a right index produces a value in the range of the stack or the heap. An input of the form AAAA %6$p %7$p %8$p ... identifies its own position by the appearance of 0x41414141 or the eight-byte equivalent. That marker technique works because the input bytes are on the stack and one of the slots points at them.
The %n conversion and what it counts
%n writes the number of characters printed so far through a pointer argument. It is part of the C standard, intended for code that needs to know how much output a conversion produced. Its argument is a pointer to an integer, and the size of the write depends on the length modifier: %n writes an int (four bytes on the common platforms), %hn a short (two bytes), %hhn a char (one byte).
A format string that contains %n and has no corresponding argument makes printf treat whatever occupies that argument slot as a pointer and write a count through it. If the attacker can place a chosen address in that slot, the count becomes an arbitrary four-byte write to an arbitrary location. The count is the number of bytes emitted so far, which the attacker controls by padding the output with literals or with width specifiers.
The width specifier is the efficient control. %100c prints one character padded to 100 columns, consuming one argument and advancing the count by 100. %12345d does the same with a number. The count is therefore settable to any value in a range determined by the width field, at a cost of that many bytes of output, which is why writes are done with %hn in two halves rather than %n in one: writing a 64-bit address would require billions of bytes of padding, while two 16-bit writes need at most 65,535 bytes each.
Putting the address where the conversion will read it
The address must be in the argument slot the format selects. The attacker’s input is usually the format string itself, and its bytes are on the stack when printf is called with it. So the attacker writes the target address as raw bytes at the start of the input, then uses a position specifier to select the slot that contains those bytes.
The layout of the input is what makes this work. Eight bytes of address, then a format that reads them:
\x78\x56\x34\x12\x00\x00\x00\x00%7$hn
The eight bytes are a little-endian address. %7$hn selects argument seven, which the attacker has determined by scanning to be the slot holding the first eight bytes of the input. printf prints nothing before the %hn except the address bytes themselves, which count as eight characters, so the write value is eight unless padding is added. The trailing NUL of the address is written by the copying function, not by printf, and it lands inside the eight-byte field, so the address must be chosen such that the zero bytes are acceptable — which they are for the high half of a canonical user-space address.
The input cannot contain a NUL in the middle of the address, because the format string is a C string and stops there. A canonical x86-64 address has its top two bytes zero, which is fine at the end, but an address with an interior zero byte cannot be written this way. The workaround is to place the address at the end of the string and put the format before it, or to use a different primitive to write the address into memory first. Both are common, and both change the arithmetic of the count because the address bytes are printed.
Ordering the writes
Multiple %hn conversions in one format string execute in sequence, and each one’s count is the total output so far, not the output since the previous write. So a format that writes four 16-bit halves to build a 64-bit value must order the writes by increasing target value, or must use negative-equivalent padding.
The standard construction sorts the four halves ascending, then emits a width for the first that reaches its value, and for each subsequent one a width equal to the difference from the previous. The total output is bounded by the largest half, which is at most 65,535 bytes. A format that writes the halves in the order the addresses appear in memory requires padding to wrap the count around, which on a 32-bit int is achievable with a width near 2^32 but produces gigabytes of output and is impractical over a network.
Each address consumes eight bytes of the input, and each conversion consumes a position index. The input must therefore be sized to hold all the addresses plus the format text, and the position indices must be recomputed for the final layout. This is why format-string exploits are usually generated rather than hand-written: the offsets depend on the length of the string that carries them.
Why the count is a weak primitive and where it is enough
An arbitrary write that can only store a number derived from the output length is not arbitrary in value. Writing a chosen address into a GOT slot requires the value to be an address, which is a large number, and reaching it with padding is possible but produces output proportional to the value. For a 64-bit address in the 0x7f... range, the low four bytes alone require output in the billions, so the technique is used for small values or for addresses whose high half is already correct.
That limitation is why the practical target is a pointer whose current value is close to the desired one: overwriting the low two bytes of a GOT entry or a return address with %hn needs at most 65,535 bytes of output and leaves the high bytes, which are already correct, untouched. This is the same partial-overwrite arithmetic that makes PLT and GOT slots attractive, and it explains why the two techniques are so often used together.
A second target class is a length or a flag. Writing a small integer through %n to a global that controls a bounds check, a privilege flag, or an allocation size requires only the ability to reach that address, and the value can be anything the output length allows. These are the writes that turn a read-only leak into a control-flow change.
The check that removes the class
The direct defence is to never pass user data as the format. printf("%s", input) instead of printf(input) removes the interpreter’s control over conversions, and no amount of %n in the input has any effect. Compilers warn on a non-literal format when the arguments do not match, and -Wformat-security plus -Werror=format-security turns the warning into a build failure.
Hardening the C library is the second layer. glibc refuses %n in a format string that lives in writable memory, which blocks the case where the format is a modifiable buffer; the check does not help when the format is a read-only literal that happens to contain user data through a pointer. FORTIFY_SOURCE adds checks on the destination size for the _chk variants of the functions, which catches overflows into a fixed buffer but not the argument-walk problem.
None of these change the underlying interface. A variadic function in C cannot verify its argument count, so any code path where the format is attacker-influenced is a path where the callee will interpret attacker-chosen conversions. The defence is at the call site, and it is a discipline rather than a mechanism: the format is always a literal, and user data is always an argument.
References
- ISO/IEC 9899:2018 — Programming languages — C — ISO/IEC
- printf(3) — Linux Programmer's Manual — Linux man-pages project
- Exploiting Format String Vulnerabilities — scut / team TESO, 2001