CWE-134: Use of Externally-Controlled Format String - PHP
Overview
PHP's printf(), sprintf(), and vsprintf() accept a format string with conversion specifiers (%s, %d, %x, ...). PHP does not implement C's %n write specifier at all, so a PHP format-string bug cannot become a memory-corruption primitive the way it can in C.
The real risk is denial of service and information disclosure through errors: as of PHP 8.0, calling sprintf()/printf() with fewer arguments than the format string requires throws ArgumentCountError, and an unknown specifier throws a ValueError - if the format string is attacker-controlled, the attacker chooses how many specifiers to include and can reliably trigger an exception the surrounding code does not handle. A width specifier is a second and harder-hitting route to the same end - one %2000000000s exhausts memory_limit and takes the worker down with a fatal error rather than an exception. Depending on the application's error-handling configuration, an uncaught exception can also render a stack trace containing file paths or other internal detail back to the client.
Primary Defence: Keep the format string a literal written by the application; user data goes in the argument list, never in the format position, e.g. printf("%s", $user_input) rather than printf($user_input).
Common Vulnerable Patterns
User Input as the Format String
<?php
function log_message($user_input) {
// VULNERABLE - $user_input is used as the format string
printf($user_input);
}
// Attack: $user_input = "%s %s %s" with no additional arguments supplied
// Result (PHP 8+): uncaught ArgumentCountError - "4 arguments are required, 1 given"
// (the count includes the format string itself)
// Result (PHP <8): silent false return plus an E_WARNING, which is easy to miss in review
Why this is vulnerable: printf/sprintf interpret whatever string they receive as a template. An attacker who controls that string controls how many arguments the call expects, and can reliably force an error path the surrounding code wasn't written to handle.
User-Controlled Format Passed to vsprintf()
<?php
function build_report($user_format, array $values) {
// VULNERABLE - $user_format is attacker-controlled
return vsprintf($user_format, $values);
}
Why this is vulnerable: Same underlying problem as printf - the attacker decides how many positional specifiers appear in the template, independent of how many values $values actually contains. The exception type differs, which matters to anyone writing a catch around it: vsprintf raises ValueError: The arguments array must contain 3 items, 1 given where sprintf raises ArgumentCountError. The attacker also gets to choose which value is printed - %3$s prints the third element of $values whatever the application's own template displayed, so a template supplied by the caller can reach a column the report was never meant to show.
Secure Patterns
Literal Format String, User Data as Argument
<?php
function log_message($user_input) {
// SECURE - format string is a literal
printf("User message: %s", $user_input);
$message = sprintf("User message: %s", $user_input);
error_log($message);
}
Why this works: The format string can no longer be influenced by input - the attacker's data flows only into the %s substitution slot, which accepts any string value without changing how many arguments the call expects.
Validating a Genuinely Dynamic Format (Rare)
<?php
function build_report(string $formatKey, array $values, array $allowedFormats) {
// SECURE - the actual format string always comes from a fixed, application-defined allowlist
if (!array_key_exists($formatKey, $allowedFormats)) {
throw new InvalidArgumentException("Unknown report format: $formatKey");
}
return vsprintf($allowedFormats[$formatKey], $values);
}
Why this works: The attacker can only select which template is used, by key, from a set the application itself wrote - never the template's content - so the specifier count and types are ones the application already validated.
Considerations
How much an uncaught format error discloses depends on deployment
configuration. A malformed format string raises ArgumentCountError or
ValueError, and what the caller sees then is decided by display_errors, not
by this code. With display enabled, the response can carry file paths and stack
frames; with it off, the same bug is a generic 500. Confirm
display_errors = Off in production regardless of this fix - it does not make
the format string safe, but it decides whether a mistake anywhere becomes an
information disclosure.
Testing
- A format string with more specifiers than arguments supplied (e.g.
"%s %s %s"with zero arguments) - should not be reachable with an attacker-controlled format after the fix. - A format string with an invalid/unknown specifier (
"%y") - same expectation; on PHP 8 this raisesValueError: Unknown format specifier "y". - A single specifier with an absurd width (
"%2000000000s") - should be unreachable. This is the assertion worth adding to any existing test that only covers the exceptions: where it is reachable it exhaustsmemory_limitand the process dies, so atry/catcharound the call proves nothing about it. - Confirm normal, legitimate formatted output still renders correctly through every changed call site.
- If a format-allowlist feature exists, request a key outside the allowlist and confirm it's rejected with a controlled error, not an uncaught exception.
Common Pitfalls
- Fixing
printfbut leaving a nearbysprintf/vsprintf/error_log(sprintf(...))call unchanged: These functions are frequently used together in the same file (one for display, one for logging) - a fix applied to only the flagged call leaves the sibling call just as exploitable. - Assuming PHP's lack of
%nmeans the finding can be ignored: PHP not supporting%nrules out the memory-write primitive from C, but not the denial-of-service risk from an attacker-controlled specifier count triggeringArgumentCountError/ValueError- the fix (literal format string) is still required, just for a different consequence. - Catching the exception without removing the user-controlled format: Wrapping the call in
try { ... } catch (\Throwable $e) { ... }stops the request from crashing but leaves the template attacker-controlled - the attacker still dictates specifier count and type on every call, so any other specifier-count-dependent behavior in the surrounding code remains reachable. It also does not stop the cheapest denial of service, because that one is not an exception:sprintf("%2000000000s", "x")asks for a two-billion-character result, which exceeds any ordinarymemory_limitand ends the request withFatal error: Allowed memory size ... exhausted. Measured on PHP 8.5.8 at the default 128M limit, and a fatal error is not catchable by\Throwableor by anything else, so the worker dies inside thetryblock. - Relying on PHP version differences instead of fixing the call: Code that behaves "safely" on PHP <8 (returns
falseinstead of throwing) is not actually fixed - it's relying on an old PHP version's silent-failure behavior, which becomes an uncaught error on any upgrade to PHP 8+.