Skip to content

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 raises ValueError: 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 exhausts memory_limit and the process dies, so a try/catch around 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 printf but leaving a nearby sprintf/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 %n means the finding can be ignored: PHP not supporting %n rules out the memory-write primitive from C, but not the denial-of-service risk from an attacker-controlled specifier count triggering ArgumentCountError/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 ordinary memory_limit and ends the request with Fatal error: Allowed memory size ... exhausted. Measured on PHP 8.5.8 at the default 128M limit, and a fatal error is not catchable by \Throwable or by anything else, so the worker dies inside the try block.
  • Relying on PHP version differences instead of fixing the call: Code that behaves "safely" on PHP <8 (returns false instead 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+.

Additional Resources