skip to content

Why is str.format with an attacker-supplied template string a data-leak risk?

level: middleimportance: must knowfreq 42%

answer

  1. Two strings go in, one is untrusted
  2. The braces hold a grammar, not a hole
  3. Dots and brackets chain inside a field name
  4. A Python method exposes its module namespace
  5. __init__.__globals__ then a key lookup

basics

~20 s

Python's format mini-language allows attribute access and indexing inside a placeholder, so whoever writes the template chooses what is read out of the objects you pass. A chain through a method's globals reaches module-level secrets.

solid answer

~40 s

`str.format` treats the text between the braces as a small expression language, not as inert text: `{0.attr}` performs attribute access, `{0[key]}` performs item access, and both chain without limit. So when the *template* is untrusted and only the *arguments* are yours, the attacker decides what gets read. The classic escalation is `{0.__init__.__globals__[API_KEY]}` — any Python-defined method exposes `__globals__`, the module namespace it was defined in, and with it every module-level constant. `str.format_map` is not safer; only the lookup differs, the field-name grammar is identical. There is no call syntax, so this is information disclosure rather than code execution — but attribute access still fires `property` getters and `__getattr__`, and an enormous width in the format spec allocates that many characters. The control is structural: untrusted text must never be the template.

code

python · 10 lines
python
API_KEY = "sk-live-not-a-real-key"


class Order:
    def __init__(self, ref):
        self.ref = ref


template = "{0.__init__.__globals__[API_KEY]}"   # arrives from a request
print(template.format(Order("A-1")))           # sk-live-not-a-real-key

go deeper

for a junior

Recall that str.format reads its instructions from the template string, and that {0.attr} and {0[key]} are real syntax inside the braces. Be able to say why a template that came from a user is different from one you wrote.

for a middle

Explain the mechanics end to end: field name, attribute and item chaining, and the walk {0.__init__.__globals__[SECRET]} from an argument to a method to its module namespace. Be ready to say why str.format_map changes nothing.

for a senior

Show where this actually enters a system — customer-editable notification bodies, translation rows, alert templates — and argue the structural control (untrusted text is never the template) over lexical blocklists. Mention property side effects and the width amplifier.

for a principal

Own the boundary rule rather than the instance: which subsystems are permitted to accept templates at all, what the reviewable invariant is, and how you detect the pattern across a large codebase before someone ships a customer-editable field again.

