Skip to content

A format string is a program

Level: 301 · deep dive

One line: printf's first argument is not text it prints — it is a little program of directives it runs, one of which (%n) writes to memory and another of which (%s) reads a pointer off the argument list, so printf(user_input) hands the user that program, and the fix is to make the format a literal the compiler can see and turn the user's text into an argument to it.

The format string vulnerability is a good one to understand slowly, because the bug is not a mistake in printfprintf does exactly what it is documented to do. The mistake is passing data where a program was expected. That is the same shape as SQL injection and shell injection: a string that one layer treats as inert is run as instructions by the next. Here the next layer is printf, and its instruction set includes reading and writing memory.

The directives are a language

format_string_c.c runs the same bytes as an argument and as a format, then shows the two directives that make a format dangerous:

format_string_c.c in full — pasted here by tools/run_examples.py from the file CI runs.

/* A format string is a small program that printf runs. The directives it can
   hold include one that writes to memory (%n), one that takes its width from
   an argument (%*d), and one whose meaning depends on whether it is text or
   format (%%). All of this is defined behaviour -- which is why a format the
   user controls is a program the user wrote. */
#include <stdio.h>

int main(void)
{
    const char *text = "100%% done";

    /* The same bytes as an argument and as the format. */
    printf("printf(\"%%s\\n\", text) prints: ");
    printf("%s\n", text);
    printf("printf(text) prints:           ");
    printf(text);
    printf("\n");

    /* %n writes the number of bytes printed so far into an int*. */
    int written = -1;
    printf("hello%n\n", &written);
    printf("%%n stored %d\n", written);

    /* Width and precision can come from arguments, so a format can read
       further down the argument list than the text suggests. */
    printf("[%*d] [%-*d] [%.*s]\n", 6, 42, 6, 42, 3, "abcdef");

    /* snprintf with a null buffer measures without writing. */
    int need = snprintf(NULL, 0, "%d bottles of %s", 99, "beer");
    printf("snprintf(NULL, 0, ...) says the text needs %d bytes\n", need);
    return 0;
}

Verified output of format_string_c.c — regenerated by tools/run_examples.py, never hand-typed.

printf("%s\n", text) prints: 100%% done
printf(text) prints:           100% done
hello
%n stored 5
[    42] [42    ] [abc]
snprintf(NULL, 0, ...) says the text needs 18 bytes

The first two lines are the whole bug in miniature. The string 100%% done printed with printf("%s\n", text) comes out as 100%% done — the %s just drops the text in. Printed with printf(text), the same bytes come out as 100% done, because now the %% is a directive meaning "one percent sign". The bytes did not change; who read them as instructions did.

Then the two directives that make this more than a display glitch. %n writes the number of bytes printed so far into an int* taken from the argument list — a directive that writes to memory, chosen entirely by the format string. And %*d takes its field width from an argument, so a format can walk further down the argument list than its literal text suggests. A format the user controls, then, can read values off the stack with a row of %x, and write to an address with %n. That is arbitrary read and write, spelled in a string.

Everything above is fully defined behaviour, which is the uncomfortable part: printf(user_string) is not a program that might misbehave, it is a program that does precisely what the user's string says.

The fix is a literal, and the compiler can enforce it

The fix is one rule: the format string is always a literal you wrote, and anything from outside is an argument. demo/greet.c breaks the rule and demo/greet_fixed.c keeps it:

/* greet.c -- the bug: the user's text is printed AS the format. */
printf(argv[1]);

/* greet_fixed.c -- the fix: the format is a literal, the text an argument. */
printf("%s\n", argv[1]);

Because the good format is a literal, the compiler can read it — and both GCC and clang will refuse the bad one when asked. format_security_sh.sh builds each program with -Werror=format-security, and a third program that promises %d and passes a long with -Werror=format, then runs the two greeters on the same input:

