Web Security

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.

March 9, 20266 min readShipSafer Team

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:

EngineProbeExpected 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.

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.