Skip to main content
Be Bitwise

How printf finds its arguments

Variadic functions, the System V AMD64 ABI, and what va_list is really doing. Plus why a wrong format string is a security problem, not just a bug.

#c#printf#calling-conventions#format-string

printf is the first function most C programmers ever call, and one of the strangest. It takes any number of arguments of any type, with the type information encoded entirely in a string at runtime. There's no compile-time check that the arguments match the format. There's no way for printf to ask the language what it was passed. The mechanism is older than C89 and survives mostly out of inertia, but the inner workings are genuinely interesting once you sit with them.

The calling convention is the whole story

On x86-64 Linux, the System V AMD64 ABI says: the first six integer or pointer arguments to a function go in rdi, rsi, rdx, rcx, r8, r9, in that order. The first eight floating-point arguments go in xmm0 through xmm7. Anything left over spills onto the stack. The caller is responsible for placing arguments in the right places before the call instruction; the callee can read them from those registers.

For a fixed-arity function the compiler knows exactly which registers hold which arguments. For a variadic function, the callee has a runtime question — "what's the third argument?" — and a runtime answer can only come from the registers and the stack the caller actually populated.

Argument index Integer/pointer Float/double
1 rdi xmm0
2 rsi xmm1
3 rdx xmm2
4 rcx xmm3
5 r8 xmm4
6 r9 xmm5
7+ stack stack/xmm6+

When you write printf("%d %s\n", 42, "hi"), the compiler emits:

nasm
lea rdi, [rip + .Lstr] ; "%d %s\n"
mov esi, 42 ; first variadic arg, integer
lea rdx, [rip + .Lhi] ; second variadic arg, pointer
xor eax, eax ; AL = number of vector args, 0 here
call printf

The format string is a regular argument in rdi. The variadic arguments go into the remaining integer registers as if they were ordinary positional arguments. There's nothing remarkable in the call site; the work happens inside printf.

al deserves a glance. The ABI requires variadic callers to set al to the number of XMM registers used for floating-point variadic args. printf reads it to decide how many XMM saves it needs. With no float args, xor eax, eax is enough.

What va_list actually is

The header <stdarg.h> exposes va_list, va_start, va_arg, va_end. The API is portable; the implementation is whatever the ABI demands. On System V AMD64, va_list is a small struct, not a pointer:

c
typedef struct {
unsigned int gp_offset; // next int reg to read (in bytes from reg_save_area)
unsigned int fp_offset; // next xmm reg to read
void *overflow_arg_area; // stack args
void *reg_save_area; // pointer to a 176-byte block on the stack
} __va_list_tag;

When a variadic function is entered, the prologue spills the six integer argument registers and (if al says so) the eight XMM registers into a 176-byte reg_save_area on the stack. va_start initialises a va_list so it points at this save area, with gp_offset and fp_offset set to skip past the named arguments.

va_arg(ap, int) reads four bytes at reg_save_area + gp_offset, advances gp_offset by 8 (registers are 8 bytes wide in the save area), and returns the value. Once gp_offset exceeds the size of the save area's integer region, va_arg switches to walking overflow_arg_area, which sits on the regular stack above the return address.

It's machinery. The format string just tells the loop which va_arg to call.

Why a wrong format string is dangerous

printf has no way to know how many arguments you actually passed. It walks the format string and calls va_arg for each % directive, returning whatever's in the next slot. Pass printf("%s", 42) and printf will treat the integer 42 as a char * and try to read a string from address 0x000000000000002a. Crash, if you're lucky.

That's the benign case. Now consider:

c
char input[256];
fgets(input, sizeof input, stdin);
printf(input);

The user controls the format string. They can type %x %x %x %x and printf will helpfully dump the next four register/stack slots — which is to say, several of printf's caller's local variables and saved registers. They can type %s and printf will dereference whichever slot it lands on as a pointer and try to print it as a string. They can use %n, the format directive that writes the number of bytes printed so far to the corresponding pointer argument, and turn the read primitive into a write primitive. With careful padding in the format string they can place arbitrary values at arbitrary addresses, one or two bytes at a time.

This is the format-string bug class. It's been around since the 1990s, and it's the canonical example of trusting attacker-controlled data to drive an interpreter (in this case, the format string interpreter inside printf).

The compiler's defence

GCC and Clang both recognise the printf family by name (or by the __attribute__((format(printf, n, m))) annotation, which you can put on your own variadic functions) and check the format string at compile time when it's a string literal. Mismatched types: warning. Wrong number of arguments: warning. With -Wformat-security (or -Wall, depending on version) the compiler will additionally warn when the format string is not a literal at all — printf(input) exactly as above.

You can satisfy the warning with printf("%s", input). The format string is now a literal that says "print this argument as a string", and the user's input is data, not code. The lesson generalises: any function that interprets one of its arguments as a program — system, eval, Runtime.exec, SQL drivers, sprintf of HTML — has the same shape of vulnerability.

The reason modern compilers warn so loudly on non-literal format strings is that there's almost no good reason to do it. Internationalisation libraries that look up format strings by ID still produce something that should be treated as trusted, and those libraries usually have their own wrappers. Plain printf(some_buffer) is almost always either a bug or an exploit primitive in waiting.

Where this fits

The calling convention angle lives in Module 21: Assembly Language, which walks through System V AMD64 in detail — argument passing, the red zone, stack alignment, callee-saved versus caller-saved, and how variadic prologues actually look in disassembly. Once you can read the prologue of printf, the format-string bug stops being a clever trick and starts looking inevitable.