Secure Coding in C · advanced · ~10 min
- Explain what a *format string* is and why the first argument to `printf` is special. - Recognise the dangerous pattern `printf(user_input)` and describe exactly what goes wrong. - Understand how `%x`, `%s`, `%p`, and especially `%n` let an attacker read from and write to memory. - Rewrite vulnerable code the safe way: a fixed format string with user data passed as an argument. - Turn on compiler defences (`-Wformat -Wformat-security`, `-Werror`) that catch this class of bug automatically. - Apply the same reasoning to every `printf`-family function (`fprintf`, `sprintf`, `snprintf`, `syslog`, and friends).
In printf and format specifiers you learned that the first argument to printf is a format string: text mixed with conversion specifiers like %d, %s, and %x. Each specifier is an instruction that says go fetch the next argument and print it this way. That works perfectly when you, the programmer, write the format string as a constant.
The trouble begins when the format string comes from outside your program — from a user, a file, a network packet, an environment variable. If you write printf(name) where name holds text a user typed, then the user is now writing the instructions that printf obeys. If their text contains %x or %n, printf will dutifully execute those instructions, reaching for arguments that were never passed and touching memory it was never meant to touch.
In plain language: a format string vulnerability is what happens when you let untrusted data play the role of a command instead of the role of data. The topic sits alongside SQL injection and command injection in the same family — mixing control and data in one channel. Because C's printf is so common and its %n specifier can write to memory, this bug has powered real remote exploits for decades. The good news is that the fix is short, mechanical, and easy to remember, and the compiler can find almost every instance for you.
Format string bugs are not a museum piece. They show up whenever a program logs, prints, or reports something built from input a program did not fully control: usernames, filenames, HTTP headers, error messages echoed back to a client. A single syslog(priority, message) where message came from the network is enough.
The consequences run the full range of severity:
%x and %p dump raw stack memory to the output — leaking pointers, canary values, and secrets that help an attacker defeat other defences like ASLR.%s treats a stack value as a pointer and dereferences it; a bad value segfaults the process.%n writes an integer into memory at an address taken off the stack. Combined with width specifiers to control the value, this historically gave attackers full code execution.The reason it matters to you as a learner is that the vulnerable code looks completely innocent — printf(msg) reads like normal, tidy C. Training your eye to flinch at that pattern, and reaching for printf("%s", msg) automatically, is one of the highest-value habits in defensive C.
Definition. The first argument to any printf-family function is a format string: a sequence of ordinary characters that are printed as-is, plus conversion specifiers beginning with % that consume and format additional arguments.
How it works internally. printf is a variadic function — its prototype ends in .... It has no idea how many arguments it was actually given. It walks the format string left to right; each time it meets a specifier like %d, it fetches the next argument from wherever the calling convention placed it (registers, then the stack) and advances an internal pointer. The format string is the only thing telling printf how many arguments to expect and what types they are.
printf("%d and %s", 42, "hi");
| | | |
| | | +-- 2nd variadic arg -> matched by %s
| | +------- 1st variadic arg -> matched by %d
| +------------- specifier says: expect a char*
+--------------------- specifier says: expect an int
The format string drives everything. Change it, and printf
fetches different arguments -- whether or not they exist.
When to use / when not. Always write the format string yourself as a string literal. Never let it be a runtime value derived from input.
Pitfall. Believing "there is no % in the user's text, so it is fine." Today there is no %; tomorrow the input source changes and there is. Safety should not depend on the current contents of data — it should depend on the structure of your call.
Knowledge check: In
printf("Hello %s", name), how many variadic arguments doesprintfexpect, and what tells it that number?
Definition. The bug is any call where attacker-influenced data occupies the format-string slot: printf(input), fprintf(fp, input), sprintf(buf, input), syslog(LOG_INFO, input).
Plain-language explanation. You meant to print the text as data. But by putting it in the format slot, you asked printf to interpret it. If it contains specifiers, printf obeys them and starts fetching arguments you never passed.
Call the programmer wrote: printf(input);
What printf believes: printf("%x %x %n ..."); (input = user text)
Stack when printf runs (grows downward):
[ input pointer ] <- printf's own 1st arg (the format)
[ ??? saved regs ] <- printf now reads THESE as if they were
[ ??? local vars ] the "arguments" the specifiers asked for
[ ??? return addr ]
[ ... ]
^
%x leaks these, %s dereferences them, %n writes to them
Pitfall. The same mistake hides inside wrappers. A helper void log_line(char *m){ fprintf(logfp, m); } is just as vulnerable as a raw printf; the danger travels with the value.
Knowledge check (find the bug):
void greet(const char *who){ printf(who); printf("\n"); }— which line is exploitable, and why is the second one fine?
| Specifier | Normal job | In an attack |
|---|---|---|
%x / %p |
print an int / pointer | dump raw stack words → info leak |
%s |
print a C string | treat a stack word as char* and deref it → crash or leak |
%n |
store chars-printed-so-far into an int* arg |
write attacker-chosen value into memory → corruption / RCE |
%N$x |
positional arg (Nth) |
jump directly to a chosen stack slot |
width %100x |
pad output width | inflate the count %n writes to a target value |
How %n is uniquely dangerous. Every other specifier only reads. %n writes: it stores the number of bytes emitted so far into the int* it thinks was passed. When the format string is attacker-controlled, that int* is really some stack word the attacker positioned, and the width specifiers let them tune the value written. That read-and-write combination is what turned this from a leak into full exploitation. Modern C libraries increasingly refuse or restrict %n, but you must never rely on that.
Knowledge check (explain in your own words): Why is
%ncategorically more dangerous than%x, even though both read arguments that were never passed?
Definition. Put the format string under your control as a literal, and pass the untrusted text as a plain argument.
printf("%s", input); /* format is a constant; input is just data */
Now %s is the only instruction, it is yours, and input is copied out verbatim — even if it contains a hundred %ns, they are printed as ordinary characters, never interpreted.
When you truly need a dynamic template (rare — e.g. localisation), the template must still come from a trusted resource you ship, never from user input, and the user's values go in as arguments.
Pitfall. printf("%s") with a missing argument is itself undefined behaviour — the fix is %s plus the matching argument, not just adding %s.
The rule in one line: the format string is a compile-time constant; everything variable is an argument.
/* VULNERABLE: input sits in the format-string slot */
printf(input);
fprintf(stderr, input);
syslog(LOG_ERR, input);
char buf[64]; sprintf(buf, input); /* also a buffer-overflow risk */
/* SAFE: constant format, data as argument */
printf("%s", input);
fprintf(stderr, "%s", input);
syslog(LOG_ERR, "%s", input);
char buf[64]; snprintf(buf, sizeof buf, "%s", input); /* bounded, too */
Compiler flags that turn this into an automatic, build-breaking check:
-Wformat enable format-string checking
-Wformat-security warn specifically about a non-literal format with no args
-Wformat=2 stricter superset of the above
-Werror promote the warnings to hard errors so the build fails
A format string is the first argument to functions like printf. It contains text plus conversion specifiers such as %s and %x, which tell the function how to read and print its other arguments.
The problem starts when user input is used as that format string:
printf(user_input);
If the user types specifiers like %x%x%x%n, printf will try to process them. But the matching arguments were never passed.
As a result, printf reads (and, with %n, can even write) past the call's actual arguments. This is a classic, well-known vulnerability.
%x reads values off the stack and leaks them.%n writes the number of characters printed so far into a memory address taken from the stack, which can let an attacker corrupt memory.Always pass user data as an argument to a fixed format string:
printf("%s", user_input);
Here the format string is a constant you control, and the user's text is treated as plain data.
Compile with -Wformat -Wformat-security to catch this mistake automatically.
#include <stdio.h>
#include <string.h>
/*
* Demonstrates the safe vs. unsafe way to print untrusted text.
* We do NOT run the unsafe branch by default -- it is shown only so
* you can see, side by side, what the vulnerable call looks like.
* Build with: cc -Wall -Wformat -Wformat-security -o fmt fmt.c
*/
/* WRONG: caller-supplied text is used as the format string. */
static void print_message_unsafe(const char *msg)
{
printf(msg); /* if msg has %x/%n, printf obeys it */
printf("\n");
}
/* RIGHT: fixed format string; the text is passed as data. */
static void print_message_safe(const char *msg)
{
printf("%s\n", msg); /* %-signs in msg are printed literally */
}
int main(void)
{
char line[256];
printf("Type a message (try including %%x or %%n): ");
if (fgets(line, sizeof line, stdin) == NULL) {
fprintf(stderr, "no input\n");
return 1;
}
line[strcspn(line, "\n")] = '\0'; /* strip trailing newline */
printf("\n-- safe printing --\n");
print_message_safe(line); /* always prints text verbatim */
/*
* The unsafe version is left here, unreachable by default, as a
* labelled example of the vulnerability. Uncommenting it and
* feeding "%x %x %x" would leak stack words to the terminal.
*/
/* print_message_unsafe(line); */ /* VULNERABLE -- do not enable */
(void)print_message_unsafe; /* silence 'unused function' */
return 0;
}
What it does. It reads one line from standard input, strips the newline, and prints it back using the safe function print_message_safe, which always treats your text as data. The unsafe function is present and labelled but deliberately not called.
Expected output (typing hello %x %n world):
Type a message (try including %x or %n): hello %x %n world
-- safe printing --
hello %x %n world
Notice the %x and %n are printed literally — because they are just data to a %s argument. Had print_message_unsafe been called instead, %x would have printed hex garbage from the stack and %n would risk a crash or worse.
Edge cases. An empty line prints an empty line. Very long input is safely truncated by fgets to fit line. Because we only ever use %s with a real argument, there is no undefined behaviour regardless of what the user types.
| Step | Code | What happens |
|---|---|---|
| 1 | char line[256]; |
Reserve a 256-byte stack buffer for the input. |
| 2 | printf("Type a message ... "); |
A constant format string with no specifiers that consume args (%% prints a literal %). Safe by construction. |
| 3 | fgets(line, sizeof line, stdin) |
Read at most 255 chars + '\0'. Bounded, so no overflow. Returns NULL on EOF/error, which we check. |
| 4 | line[strcspn(line, "\n")] = '\0'; |
strcspn returns the index of the first \n (or the length if none); we overwrite it with a terminator to drop the trailing newline. |
| 5 | print_message_safe(line) |
Calls printf("%s\n", msg). The format is our literal; msg is fetched because %s asked for exactly one char*, and it is the one we passed. |
| 6 | inside %s |
printf copies bytes of msg until '\0'. Any % inside msg is an ordinary byte here — never re-scanned as a specifier. |
| 7 | print_message_unsafe (not called) |
Had we called it, printf(msg) would scan msg as a format; %x would pull the next stack word (undefined — no arg was passed) and print it. |
Tracing the danger (thought experiment only). Suppose msg = "%x" reached printf(msg). printf sees %x, looks for the first variadic argument, finds none was passed, and instead reads whatever bytes sit where the calling convention expects arg #1 — a leftover register save or stack slot — and prints it as hex. Each additional %x walks one word further up the stack. That walk is the information leak; %n would turn the same walk into a write.
Mistake 1 — "There's no % in my data, so it's safe."
printf(username); /* WRONG even if usernames "never" contain % */
Why it's wrong: safety must not depend on the current contents of runtime data. The input source can change; validation elsewhere can be removed; an attacker chooses the input. Fix:
printf("%s", username); /* RIGHT: structural safety */
Recognise it: any printf/fprintf/syslog whose format argument is a variable, not a string literal.
Mistake 2 — hiding the bug in a wrapper.
void logmsg(const char *m){ fprintf(logfp, m); } /* WRONG */
Why it's wrong: the vulnerability travels with the value; wrapping it doesn't neutralise it, it just moves it. Every caller of logmsg is now exposed. Fix:
void logmsg(const char *m){ fprintf(logfp, "%s", m); } /* RIGHT */
Recognise it: helper functions that forward a caller-supplied string into a formatting function.
Mistake 3 — "fixing" it by adding %s but forgetting the argument.
printf("%s"); /* WRONG: %s with no matching argument = UB */
Why it's wrong: now %s reads a garbage pointer and printf dereferences it — a likely crash. Fix: supply the data as the argument, printf("%s", input);.
Mistake 4 — using sprintf with a variable format.
char buf[64];
sprintf(buf, fmt); /* WRONG twice: format-string bug + overflow */
Why it's wrong: it's a format-string bug and an unbounded write into a fixed buffer. Fix with a constant format and a bounded call:
snprintf(buf, sizeof buf, "%s", input); /* RIGHT */
Prevention habit: whenever you type a printf-family call, ask "is my first format argument a string literal?" If not, stop and rewrite.
Compiler warnings (your first and best signal).
warning: format not a string literal and no format arguments [-Wformat-security]
warning: format string is not a string literal [-Wformat-nonliteral]
Seeing either means you have a non-constant format. Build with -Wall -Wformat -Wformat-security -Werror so these break the build rather than scroll past. This alone catches the vast majority of instances.
Runtime symptoms and what they mean.
| Symptom | Likely cause |
|---|---|
| Random hex numbers or pointers appear in output | %x/%p in data reaching a format slot — memory leak in progress |
Segfault inside printf/vfprintf |
%s dereferenced a bogus stack word, or %n wrote to a bad address |
| Output contains addresses that change each run | leaked stack/ASLR values — confirm a format-string path |
Sanitizer aborts on a %n |
libc hardening (_FORTIFY_SOURCE) caught a write specifier |
Concrete debugging steps.
grep -rnE 'printf\s*\([^\"]' src/ finds calls whose first argument isn't a string literal (review each hit — some are false positives).-Wformat-security -Werror and fix every diagnostic.-D_FORTIFY_SOURCE=2 -O2, which adds runtime checks to printf-family calls.-fsanitize=address) and feed inputs full of %x, %s, %n, %p to force a failure where a bug exists.Questions to ask when it "doesn't work." Where does this string come from — can any part be influenced from outside? Is the format argument a literal? Am I forwarding a caller-supplied string into a formatter through a helper? Did I add %s but forget the value?
This is squarely an undefined-behaviour and memory-safety topic, so treat every point below as a hard rule, not a style preference.
The core UB. Passing more or fewer arguments than the format specifiers demand, or arguments of the wrong type, is undefined behaviour (C11 §7.21.6.1). A user-controlled format string violates this at the attacker's discretion.
Read out of bounds. %x/%p walk the stack past the real arguments, disclosing memory: return addresses, saved registers, stack canaries, and secrets. Leaking a canary or a code address defeats stack-smashing protection and ASLR, which are the defences protecting other bugs.
Invalid dereference. %s interprets a stack word as char* and reads until a '\0'. If that word isn't a valid pointer, the process crashes (DoS); if it is, it may leak whatever it points to.
Arbitrary write — the severe case (labelled vulnerability). %n writes the running output-byte count into an int* taken from the stack. With positional args (%7$n) to pick the target and width specifiers (%NNNNx) to set the value, an attacker can write a chosen value to a chosen address — historically a path to code execution. Fix: never let the format string be untrusted; additionally compile with -D_FORTIFY_SOURCE=2, and prefer libc/build settings that reject %n in writable format strings.
Defensive practices, layered (defence in depth):
printf("%s", data) — a constant format is immune regardless of input. This is the fix; everything else is a backstop.-Wformat -Wformat-security -Werror makes the build fail on the pattern.-D_FORTIFY_SOURCE=2, stack canaries (-fstack-protector-strong), ASLR (on by default), and -fsanitize=address during testing.snprintf over sprintf to also close the buffer-overflow door.Remember: input validation reduces risk but is not the fix — a constant format string is. Never rely on "the user won't type %n."
Where this bites in real software. Servers and daemons that log request data (syslog(LOG_INFO, header)), CLI tools that echo filenames or arguments back to the user, error paths that print a message built from a failed request, and network services that reflect client-supplied strings. The classic historical case is the WU-FTPD FTP daemon, whose format-string flaw allowed remote root compromise; similar bugs appeared across many Unix utilities and are still found in embedded firmware and IoT devices today, where old, hand-rolled C is common.
Best-practice habits (beginner):
printf("%s", x) your reflex; never printf(x).printf-family call a literal format string.-Wall -Wformat -Wformat-security and fix warnings before moving on.snprintf with sizeof buf over sprintf.Best-practice habits (advanced / professional):
-Wformat-security -Werror and -D_FORTIFY_SOURCE=2 part of the CI build so the pattern can never merge.clang-analyzer-security, Coverity) that flag non-literal formats, including through wrappers.__attribute__((format(printf, N, M))) so the compiler type-checks their format strings and callers too.Beginner 1 — Spot and fix.
Objective: correct a vulnerable print. Given void show(const char *name){ printf(name); }, rewrite show so it prints the name safely and always appends a newline. Requirements: the format argument must be a string literal; behaviour must be identical for ordinary names. Example: show("Ann") prints Ann\n; show("50%% off %x") must print the text literally. Concepts: constant format, data as argument.
Beginner 2 — Prove it to yourself.
Objective: write a small program that reads a line with fgets and prints it with printf("%s\n", line). Requirements: strip the trailing newline, handle EOF, build cleanly under -Wall -Wformat -Wformat-security -Werror. Input abc %x %n must appear verbatim on output. Constraint: buffer of fixed size; no sprintf. Concepts: bounded input, safe printing.
Intermediate 1 — Harden a logger.
Objective: implement void log_line(FILE *out, const char *msg) that writes a timestamped line safely. Requirements: use a constant format string, pass msg as data, and use fprintf with an explicit stream. Add the compiler attribute __attribute__((format(printf, ...))) to a separate variadic helper log_fmt(FILE*, const char *fmt, ...) and show the compiler catching a type mismatch. Concepts: wrappers, format attribute, defence in depth.
Intermediate 2 — Detector.
Objective: write int risky_format(const char *s) returning 1 if s contains a % followed by a conversion character (one of diouxXeEfFgGaAcspn%... treat %% as not risky) and 0 otherwise. Requirements: scan once, handle the string end after a trailing %, no out-of-bounds read. Example: "hi %s"→1, "100%% sure"→0, "trailing %"→0. Constraint: const char *, read-only. Concepts: string scanning, specifier structure (mirrors the related exercises).
Challenge — Safe template engine (trusted templates only).
Objective: build a tiny reporter that takes a fixed, program-defined template with named slots (e.g. "User: {name}, Score: {score}\n") and fills the slots from user-supplied values, never letting user text act as a format. Requirements: parse {name}/{score} yourself, copy user values in with bounded snprintf, reject unknown slot names, and ensure a value containing {, }, or % is treated purely as data. Provide an input/output example. Constraints: no user-controlled printf format anywhere; must build under -Wformat-security -Werror. Concepts: separating control from data by design, bounded copies, input validation.
printf-family function is a format string — a mini-program of instructions (%d, %s, %x, %n) that tells the function which arguments to fetch and how.printf(input), fprintf(fp, input), syslog(pri, input), sprintf(buf, input). The user then writes the instructions printf obeys.%x/%p leak stack memory, %s can crash or leak by dereferencing a bogus pointer, and %n can write attacker-chosen values to attacker-chosen addresses — the path from info-leak to full compromise.printf("%s", input). It is immune no matter what the input contains.%"; validation helps but is not the fix.-Wall -Wformat -Wformat-security -Werror, add -D_FORTIFY_SOURCE=2, annotate your own variadic helpers with __attribute__((format(printf, N, M))), and prefer snprintf over sprintf.