Template injection is one of those vulnerability classes that consistently surprises developers who know it exists theoretically but haven’t seen it exploited in a real codebase. The gap between “user input ends up in a template string” and “the attacker has shell access” is shorter than most people expect.
What SSTI is and how it differs from XSS
Client-side template injection and XSS affect what the browser executes. Server-side template injection affects what the server executes. The severity difference is substantial: XSS gives the attacker code execution in a victim’s browser; SSTI gives the attacker code execution with the privileges of the server process.
The vulnerability requires two conditions: a template engine rendering templates at runtime, and user-controlled data being embedded directly into the template string before rendering — not passed as a variable into a safely rendered template, but embedded into the template string itself.
The distinction is critical and easy to miss:
# SAFE: user_input passed as a context variable
from jinja2 import Template
template = Template("Hello {{ name }}")
output = template.render(name=user_input) # user_input can't escape the {{ }} context
# VULNERABLE: user_input embedded directly into the template string
template_str = f"Hello {user_input}" # user_input is now part of the template
output = Template(template_str).render() # if user_input is {{ 7*7 }}, renders "Hello 49"
In the vulnerable pattern, the user controls the template structure, not just the data. Anything valid in the template language is valid in their input.
Jinja2 exploitation (Python)
Jinja2 is Flask’s default templating engine and is widely used in Django applications. Its expression syntax gives attackers a path to Python’s object hierarchy.
A minimal probe to confirm Jinja2 SSTI: submit {{7*7}} to any field that gets reflected. If the response contains 49, the field is vulnerable.
From there, the standard exploitation chain traverses Python’s class hierarchy to reach OS subprocess execution:
# Payload to list files (for illustration — this executes on the server)
{{ ''.__class__.__mro__[1].__subclasses__() }}
# Find Popen in the subclass list, then:
{{ ''.__class__.__mro__[1].__subclasses__()[X]('id', shell=True, stdout=-1).communicate() }}
# where X is the index of subprocess.Popen in the subclass list
Modern Jinja2 exploitation uses Jinja2’s request object (in Flask context) or module imports to reach the same result more reliably. The exact payload index changes with Python version, but the traversal path is stable enough that automated tools resolve it reliably.
Vulnerable Flask pattern:
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/page')
def page():
name = request.args.get('name', 'World')
# VULNERABLE: name is embedded in the template string
template = f"<h1>Hello {name}</h1>"
return render_template_string(template)
Safe equivalent:
@app.route('/page')
def page():
name = request.args.get('name', 'World')
# SAFE: name is passed as a context variable to a static template string
return render_template_string("<h1>Hello {{ name }}</h1>", name=name)
The fix is one line. The template string is static; the user input is passed as named context. Jinja2’s auto-escaping and expression context restrictions apply to context variables — they don’t apply when user input is part of the template structure itself.
Thymeleaf exploitation (Java/Spring)
Thymeleaf, the standard Spring MVC template engine, has a different but equally exploitable SSTI pattern. The Spring Expression Language (SpEL) processor within Thymeleaf can be reached through specific template attributes when user input reaches the view template unsanitised.
The typical vulnerable pattern is a Spring MVC controller that puts user input into the model and then references it in a th:text attribute that isn’t properly scoped:
// VULNERABLE: user input reaches a Thymeleaf expression fragment
@GetMapping("/search")
public String search(@RequestParam String query, Model model) {
model.addAttribute("query", query);
return "search-results"; // template references query in a th:inline expression
}
In the template, if query ends up in an unquoted th:inline=text context with [[${query}]] syntax, an attacker can inject SpEL:
// Probe: submit as query parameter
__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x
// Thymeleaf prefix/suffix injection pattern targeting prepend-fragment controllers
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec('id').getInputStream()).useDelimiter('\\A').next()}__::.x
Thymeleaf’s prefix/suffix injection (where user input controls part of the template fragment path) is the most exploited variant — it reaches SpEL evaluation without needing the input to appear inside an expression context.
Prevention: Never use user input to construct template fragment paths. Use th:text (which HTML-encodes output) rather than th:utext (which doesn’t). Disable SpEL resolution in untrusted contexts by configuring Thymeleaf’s StandardExpressionParser with a restricted expression evaluator.
// Spring Security configuration to restrict SpEL in Thymeleaf
@Bean
public SpringTemplateEngine templateEngine() {
SpringTemplateEngine engine = new SpringTemplateEngine();
engine.setEnableSpringELCompiler(false); // Disable SpEL compilation
// ... other config
return engine;
}
Handlebars/Pebble/Freemarker notes
JavaScript Handlebars, used server-side in Node.js applications, restricts expression evaluation but has had exploitable prototype pollution issues that allow accessing process.mainModule.require. Pebble (Java) evaluates expressions by default and has an equivalent class traversal path to Jinja2. Freemarker (Java) supports ?eval as a built-in that executes arbitrary expressions and has been exploited in Atlassian Confluence and other products.
The pattern is consistent across engines: any engine that evaluates expressions at render time is exploitable if user data reaches the template string.
Testing for SSTI
The initial probe is engine-detection: submit template syntax from different engines and observe which causes the response to change.
{{7*7}} → 49 (Jinja2, Twig)
${7*7} → 49 (Freemarker, Pebble, SpEL)
#{7*7} → 49 (Thymeleaf — in some contexts)
<%= 7*7 %> → 49 (ERB — Ruby)
If any of these reflect 49 instead of the literal string, the field is vulnerable. In modern applications, the response may be JSON-encoded or appear in a different field than expected — test every reflected parameter, not just visible form fields.
Burp Suite’s active scan detects common SSTI patterns. SemGrep has an SSTI rule set for Python/Jinja2 source scanning. For manual testing, PEASS-ng and tplmap automate the exploitation chain across multiple template engines.
Prevention summary
| Framework | Safe pattern | Unsafe pattern |
|---|---|---|
| Flask/Jinja2 | render_template_string("{{ var }}", var=input) | render_template_string(f"{ {input} }") |
| Spring/Thymeleaf | model.addAttribute("q", input) + th:text="${q}" | User input in fragment path or th:utext with concatenation |
| Node.js/Handlebars | Compile template once at startup, pass data as context | Handlebars.compile(userInput) |
| Java/Freemarker | Static templates with ?html escaping | template.process() with user-constructed template string |
The common thread: compile or load templates from static files or string literals that you control. Pass user input only as context variables, never as part of the template string. Any deviation from this creates a potential SSTI surface.
For SAST coverage, include a Semgrep rule targeting dynamic template string construction in your CI pipeline. The pattern Template(f"...{variable}...") or render_template_string("..." + variable) is the signature to catch.