It is easy to read printf(buff) and printf("%s", buff) as two spellings of the same call. One prints a string the caller passed in; the other prints the same string but routes it through a %s specifier. The output looks the same for benign buff. The reading is wrong, and the reason it is wrong is the thing this whole attack class rests on.
The tempting picture: the format string is templating
On the tempting picture, printf is a templating helper. You give it a template and some arguments; the function substitutes each placeholder in the template with one argument. The number of %s in the template has to match the number of arguments the programmer passed, because that is how a template engine works — read the template, count the placeholders, expect that many arguments. printf(buff) and printf("%s", buff) differ only in that the first has zero placeholders and the second has one placeholder matched to one argument; both are inert when buff is just text. Format strings are templating.
Move to the ABI and the picture breaks.
The mechanism: varargs passes where, not how many
The C calling convention for variadic functions is the problem. For a normal call, the prototype declares the parameter types and the compiler lays arguments out according to it. For a variadic function like printf, the prototype ends in ... — meaning there is no declared argument count and no declared argument list after the fixed parameters. The ABI passes the fixed parameters in registers and on the stack as usual, and for the rest it hands the callee a pointer to where the variadic arguments start. It does not pass how many.
So printf has to recover the count itself, and the only information it has to recover it from is the format string. It walks the argument list by parsing % specifiers off the format string and consuming one variadic argument per specifier. The format string is, in effect, the missing argument count — parsed lazily, from data, by the call itself.
When the programmer owns the format string, the count lines up: they wrote as many %s as they passed arguments, and the walk stops on the last argument. When the user owns the format string, the specifiers the user wrote are the ones printf walks — and the program’s actual arguments ended some number of slots ago. Nothing told printf to stop. Each % walks one slot further past where the program’s arguments ended, into adjacent stack.
Two ways the walk turns into a primitive
The walk past the declared arguments is bad on its own — it is an arbitrary stack read. The format string turns it into two distinct primitives by reinterpreting the slot it just walked to:
- a read specifier (
%x,%s,%llx, …) interprets the consumed slot as data of the specifier’s type and prints it.%xprints a 4-byte int from the slot;%sreinterprets the slot as a pointer and dereferences it, printing whatever character string it points at. The user has read arbitrary stack, and in the%scase dereferenced an arbitrary pointer of their choosing. - the
%nspecifier reinterprets the consumed slot as a pointer and writes to it, storing the number of characters printed so far. The walking read primitive has flipped to a walking write primitive. The size of the write is set by padding the printed count up to the desired byte; the address is set by arranging for the consumed slot to hold a target pointer the attacker primed elsewhere (an earlier variable, ascanf-primed local).
Same mechanism — user-owned specifier, slot consumed is not the program’s declared argument. %s and %n just decide whether to interpret that slot as data-to-print or pointer-to-write.
The escalation path
The write variant is what escalates the bug from an information leak to full control-flow hijack. An arbitrary write to a pointer the attacker supplied can target:
- the GOT entry of a function the binary will call next — overwrite
puts’s GOT slot with the address ofsystem, so the nextputs("…")callssystem("…")instead; - the saved return address on the stack of any frame still alive — redirect the next return into attacker-chosen code.
Crucially, both bypass stack canaries entirely: the write goes through a pointer the attacker picked, not along the stack linearly past a buffer, so it never crosses the canary. The canary defends against linear overflows; %n via a primed address is not linear. The attacker’s mov [rax], rdx write primitive from the previous lecture’s ROP discussion is exactly the same idea at the gadget level — %n is the printf-level version of it, with the format string providing the pointer and the padding providing the value.
The corrected model
printf(buff) and printf("%s", buff) are not stylistic variants. They are different operations:
printf("%s", buff)is one declared specifier matched to one declared argument; the function walks one slot and stops there.printf(buff)is a specifier list the user wrote, walked past the program’s arguments into whatever sits next on the stack, with%sdereferencing and%nflipping read to write.
The fix is structural: the first argument of a printf-family function must be a compile-time constant chosen by the programmer, so that the specifier-to-argument count is fixed by the call site, not by the user. This is why printf(buff) is categorized as a taint-style vulnerability — the tainted source is the input-reading function that filled buff, the sink is the first argument of printf, and the safe discipline is the same one the secure-C coding standard is pointing at: never let tainted data be the format string. The lesson at the ABI level is that the argument count has to come from somewhere trustworthy, and the format string is the only candidate the ABI gives you. Let the user own it and you have given them the count, and with it the walk, and with %n, the write.