07.08 · OWASP A03:2021 (Injection) · CWE-1336

Server-Side Template Injection

Template engines were built to mix code and data. When the data becomes code, the engine doesn't notice. A user's display name in a welcome email becomes a Python interpreter running as your web service.

What template engines do

You write a template with placeholders:

Hello, {{ name }}! You have {{ unread_count }} unread messages.

The engine substitutes values. The placeholders aren't just variable interpolation — modern engines support full expression evaluation, filters, even arbitrary method calls. They're small programming languages embedded in your strings.

The bug appears when the template itself — not just the values — comes from user input. The classic shape:

The vulnerable patternvulnerable
# Flask + Jinja2. The welcome email customization feature. @app.route("/send-welcome") def send_welcome(): template_str = "Hello {customer}! Welcome to " + request.args.get("company") rendered = render_template_string(template_str, customer=user.name) return rendered

The user controls the company parameter and it lands directly in the template before rendering. Looks harmless. "Welcome to Acme Corp."

Then someone sends:

/send-welcome?company={{ 7*7 }}

The page returns: Hello Marcus! Welcome to 49. Confirmed — the engine evaluated the expression. From there, escalation to RCE is mechanical.

The famous Jinja2 RCE

Jinja2's sandbox lets you traverse Python's object graph by walking from any object up to its class, then up the type hierarchy. With the right chain, you can reach os.popen or subprocess.

{{ ''.__class__.__mro__[1].__subclasses__() }} # returns every subclass of object — including warnings.catch_warnings, # which holds a reference to a module dict that contains os. {{ ''.__class__.__mro__[1].__subclasses__()[133].__init__.__globals__['os'].popen('id').read() }} # returns: uid=33(www-data) gid=33(www-data) groups=33(www-data)

The exact subclass index varies per Python build, but a payload that enumerates and finds the right one is a few lines of attacker work. The SSTI Jinja payload list on PortSwigger's labs walks through this in detail.

"But I disabled the unsafe stuff." Jinja2 has a SandboxedEnvironment. People bypass it. The Jinja maintainers themselves recommend not using user-controlled templates — the sandbox is a hardening layer, not a security boundary.

Per-engine probes

Different template engines use different delimiters and expression syntax. The first attacker step is identifying which engine.

EngineUsed byProbeSuccessful response
Jinja2Flask, Ansible, Salt{{ 7*7 }}49
TwigSymfony, Drupal 8+{{ 7*7 }}49
SmartyLegacy PHP CMSes{$smarty.version}Version string
MakoPython (Pylons, Pyramid)${7*7}49
VelocityApache, older Java apps#set($x=7*7)$x49
FreemarkerSpring, Atlassian${7*7}49
HandlebarsJavaScript{{7*7}}Echo (NOT 49 — Handlebars doesn't evaluate expressions, safer by default)
ERBRuby on Rails<%= 7*7 %>49

The progression: math probe (does it evaluate?) → identify engine via syntax differences → engine-specific payload chain to RCE.

The fix

Pattern 1: never put user input into template source

The single rule that kills the bug. Templates are author-controlled. User data goes in as values, never as the template string itself.

safe patternsafe
# template defined statically: TEMPLATE = "Hello {{ customer }}! Welcome to {{ company }}." @app.route("/send-welcome") def send_welcome(): company = request.args.get("company") # passed as a VALUE, not embedded in template return render_template_string(TEMPLATE, customer=user.name, company=company)

Now {{ 7*7 }} in the company parameter is rendered as literal text, because Jinja sees it inside a value, not the template source.

Pattern 2: if you must let users supply templates, use a logic-less engine

Engines like Mustache or Handlebars deliberately don't allow arbitrary expressions. They support {{name}} substitution but not {{name.constructor.constructor("...")}} shenanigans. Use them anywhere users need template authoring (email customization, dashboard widgets, etc.).

Pattern 3: sandbox + de-privilege

If your stack demands a richer engine and you cannot remove user-template input, run the renderer in a sandboxed process with no filesystem or network access, as an unprivileged user. Even a successful injection has nowhere to go.

Real incidents

  • Uber (2016) — an SSTI in their email parameter template earned a researcher a $10,000 bounty; payload was a classic Jinja2 chain.
  • Atlassian Confluence (CVE-2022-26134) — OGNL injection in URL parameters; full unauthenticated RCE. Used in widespread post-disclosure attacks.
  • Apache Spark UI (CVE-2022-33891) — ACL-checking code passed user input to a shell via a templated command. Pre-auth RCE.
  • Velocity in older Atlassian products — multiple CVEs over the years, several leading to RCE via template injection in admin pages.

The takeaway

SSTI is what happens when a developer treats a template engine as just a string formatter. It isn't — it's a programming language embedded in your strings, and any user input that reaches the template source becomes code.

The rule: templates are author-controlled, data is user-controlled, and the two never mix. User input is always passed as a value to a static template. If you can hold that line, the vulnerability class doesn't exist in your app.

References

Formatted in APA 7.

  1. Kettle, J. (2015). Server-side template injection: RCE for the modern web app. PortSwigger Research. https://portswigger.net/research/server-side-template-injection
  2. OWASP. (2024). Server-side template injection. https://owasp.org/www-project-web-security-testing-guide/...Testing_for_Server-side_Template_Injection
  3. MITRE. (2024). CWE-1336: Improper neutralization of special elements used in a template engine. Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/1336.html
  4. National Institute of Standards and Technology. (2022). CVE-2022-26134 detail (Atlassian Confluence). National Vulnerability Database. https://nvd.nist.gov/vuln/detail/CVE-2022-26134