Introduction
Server-Side Template Injection (SSTI) occurs when user-controlled input is interpreted as part of a server-side template language. Because templates often have powerful expression capabilities (loops, filters, function calls), an attacker who can inject arbitrary template syntax may execute code, read files, or even achieve full remote code execution (RCE).
Why it matters: SSTI bridges the gap between classic injection flaws (SQLi, XSS) and full RCE. A single mis-handled render_template call can give an adversary a sandbox-escape path that bypasses many traditional defenses.
Real-world relevance: Public bug bounty programs (e.g., HackerOne, Bugcrowd) regularly reward SSTI findings, and high-profile breaches (e.g., a 2023 compromise of a SaaS provider) were traced back to Jinja2 injection.
Prerequisites
- Understanding of OWASP Top 10, especially A03:2021 - Injection.
- Basic Python syntax, data structures (lists, dicts), and familiarity with Flask/Django style routing.
- Knowledge of HTTP request/response cycles (methods, headers, body).
Core Concepts
SSTI exploits the fact that many web frameworks separate presentation (templates) from business logic. A template engine parses a string, evaluates expressions, and renders the final markup. If user input reaches the parser without proper sanitisation, the engine will treat that input as executable template code.
Typical flow:
- Client sends a value (e.g.,
namequery parameter). - Server concatenates the value into a template string or passes it directly to a rendering function.
- The engine evaluates the string, potentially executing arbitrary functions.
Because most engines expose a rich standard library, the attacker can chain calls to os, subprocess, or language-specific globals to escape the sandbox.
Definition of Server-Side Template Injection
SSTI is a class of injection where the payload is interpreted by the server-side templating engine. Unlike client-side template injection (e.g., AngularJS), the execution occurs before the HTTP response is sent, giving the attacker deeper system access.
Key properties:
- Context-aware: The payload must be valid template syntax for the target engine.
- Code-execution potential: Many engines expose functions that map to OS commands.
- Detection difficulty: The payload often looks like normal template markup, evading simple pattern-based scanners.
Typical vulnerable code patterns (e.g., render_template, eval, exec)
Below are common anti-patterns that create an SSTI surface.
# Flask example - vulnerable
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/greet')
def greet(): # Directly injects user input into a template string name = request.args.get('name', '') template = f"<h1>Hello {{ {name} }}!</h1>" return render_template_string(template)
In the snippet, name is interpolated into a Jinja2 template without sanitisation. An attacker can supply __import__('os').system('id') and the engine will execute it.
# Django - unsafe use of eval
from django.http import HttpResponse
def echo(request): user_input = request.GET.get('msg', '') # eval is rarely needed; here it is used to render a template rendered = eval(f"f'{user_input}'") return HttpResponse(rendered)
Calling eval on a string that later passes through a template engine amplifies the risk. Even if the template engine itself is safe, the prior eval can already execute arbitrary Python.
# Generic exec example - often seen in custom micro-frameworks
user_template = request.form.get('tpl')
exec(user_template) # ❌ Direct execution of user-provided code
While exec is not a template engine, many developers mistakenly treat it as a quick “render” function, creating a direct RCE vector.
Common templating engines overview (Jinja2, Twig, Velocity, etc.)
Understanding the capabilities of each engine helps you gauge the attack surface.
- Jinja2 (Python): Used by Flask, Django (as a fallback), and many custom apps. Supports filters, macros, and full Python object access via the
selfandnamespaceconstructs. - Twig (PHP): Symfony’s default engine, mirrors Jinja2 syntax but runs on PHP. Provides
{{ _self }}and{{ attribute() }}helpers that can reach global PHP functions. - Velocity (Java): Apache’s engine for Java web apps. Allows method invocation on objects, e.g.,
$runtime.exec('id'). - Freemarker (Java): Similar to Velocity but with stricter type handling. Still vulnerable when
?evalis enabled. - Handlebars (Node.js): Server-side implementation (e.g.,
express-handlebars) can expose helpers that callrequire('child_process')if mis-configured.
All these engines share a common trait: they allow expression evaluation, which can be abused to traverse the runtime environment.
Why Jinja2 is attractive for attackers
Jinja2 dominates the Python web ecosystem, making it a frequent target. Its attractiveness stems from:
- Rich sandbox bypass techniques: The
configobject,cycler, andnamespacecan be manipulated to reach__import__and theosmodule. - Implicit globals: By default, Jinja2 exposes
self,loop, and theenvironmentwhich can be used to fetch__builtins__. - String interpolation: The
{{}}syntax accepts arbitrary Python expressions when the environment is not locked down. - Template inheritance: Attackers can create a child template that imports a malicious base, widening the attack surface.
Because many developers rely on render_template_string for quick prototypes, the risk of feeding raw user data into Jinja2 is high in real-world codebases.
Impact assessment: data disclosure, sandbox escape, RCE
Depending on the engine configuration, the consequences range from low-grade information leakage to full system compromise.
| Impact | Typical Payload | Result |
|---|---|---|
| Data disclosure | {{ config.items() }} | Exposes framework configuration, environment variables, secret keys. |
| Sandbox escape | {{ ''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read() }} | Reads arbitrary files by walking Python's class hierarchy. |
| Remote Code Execution (RCE) | {{ __import__('os').popen('id').read() }} | Executes OS commands, potentially spawning a reverse shell. |
Even when a sandbox is enabled, clever chaining of built-ins can bypass restrictions, as demonstrated in public PoC scripts.
Practical Examples
We will walk through a vulnerable Flask app, exploit it, and then show a patched version.
# vulnerable_app.py
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/')
def index(): # The 'tpl' parameter is directly rendered - classic SSTI tpl = request.args.get('tpl', 'Hello {{ "world" }}') return render_template_string(tpl)
if __name__ == '__main__': app.run(debug=True)
Running this app and visiting http://localhost:5000/ returns the entire Flask configuration dictionary, exposing secret keys.
To achieve RCE, an attacker can use the infamous class-hierarchy traversal:
curl "http://TARGET_URL/?tpl={{ ''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read() }}"
Assuming the 40th subclass is subprocess.Popen on the target Python version, the response contains the contents of /etc/passwd.
**Mitigated version** - using a strict template environment:
# safe_app.py
from flask import Flask, request, render_template_string
from jinja2 import Environment, BaseLoader, select_autoescape
app = Flask(__name__)
# Create a sandboxed environment - disable globals and imports
safe_env = Environment( loader=BaseLoader(), autoescape=select_autoescape(['html', 'xml']), enable_async=False, undefined=jinja2.StrictUndefined, # The following line removes the ability to access __import__ and other builtins globals={}
)
@app.route('/')
def index(): tpl = request.args.get('tpl', 'Hello {{ "world" }}') template = safe_env.from_string(tpl) return template.render()
if __name__ == '__main__': app.run(debug=False)
By providing an empty globals dict and using StrictUndefined, any attempt to reference undefined objects results in a rendering error, neutralising the payload.
Tools & Commands
- Burp Suite Intruder - craft payloads with Jinja2 expression lists.
- ssti-fuzz - a Python script that enumerates common payloads for multiple engines.
git clone REPO_URL cd ssti-fuzz python3 ssti_fuzz.py -u "http://TARGET_URL/?tpl=FUZZ" -p payloads/jinja2.txt - Template injection scanner (TIS) - integrates with OWASP ZAP to detect unsafe
render_template_stringusage. - Python REPL - useful for testing payloads locally before sending them over HTTP.
from jinja2 import Template payload = "{{ __import__('os').popen('whoami').read() }}" print(Template(payload).render())
Defense & Mitigation
- Never render raw user input. Use static templates and pass user data as context variables.
- Enable a sandbox. Most engines provide a sandbox mode that disables imports, attribute access, and function calls.
- Whitelist allowed template names. If you must allow dynamic selection, validate against a known list.
- Content-Security-Policy (CSP) for server-side rendering. While CSP primarily protects the client, it can limit the impact of reflected payloads that attempt to inject script tags.
- Static analysis. Scan codebases for
render_template_string,eval,exec, and any concatenation of request data into template strings. - Runtime monitoring. Log template rendering errors; sudden spikes may indicate probing.
Common Mistakes
- Assuming that escaping HTML entities (“<”, “>”) sanitises template syntax - it does not.
- Using
render_template_stringfor convenience in production code. - Relying solely on input validation (e.g., alphanumeric checks) without considering that template syntax can be expressed with letters and numbers (
__class__). - Disabling the sandbox but forgetting that some built-ins remain accessible via the
selfvariable. - Failing to update third-party libraries; older Jinja2 versions had known sandbox bypasses.
Real-World Impact
In 2022, a popular SaaS platform built on Flask exposed an admin endpoint that directly rendered a msg query parameter via render_template_string. A bug bounty researcher submitted a Jinja2 payload that read /var/www/.env, leaking API keys and leading to a full account takeover. The vendor patched the code after a month of public disclosure, illustrating the speed at which SSTI can move from discovery to exploitation.
Trend analysis: As micro-frameworks gain traction, developers favor render_template_string for rapid prototyping, inadvertently widening the attack surface. Automated CI/CD scanners are beginning to flag these patterns, but manual code review remains essential.
Practice Exercises
- Identify vulnerable patterns: Clone a simple Flask repo and locate every occurrence of
render_template_string. Replace them with static templates. - Craft a Jinja2 bypass: Using the class-hierarchy technique, read
/etc/hostsfrom a deliberately vulnerable app. - Sandbox configuration: Write a Python script that creates a Jinja2
Environmentwith a customSandboxedEnvironmentand verify that the same payload now raises an exception. - Automation: Integrate
ssti-fuzzinto a CI pipeline and generate a report for a dummy web service.
Further Reading
- Jinja2 Documentation - official site
- OWASP Server-Side Template Injection Cheat Sheet
- “Exploiting Jinja2 Sandbox Bypass” - research paper by PortSwigger (2021)
- Secure Coding Guidelines for Python - Python Security Project
Summary
SSTI is a powerful injection class that leverages the expressiveness of server-side templating engines. By understanding vulnerable code patterns, the capabilities of popular engines like Jinja2, and the range of possible impacts—from data leakage to full RCE—security professionals can systematically detect, test, and remediate this risk. The key takeaways are: never render raw user input, employ sandboxed environments, and incorporate static analysis into your development lifecycle.