Server-Side Template Injection (SSTI): Detection and Prevention
How SSTI works in Jinja2, Twig, and Freemarker, the path from template expression to RCE, sandbox escapes, and effective input escaping strategies.
Server-Side Template Injection (SSTI): Detection and Prevention
Server-Side Template Injection (SSTI) occurs when user-controlled input is embedded directly into a template that is then rendered by the server. Template engines — Jinja2, Twig, Freemarker, Velocity, Pebble, and others — are designed to evaluate expressions and execute logic. When an attacker can inject template syntax into the rendered output, they can escalate from reflected text to arbitrary code execution on the server.
SSTI is often confused with XSS because both involve injection into rendered output. The critical difference: XSS executes in the victim's browser; SSTI executes on the server with the privileges of the application process.
How SSTI Works
Consider a Python Flask application that renders a greeting:
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/greet')
def greet():
name = request.args.get('name', 'World')
# Vulnerable: user input directly in template string
template = f"Hello, {name}!"
return render_template_string(template)
A request to /greet?name=World returns Hello, World!. But a request to /greet?name={{7*7}} returns Hello, 49! — the Jinja2 expression was evaluated. The server told you it can execute template logic from your input.
Jinja2 (Python): From Expression to RCE
Jinja2 is used by Flask, Django (optionally), and Ansible. Its expression language is powerful, and without a sandbox, it provides direct access to Python internals.
Confirming injection:
{{7*7}} → 49
{{7*'7'}} → 7777777 (string repetition, not multiplication — distinguishes Jinja2 from Twig)
Reaching os.system through Python's object graph:
{{ ''.__class__.__mro__[1].__subclasses__() }}
This lists all subclasses of object. From that list, an attacker locates a class that can invoke shell commands — typically via subprocess.Popen or similar:
{{ ''.__class__.__mro__[1].__subclasses__()[396]('id', shell=True, stdout=-1).communicate() }}
The exact index varies by Python version and installed packages, but the principle is consistent: traverse Python's class hierarchy to find a callable that reaches the OS.
Why Jinja2's sandbox is not enough on its own:
Jinja2 provides a SandboxedEnvironment that restricts attribute access. However, sandbox escapes have been published repeatedly. The sandbox reduces the attack surface but should not be the only defense.
Twig (PHP): Template Injection to Code Execution
Twig is the default template engine for Symfony. It uses {{ }} for expressions and {% %} for control structures.
Confirming injection:
{{7*7}} → 49
{{7*'7'}} → 49 (numeric coercion, distinguishes Twig from Jinja2)
Reaching system() in Twig:
Twig's sandbox is more restrictive than Jinja2's, but without sandboxing enabled:
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
This registers PHP's exec function as a Twig filter and immediately calls it. CVE-2019-10909 (Symfony) and multiple CMS vulnerabilities have exploited this pattern.
Freemarker (Java): SSTI to JVM Code Execution
Apache Freemarker is used in Java enterprise applications. Its template language exposes the Java API:
Confirming injection:
${7*7} → 49
Executing system commands:
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
freemarker.template.utility.Execute is a class that ships with Freemarker itself and calls Runtime.exec(). An attacker who can inject into a Freemarker template can execute arbitrary shell commands immediately.
Freemarker also supports new() for instantiating arbitrary Java classes, and ?api to access underlying Java objects from template wrappers.
Detection: Identifying SSTI Vulnerabilities
Manual testing
Inject template expressions and look for evaluation in the response. A useful probe is {{7*'7'}} — Jinja2 returns 7777777, Twig returns 49, and a non-template-injecting context returns the literal string. This helps identify both the vulnerability and the engine.
Detection payloads by engine:
| Engine | Probe | Expected output |
|---|---|---|
| Jinja2 | {{7*'7'}} | 7777777 |
| Twig | {{7*'7'}} | 49 |
| Freemarker | ${7*7} | 49 |
| Velocity | #set($x=7*7)${x} | 49 |
| Smarty | {7*7} | 49 |
Automated tools
Tplmap is an open-source tool that automates SSTI detection and exploitation across multiple template engines. Burp Suite's active scan also detects SSTI in HTTP parameters.
Code review
Search for direct string formatting into template rendering calls:
# Dangerous patterns in Python
render_template_string(f"Hello {user_input}")
render_template_string("Hello " + user_input)
jinja2.Environment().from_string(user_input).render()
// Dangerous in Freemarker
Template t = new Template("name", new StringReader(userInput), cfg);
Prevention
Separate template logic from user data
The correct approach is to pass user data as template variables, never as part of the template string itself:
# Safe
from flask import Flask, request, render_template_string
@app.route('/greet')
def greet():
name = request.args.get('name', 'World')
# User data is a variable, not part of the template syntax
return render_template_string("Hello, {{ name }}!", name=name)
The template "Hello, {{ name }}!" is a fixed string. The user-controlled name value is passed as a context variable. The template engine renders {{ name }} using the value of the variable, not by evaluating the variable's content as a template expression.
// Safe Freemarker usage
Template template = cfg.getTemplate("greet.ftl"); // template from file, not user input
Map<String, Object> model = new HashMap<>();
model.put("name", userName); // user data as variable
template.process(model, writer);
Use sandboxed environments
Where user-generated templates must be supported (e.g., a marketing email builder, a report template system), use the template engine's sandbox:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string(user_template_string)
result = template.render(data=allowed_data)
The SandboxedEnvironment restricts access to Python internals and prevents attribute traversal to dangerous objects. Combine it with an explicit allowlist of what variables and filters the user's template can access.
For Twig, enable the sandbox with an explicit policy:
$policy = new \Twig\Sandbox\SecurityPolicy($allowedTags, $allowedFilters, $allowedProperties, $allowedMethods, $allowedFunctions);
$sandbox = new \Twig\Extension\SandboxExtension($policy, true);
$twig->addExtension($sandbox);
Output encoding for reflection scenarios
If you must reflect user input back in a template, treat it as data and use auto-escaping. Never disable auto-escaping (|safe in Jinja2, raw in Twig) on user-controlled content.
Disable dangerous built-ins in Freemarker
Freemarker provides a configuration setting to disable new() and restrict API access:
cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
SAFER_RESOLVER blocks freemarker.template.utility.Execute and similar dangerous classes while still allowing Freemarker's built-in object types.
SSTI is one of the few vulnerability classes where exploitation can reach full RCE with a single HTTP request. Keeping user data out of template strings is the only reliable prevention.