### Two strings, and only one of them is yours Every `str.format` call has two inputs: the **template** — the string carrying the braces — and the **arguments**. Most Python code treats the template as inert text with holes punched in it. It is not. The text between the braces is a small expression language, the *format mini-language*, and the interpreter evaluates it against whatever you passed. So the template decides *what is read*; the arguments only decide *what is reachable*. The moment the template comes from a request body, a database row a customer edited, or a config file a tenant uploads, an attacker is writing that expression for you. ### What the mini-language actually permits A replacement field is `{field_name!conversion:format_spec}`. The field name starts with a positional index (`{0}`) or a keyword (`{customer}`) and may then chain, without limit: * **attribute access** — `.name`, which performs the same lookup as `getattr`; * **item access** — `[key]`, where the key is taken literally, as a string unless it is all digits, in which case it becomes an integer index. `{0.a.b[c].d}` is a legal field name. What is *absent* matters as much: there is no call syntax, no arithmetic, no assignment. The primitive an attacker gets is **read**, not **execute**. ### The escalation into module globals Reads are enough. Every Python-level function object carries `__globals__`, the dict of the module namespace it was defined in — and a bound method exposes the same attribute. So if any argument is an instance of a class whose `__init__` is written in Python, the template `{0.__init__.__globals__[API_KEY]}` walks from your object to its method, from the method to the module namespace, and from there to any module-level name: an API token, a signing key, a database URL, a settings object it can keep walking through. ```python API_KEY = "sk-live-not-a-real-key" class Order: def __init__(self, ref): self.ref = ref "{0.__init__.__globals__[API_KEY]}".format(Order("A-1")) # -> 'sk-live-not-a-real-key' ``` Note the chain needs a *Python-defined* function somewhere. `object.__init__` is a C slot wrapper and has no `__globals__`, which is why the payload is usually aimed at your own classes rather than at a builtin — and why "we only pass strings" narrows the blast radius without being a control you should rely on. ### `str.format_map` is not the hardened version `str.format_map` differs from `str.format(**mapping)` in exactly one way: it passes the mapping through instead of copying it into keyword arguments, so a mapping subclass with `__missing__` can answer for absent names. The field-name grammar is identical, and attribute access still applies to the mapped values: ```python "{customer.__class__.__mro__}".format_map({"customer": "ada"}) # (<class 'str'>, <class 'object'>) ``` Reaching for `format_map` because it "only substitutes values" is a very common wrong answer. ### Disclosure, side effects, and amplification Because there is no call syntax, this is primarily an **information-disclosure** primitive rather than remote code execution. Two caveats keep it from being merely theoretical: * **Attribute access runs code.** A `property` getter or a `__getattr__` hook fires during the walk, so anything with side effects behind a property is reachable — including lazily loaded credentials. * **The format spec allocates.** `"{0:>100000000}".format("x")` builds a 100-million-character string from a fifteen-character template. A stored template is a compact memory-amplification bug. ### How this gets into a codebase Nobody writes `request.json["template"].format(...)` on purpose. The pattern arrives sideways: user-configurable notification or webhook bodies; localisation strings pulled from a table that translators can edit; alert templates in a rules engine; "subject line" fields in an admin UI. In all of them the template became **data** at some point, and data crossed a trust boundary while the code still treated it as source. ### The shape of the fix The control is structural, not lexical: **untrusted text must never be the template.** Blocklisting `__globals__`, stripping underscores, or escaping braces in the *values* all miss the point — the values were never the problem. Either pick a placeholder syntax with no attribute or item grammar to abuse, or interpose your own `string.Formatter` subclass that rejects any field name outside an allowlist, and pass it only pre-stringified primitives. ### Version note None of this has moved between Python 3.10 and 3.14; the mini-language grammar is stable. Template strings (t-strings, PEP 750) arrived in 3.14 and are sometimes offered as the answer here — they are not. A t-string is a literal in *your* source, so it addresses how values reach a sink, not who wrote the template.

  • Does str.format_map make an untrusted template any safer than str.format?
    No. The only difference is that `format_map` passes the mapping through instead of copying it into keyword arguments, so a mapping subclass can answer for missing keys via `__missing__`. The field-name grammar is identical, and attribute and item access still apply to the mapped values — `"{customer.__class__}".format_map({"customer": "ada"})` resolves happily. Choosing `format_map` as a hardening measure is a common wrong answer.
  • Can an attacker reach code execution through the format mini-language alone?
    Not directly: the grammar has no call syntax, no arithmetic and no assignment, so the primitive is a read. Two qualifications matter. Attribute access still runs `property` getters and `__getattr__`, so code with side effects is reachable. And the format spec allocates — `"{0:>100000000}".format("x")` builds a hundred million characters — so a short stored template is a memory-amplification bug even without any leak.
  • How much does it limit the damage if the arguments are only plain strings?
    It narrows the walk considerably: a `str` exposes type metadata such as `__class__` and `__mro__`, and the C-level slot wrappers reachable from there have no `__globals__`, so there is no route into a module namespace. Treat that as defence in depth, not as the control — the next refactor that passes a settings object or one of your own instances silently reopens the full chain.

It is the difference between filling in a form and letting the applicant redesign the form. Handing over the template hands over the questions, and the questions are what decide which drawers of your filing cabinet get opened.

saying these in an interview costs you the question

  • Says f-strings are the dangerous ones and .format() is safe
  • Thinks the attacker must control the arguments, not the template
  • Believes str.format_map with a dict blocks attribute access
  • Claims escaping braces in user values is a complete fix
  • Assumes the mini-language can call functions, so it is remote code execution
  • Proposes scanning the rendered output for secrets afterwards

context