CWE-134: Use of Externally-Controlled Format String - C
Overview
In C, printf, fprintf, sprintf, snprintf, and syslog all treat their format argument as a template containing conversion specifiers (%s, %x, %n, ...). If untrusted input reaches that argument directly - printf(user_input) instead of printf("%s", user_input) - the attacker controls how many arguments the function tries to consume and what it does with them. %x repeated several times leaks whatever those non-existent arguments happen to be - on the x86-64 System V ABI the first five conversions read the argument registers rsi, rdx, rcx, r8 and r9 before anything comes off the stack, which is why real payloads reach past them with a positional specifier such as %7$x rather than by repeating %x (the "walks the stack word by word" description is a 32-bit one, where every variadic argument was on the stack). %s treats such a value as a pointer and dereferences it, usually crashing; %n writes the number of bytes printed so far to the address given by the corresponding argument - an attacker-controlled write primitive. This is the most severe variant of format string bugs because it is a direct route to arbitrary memory read and write, not just information disclosure.
Primary Defence: The format argument to every variadic formatting function must be a compile-time string literal. User-controlled data goes in the argument list, never in the format position - in every call site, not just the one named in a scan finding.
Common Vulnerable Patterns
User Input as the Format Argument
#include <stdio.h>
#include <syslog.h>
void log_message(const char *user_input) {
printf(user_input); // VULNERABLE - user input as format string
char buffer[100];
sprintf(buffer, user_input); // VULNERABLE - same problem
syslog(LOG_INFO, user_input); // VULNERABLE - syslog format strings are just as exploitable
}
// Attack: user_input = "%x %x %x %x" -> leaks whatever is in the argument registers
// Attack: user_input = "%7$x" -> reaches past them onto the stack
// Attack: user_input = "%s" -> dereferences one of those values as a pointer, usually crashes
// Attack: user_input = "%n" -> writes an integer to an attacker-influenced address
Why this is vulnerable: The function has no way to distinguish a literal % character the attacker typed from a real conversion specifier. It reads every %x/%s/%n sequence in the string as an instruction to consume and act on the next variadic argument slot, whether or not one was actually passed - a register on x86-64 until the register-save area runs out, and the stack after that.
Fixing One Call Site But Missing the Others
// VULNERABLE - Fixing One Call Site But Missing the Others
void log_message(const char *user_input) {
printf("%s", user_input); // fixed here...
char buffer[100];
sprintf(buffer, user_input); // ...but this sprintf call, added later or overlooked, is not
}
Why this is vulnerable: A codebase almost never has exactly one format-string call site per function. Fixing the call a scanner flagged while leaving a sprintf, fprintf, or syslog call nearby unchanged - especially one added after the original review - leaves the same class of bug reachable through a different path.
Secure Patterns
Literal Format String, User Data as Argument
#include <stdio.h>
#include <syslog.h>
void log_message(const char *user_input) {
printf("%s", user_input); // format is a literal
char buffer[100];
snprintf(buffer, sizeof(buffer), "%s", user_input); // literal format + bounded write
syslog(LOG_INFO, "%s", user_input); // literal format here too
}
Why this works: In every call, the format string is a compile-time constant that the attacker cannot influence - user data is passed as an argument to be substituted into %s, never interpreted as formatting instructions. Using snprintf instead of sprintf additionally bounds the write to the destination buffer's real size, addressing CWE-787 (Out-of-bounds Write) at the same call site.
Enforcing It at Compile Time
// Compile with these flags so the compiler itself catches a non-literal format argument:
// gcc -Wformat -Wformat-security -Werror
//
// -Wformat-security warns when a format function is called with a non-literal
// format string AND NO FORMAT ARGUMENTS - printf(foo). That is the classic
// shape, but it is not every shape: printf(user_fmt, name) passes it silently.
//
// To cover that case as well, add -Wformat-nonliteral (or -Wformat=2, which
// turns on both). It warns on any non-literal format, so expect to have to
// work with it: it also fires on legitimate printf-style wrapper functions.
// Silence those by tagging the wrapper __attribute__((format(printf, N, M))),
// which is worth doing anyway - that attribute is what lets the compiler
// check calls THROUGH the wrapper as well.
Why this works: -Wformat-security flags the vulnerable call shape at compile time, so a regression is caught in CI before it ships rather than relying on every reviewer noticing it. The GCC manual scopes it to calls "where the format string is not a string literal and there are no format arguments", so the moment a call passes even one argument alongside the attacker-controlled format the warning goes quiet while the bug remains. -Wformat-nonliteral is the flag that closes that gap, and Clang implements both with the same meanings.
Testing
%x %x %x %x- should print literally, not leak the contents of unsupplied argument slots.%s- should print literally, not dereference one of those values as a pointer and crash.%n- should print literally, not attempt a memory write.%7$x(positional specifier) - should print literally. Include this one even where%x %x %x %xalready passes: on x86-64 the repeated form reads argument registers first, so a positional specifier is what a real payload uses to reach the stack, and a partial fix can leave it working.- Confirm normal, non-adversarial input still formats correctly through every call site that was changed.
- Rebuild with
-Wformat-securityand-Wformat-nonliteralenabled and confirm both report zero findings.-Wformat-securityalone is silent on any call that passes format arguments, so a clean build with only that flag is not evidence.
Common Pitfalls
- Fixing the call site named in the finding but not the rest of the file: A scanner typically reports one call per finding; if the same function or a nearby one has a second
sprintf/fprintf/syslogcall also passing user input as the format, it remains exploitable even after the reported line is fixed. Grep the whole file (and callers) for the same function names before considering the fix complete. - Concatenating a literal prefix onto user input and using the result as the format:
printf(strcat(strcpy(buf, "User said: "), user_input))still puts attacker-controlled bytes into the format position - the literal prefix doesn't neutralize conversion specifiers appearing later in the same string. - Stripping only
%n: Filtering the most dangerous specifier while still passing the format throughprintf(user_input)leaves%x/%savailable for memory disclosure and crashes - the fix is a literal format string, not a denylist of specific specifiers. - Relying on
_FORTIFY_SOURCEalone:_FORTIFY_SOURCEat=2or higher can turn some format-string misuse (like%nin a writable format string) into a fatalabort()rather than exploitation, but it's a runtime hardening backstop, not a substitute for fixing the call - it doesn't stop memory disclosure via%x/%s, and not every vulnerable shape is covered.