Skip to content

CWE-134: Use of Externally-Controlled Format String - Python

Overview

Python doesn't have C's %n-style memory write primitive, so a user-controlled format string can't corrupt memory directly. The real risk is information disclosure: str.format() allows dotted attribute access and item lookups inside its placeholders ({0.__class__} walks attributes, {config[SECRET_KEY]} indexes a mapping or sequence), so if untrusted input becomes the template passed to .format(**data) or .format(some_object), an attacker can walk from a seemingly harmless object down into its internals - class, __init__, __globals__ - and read values the application never intended to expose. The two syntaxes are not interchangeable: [...] calls __getitem__, so {user[__init__]} raises TypeError: 'User' object is not subscriptable rather than reaching an attribute, and the attribute chain is what the disclosure runs on. The legacy % operator is less powerful but still lets an attacker probe dictionary keys (%(key)s) if the format string and the data dictionary are both reachable. Logging is a separate case: logging module calls apply %-style substitution lazily and catch formatting errors internally, so a malformed user-supplied message is less likely to crash the process than to silently produce a broken log line or an internal logging error.

Primary Defence: Never let untrusted input become the template string passed to .format(), %, or Formatter.format(). Untrusted input belongs in the substitution values, with a template the application itself wrote as a literal.

Common Vulnerable Patterns

User-Controlled Template With .format()

def render_greeting(template, **context):
    # VULNERABLE - template is attacker-controlled; .format() allows attribute/item traversal
    return template.format(**context)

# Attack: template = "{user.__class__.__init__.__globals__[SECRET_KEY]}"
# Result: if `user` is passed in context, the attacker reads an attribute chain down to
# module globals, potentially exposing secrets, config, or other internal state

Why this is vulnerable: str.format() placeholders support .attribute and [key] access chained arbitrarily deep. When the template itself is attacker-controlled, the attacker chooses which attributes to walk, not just which values get substituted - this is fundamentally different from passing untrusted data as an argument. The chain above needs one thing of the object it starts from, and that matters before you conclude a call site is safe: __init__.__globals__ reaches module globals only where __init__ is a Python function. A class that never defines one inherits object.__init__, a C slot wrapper, and the chain stops with AttributeError: 'wrapper_descriptor' object has no attribute '__globals__' - so the same template fails against a bare data holder and succeeds against almost anything else in scope, including any bound method ({user.save.__globals__[SECRET_KEY]}).

User-Controlled Format With the % Operator

def build_report(user_format, values):
    # VULNERABLE - user_format is attacker-controlled
    return user_format % values

Why this is vulnerable: If values is a dict, the attacker's format string can reference arbitrary keys with %(key)s, potentially reading dictionary entries the caller never intended to expose through this code path. If values is a tuple/positional and the attacker's specifier count doesn't match, it raises TypeError.

Secure Patterns

Literal Template, Untrusted Data as Substitution Values Only

def render_greeting(username):
    # SECURE - template is a literal string the application controls
    template = "Hello, {}! Welcome back."
    return template.format(username)

Why this works: The template can no longer be influenced by input, so there is no attacker-chosen placeholder to walk into object internals - only the substituted value (a plain string here, not an object with attributes worth traversing) is attacker-influenced.

f-strings for Application-Authored Templates

def render_greeting(username):
    # SECURE - f-strings are parsed from source code at compile time; the template
    # can never come from runtime, untrusted input
    return f"Hello, {username}! Welcome back."

Why this works: An f-string's template is fixed at the point it's written in the source file - there is no way to construct one dynamically from a runtime string without falling back to eval(), which would be a different and more severe vulnerability (CWE-95). This structurally rules out an attacker ever supplying the template.

Parameterized Logging

import logging

logger = logging.getLogger(__name__)

def log_user_action(username, action):
    # SECURE - username/action are passed as logging arguments, not concatenated into the message
    logger.info("User %s performed action: %s", username, action)

Why this works: The message template ("User %s performed action: %s") is a literal the application wrote; user-controlled values are passed as separate arguments that logging substitutes internally, so nothing attacker-controlled can change how many substitutions are expected or where they come from.

Considerations

Decide what happens to the feature the fix removes. These findings often sit behind a user-facing capability - a custom notification message, a templated export, a configurable label. Moving to a literal template with substituted arguments takes that capability away, so the choice is to re-provide it as a fixed set of application-authored templates the user selects by key, or to retire it if it is not load-bearing. Making that call explicitly is better than shipping a fix that quietly breaks a feature someone depends on.

Testing

  • {0.__class__} / {obj.__init__.__globals__} as a template value where an object is in scope - should be rejected or treated as a literal string, never resolved.
  • %(key)s naming a key that genuinely exists in the substitution dict but that this code path never displays - should be unreachable after the fix. Use a real key: % lookups go to the mapping, so a payload like %(__class__)s raises KeyError against any ordinary dict and passes whether the fix landed or not.
  • A width specifier with an absurd value ("{:2000000000}", "%2000000000d") - should be unreachable; where it is reachable it allocates the full width, so this is a memory-exhaustion test rather than an exception test.
  • Confirm normal, application-authored templates still render correctly for legitimate users.
  • If a template-selection-by-key feature exists, test a key outside the allowlist and confirm it's rejected.

Common Pitfalls

  • Fixing the top-level template but not a nested one: Applications that support "custom notification text" or "custom report headers" often have more than one .format()/% call built from the same user-supplied template (once for a preview, again for the actual send) - fixing only the first leaves the second reachable.
  • Assuming f-strings make the whole file safe: f-strings themselves are safe because the template is fixed in source, but that safety doesn't transfer to a nearby .format() or % call that still takes a runtime, user-controlled template - each call site needs to be checked individually.
  • Restricting the context dict instead of the template: Removing sensitive objects from the **context passed to .format() closes one path but not the underlying issue - any object still present (even a seemingly harmless one) can be a starting point for attribute traversal ({safe_obj.__class__.__init__.__globals__[SECRET_KEY]} style chains) as long as the template itself remains attacker-controlled. Note the shape of the chain: the format mini-language has no call syntax, so the widely-quoted __subclasses__() payload resolves nothing - "{o.__class__.__mro__[1].__subclasses__()}" raises AttributeError: type object 'object' has no attribute '__subclasses__()', because the parentheses are read as part of the attribute name. Attribute and item access are the whole of what the attacker gets, which is enough to read any module global.
  • Relying on logging's internal error handling as a fix: logging catching a formatting mismatch and printing "--- Logging error ---" instead of crashing prevents denial of service, but doesn't address a user-controlled template being used at all. The remaining risk is not the .format() traversal from the section above - logging substitutes with %, so {0.__class__} in a log message is inert text. What survives is mapping-key selection: logging passes a lone dict argument straight to %, so logger.info(user_message, context) with user_message = "%(secret)s" prints context['secret'] however the call site meant that dict to be used.
  • Catching the exception rather than removing the user-controlled template: A try/except (KeyError, IndexError, TypeError, ValueError) around the call stops the obvious crashes and leaves the disclosure untouched, because a successful traversal raises nothing at all. It also misses the cheapest denial of service, which is not an exception: a width of "{:2000000000}" or "%2000000000d" asks Python to build a two-billion-character string, so a single specifier costs 2 GB of resident memory and a MemoryError (or the OOM killer) on a container-sized host.

Additional Resources