format_security_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# Build the vulnerable greeter and the fixed one, ask each compiler to refuse
# the vulnerable one, and run both on the same input.
#
# Runs in a scratch copy of ../demo. GCC and clang word their warnings
# differently, so the compile steps print only an exit status; the messages
# themselves are on the page in fences that name the compiler. The runs print
# what the programs print, which is the same on both machines.
set -u
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
cp ../demo/*.c "$work"/
cd "$work" || exit 1

say()    { printf '$ %s\n' "$*"; eval "$*" 2>&1; }
status() { printf '$ %s\n' "$*"; eval "$*" >/dev/null 2>&1; echo "exit $?"; }

status cc -std=c17 -Wall -Wextra -Werror=format-security -o greet greet.c
status cc -std=c17 -Wall -Wextra -Werror=format-security -o greet_fixed greet_fixed.c
status cc -std=c17 -Wall -Wextra -Werror=format -o wrong_type wrong_type.c

# Without -Werror the vulnerable program builds (with a warning, not shown).
status cc -std=c17 -o greet greet.c

# One plain word survives either program unchanged.
say "./greet hello"
say "./greet_fixed hello"

# A % in the input is where they part: greet runs it as a format directive,
# greet_fixed prints it. "%%" is the literal two-character percent-sign.
say "./greet '100%% done'"
say "./greet_fixed '100%% done'"

Verified output of format_security_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ cc -std=c17 -Wall -Wextra -Werror=format-security -o greet greet.c
exit 1
$ cc -std=c17 -Wall -Wextra -Werror=format-security -o greet_fixed greet_fixed.c
exit 0
$ cc -std=c17 -Wall -Wextra -Werror=format -o wrong_type wrong_type.c
exit 1
$ cc -std=c17 -o greet greet.c
exit 0
$ ./greet hello
hello
$ ./greet_fixed hello
hello
$ ./greet '100%% done'
100% done
$ ./greet_fixed '100%% done'
100%% done

The exit statuses are the whole point: 1 for the vulnerable program, 0 for the fixed one, 1 again for the wrong-type one. The compilers word the diagnostics differently, so the script records only the status; here is what clang actually says, with GCC's wording noted beside it:

Real output — Apple clang 21, x86-64 macOS; CI checks the exit status, not this text
greet.c:10:12: error: format string is not a string literal (potentially insecure) [-Werror,-Wformat-security]
   10 |     printf(argv[1]);
      |            ^~~~~~~
greet.c:10:12: note: treat the string as an argument to avoid this
      |            "%s", 

GCC on the Ubuntu runner says format not a string literal and no format arguments for the same line, and format '%d' expects argument of type 'int', but argument 2 has type 'long int' for the wrong-type program. The messages differ; the refusal does not. The last thing the script proves is that without -Werror the vulnerable program still builds — the check is a warning by default, and making it a build error is a flag you have to add. It is worth adding: -Wformat -Wformat-security on, -Werror=format-security if you can.

The same shape in Python

str.format has the same bug, because a format the user supplies is run against the arguments, and a replacement field can walk attributes and subscripts. format_string_py.py:

format_string_py.py in full — pasted here by tools/run_examples.py from the file CI runs.

"""Python's str.format has the same shape of bug. A format the user supplies
is run against the arguments, and a replacement field can walk attributes and
subscripts -- so a format string can read things that were never meant to be
printed. f-strings cannot be built from user text at run time, which is the
same fix as Rust's: the format must be a literal."""
API_KEY = "hunter2"


class Greeting:
    def __init__(self, name):
        self.name = name


g = Greeting("Ada")

for fmt in ["Hello, {0.name}!", "{0.__class__.__name__}", "{0.__init__.__globals__[API_KEY]}"]:
    print(f"{fmt!r:44} -> {fmt.format(g)!r}")

# The % operator has the same problem with a mapping.
config = {"user": "ada", "api_key": API_KEY}
user_fmt = "%(api_key)s"
print(f"{user_fmt!r:44} -> {user_fmt % config!r}")

# A template with no attribute access: string.Template substitutes names only.
from string import Template
print(f"{'$name':44} -> {Template('$name').safe_substitute(name='Ada')!r}")

Verified output of format_string_py.py — regenerated by tools/run_examples.py, never hand-typed.

'Hello, {0.name}!'                           -> 'Hello, Ada!'
'{0.__class__.__name__}'                     -> 'Greeting'
'{0.__init__.__globals__[API_KEY]}'          -> 'hunter2'
'%(api_key)s'                                -> 'hunter2'
$name                                        -> 'Ada'

A user-supplied "{0.__init__.__globals__[API_KEY]}" reaches a module global that was never meant to be printed — the same class of hole, reachable without %n because the format language here can follow references. The fix is the same as C's and Rust's: the format must be a literal, which for Python means an f-string (f"..." cannot be built from user text at run time) or string.Template, which substitutes names only and cannot follow attributes.

If you are coming from another language

Rust. println! and format! require the format to be a literal — you cannot pass a runtime String as the format at all, which is the fix built into the language rather than added by a flag. The RFC 69 page ↗ is about a different literal, but the same principle runs through the macros: the format is checked at compile time because it is always known then.

ABAP. (Not machine-checked — CI cannot run ABAP.) WRITE and string templates (|{ ... }|) format by named fields, and there is no directive that writes to memory, so the C form of the bug does not exist. The nearest relative is dynamic constructs — ASSIGN with a computed name, or dynamic SQL — where the injection risk is the same idea in a different room.

See also