Server-Side Template Injection (SSTI): When Django Templates Become a Weapon

Server-Side Template Injection (SSTI): When Django Templates Become a Weapon

Django Security Series β€” Post 3 | Series I: Injection Attacks
OWASP A05:2025 β€” Injection | Reading time: ~16 min

πŸ§ͺ Run it yourself. This attack ships as a runnable lab in django-security-lab: a vulnerable view that compiles your ?tpl= string as a template, and a secure one that passes the same string in as data. Everything is exercised from the command line β€” send {{ flag }} with curl and watch it leak a context secret on /ssti/vulnerable/ and come back as literal text on /ssti/secure/. Clone it and reproduce every step.

Post 1 of this series covered SQL Injection β€” untrusted data breaking the boundary between SQL structure and a database query. Post 2 covered XSS β€” untrusted data breaking the boundary between HTML structure and a browser's rendering engine. Post 3 completes the injection triad with the variant most likely to end in code execution: Server-Side Template Injection (SSTI), where untrusted data breaks the boundary between a string and a server-side template engine β€” and on an engine that evaluates expressions, the payoff for the attacker is not a DOM-level script execution but Remote Code Execution (RCE) on the server itself.

This is the post that surprised me most while researching the series. I'd assumed Django was immune to SSTI β€” the attack sits under the same OWASP "Injection" umbrella (A05:2025) as SQL Injection and XSS, yet almost every write-up and lab illustrates it with Jinja2 or FreeMarker, never Django's own template language. What I discovered is more nuanced: Django's DTL (the Django Template Language, the built-in engine) refuses to evaluate Python expressions β€” {{ 7*7 }} raises a TemplateSyntaxError at parse time, not 49 β€” but it is not a sandbox, and Django's documentation says it isn't safe against untrusted template authors. Pass user input to Template(user_input) and the user becomes one. PetiΓ§Γ£o Brasil, the platform whose security I was studying while writing this series, sends more than a dozen kinds of notification email β€” signature verified, milestone reached, petition approved or rejected β€” each rendered from a fixed template file that only a code deploy can change. The efficiency request writes itself: move those bodies into a model so staff can edit the copy from the admin without shipping code. Implement that the naive way β€” rendering each stored body as a template β€” and every one of those emails turns into a stored-SSTI sink. That is what made the class feel real rather than academic.

This post covers where DTL's safety stops: a template string a user wrote, reading whatever the view put in its context.


The Attack: What It Is and How It Works

SSTI occurs when user-controlled input is concatenated into a template string and then rendered by a template engine, causing the engine to evaluate the input as code rather than data. The root cause is the same category of mistake as SQL Injection β€” conflating data and code at an interpreter boundary β€” just one layer higher in the stack. In SQL Injection the interpreter is the database engine; in SSTI the interpreter is the template engine.

What the engine can reach decides what the attacker gets. Django's own engine, DTL (the Django Template Language), does not evaluate Python expressions: {{ 7*7 }} raises TemplateSyntaxError at parse time instead of printing 49, and attribute names starting with an underscore are refused. What it does do is resolve every name in a template against the context the view passed in. So when a view compiles user input with Template(), the attacker is not running code; they are writing a template that reads whatever is in that context.

The lab's greeting view is the whole attack. It takes a ?tpl= parameter, compiles it with Template(tpl) and renders it with a context holding a display name and, beside it, a secret the greeting never shows. The usual first probe, {{7*7}} (PortSwigger's, for finding template injection), gets a TemplateSyntaxError rather than 49, which says the input reached a DTL parser instead of coming back as text. ?tpl={{ name }} renders world: the template was evaluated. ?tpl={{ flag }} renders the secret. No payload more elaborate than a name is needed, and nothing in the request looks like an attack.

The same mistake goes further on other engines and in other places. On an engine that evaluates expressions β€” a Jinja2 backend left on its default Environment rather than a SandboxedEnvironment β€” the injected template is Python: from any string, ''.__class__.__mro__[1].__subclasses__() walks up to object and down to subprocess.Popen, and the template runs commands on the server under the web process's account, which is remote code execution. When the rendered output never reaches the attacker β€” a template rendered into an email or a background job β€” the injection is blind, and execution is proven out of band, by a request the server makes to a host the attacker controls, or by a delay. A blocklist of {{ or __class__ does not stop it: Jinja2's |attr filter and {% print %} reach the same objects without either string. And the template does not have to arrive in the request at all: a body stored in the database and compiled at render time β€” the editable notification emails from the introduction, built the naive way β€” is stored SSTI, one write and a run on every send.


Real-World Incidents

VMware Workspace ONE Access SSTI β€” CVE-2022-22954 (2022)

In April 2022, VMware disclosed a critical unauthenticated SSTI vulnerability in Workspace ONE Access (formerly VMware Identity Manager), rated CVSS 9.8. The entry point was the FreeMarker template engine used in the Workspace ONE web interface: an attacker could supply a malicious template expression through the deviceUdid HTTP parameter in the /catalog-portal/ui/oauth/verify endpoint with no authentication required. FreeMarker, like Jinja2, evaluates expressions at render time β€” a FreeMarker template expression that reaches the OS via the freemarker.template.utility.Execute class achieves the same outcome as the Jinja2 MRO traversal above.

The vulnerability was added to the CISA Known Exploited Vulnerabilities (KEV) catalogue on 14 April 2022. Actors reverse-engineered the patch to develop a working exploit within approximately 48 hours of the 6 April disclosure, and VMware confirmed in-the-wild exploitation on 13 April. Exploitation began within a week: CISA responded to intrusions by likely APT actors from 12 April, on 14–15 April Morphisec saw it used to drop the Core Impact tool, with tactics it called common among groups such as the Iranian-linked Rocket Kitten, and by August Fortinet was seeing it deliver Mirai-variant botnets, ransomware and cryptominers. The CVSS score of 9.8 reflects the pre-authentication attack surface: any affected, unpatched installation whose web UI was reachable could be exploited without credentials.

The MITRE ATT&CK mapping is: T1190 (Exploit Public-Facing Application) for initial access; T1059 (Command and Scripting Interpreter) for execution after RCE was achieved.

The lesson for Django developers: the vulnerability was not in VMware's business logic β€” it was in the decision to render a user-controlled string through a powerful, expression-evaluating template engine. A Django DTL view processing the same parameter would not have been vulnerable in the same way. A Django view using Jinja2 without SandboxedEnvironment would have been.

Source: VMware Security Advisory VMSA-2022-0011


Django's Default Protections

DTL does not evaluate Python expressions. This is architectural, not a configuration setting you can accidentally disable. When the Django template engine encounters {{ variable }}, it looks up the name variable in the Context: the view's dictionary plus whatever context processors add. Filters and tags such as {% if %} bring their own limited logic, but there is no parser for arbitrary Python expressions, no access to Python builtins, and no path to the object graph beyond what is in the context.

The four consequences that follow from this architecture:

  1. {{ 7*7 }} raises TemplateSyntaxError β€” arithmetic operators are not valid variable-lookup syntax; DTL's parser rejects the expression before rendering begins.
  2. {{ user.__class__ }} raises TemplateSyntaxError β€” DTL explicitly forbids attribute access starting with underscores ("Variables and attributes may not begin with underscores").
  3. {{ undefined_name }} renders as empty string β€” a syntactically valid variable name that is not in the context resolves to '' (the string_if_invalid default). Only valid-syntax, undefined names produce this silent empty behaviour.
  4. {{ request.META.HTTP_HOST }} renders as empty string with a plain Context, as in the lab. Render through RequestContext or render(request, ...) with the request context processor enabled, which startproject does by default, and request is there without the view ever passing it.

{% load %}, the mechanism for adding custom functionality to DTL templates, reaches only libraries registered with the engine: the templatetags/ packages of installed apps and any listed in OPTIONS['libraries']. Whoever writes the template can load any of those ({% load i18n %} works from a user-supplied string), but not {% load subprocess %}: the load mechanism resolves registered libraries, never arbitrary Python modules.

Django's autoescape=True default (covered in Post 2) adds an XSS layer on top: even if a variable value contained an HTML payload, it would be entity-escaped before being written into the page.

But every protection described here assumes one thing: that the template source is yours, loaded from the filesystem, and not something a user handed you. The next section is what happens when that assumption breaks.


The Vulnerable Pattern and the Fix

DTL's safety assumes you wrote the template; Django's documentation says outright that the template system isn't safe against untrusted template authors. Pass a string from user input to Template() and that assumption is gone: the user is no longer supplying values to your template β€” they are writing it, and DTL resolves every name they write against the context your view passed in.

Template(user_input) in a View

The most common SSTI mistake in Django is passing user input straight to the Template() constructor. The lab's greeting view does it with a ?tpl= parameter, and the fix keeps the same input as data:

     tpl = request.GET.get("tpl", "Hello!")
     flag = (Flag.objects.first() or Flag(value="")).value
-    rendered = Template(tpl).render(Context({"name": "world", "flag": flag}))
+    rendered = Template("{{ message }}").render(
+        Context({"message": tpl, "name": "world", "flag": flag})
+    )

On the vulnerable view, ?tpl={{ flag }} comes back as the secret: the user wrote the template, and DTL resolved flag against the context. On the secure view the same payload is the value of message, substituted into a template the developer wrote and never parsed again, so the literal text {{ flag }} comes back β€” with flag still in the context. In a real view the fixed template would be a file, render(request, "greeting.html", {"message": tpl}); the lab writes it inline so the two views differ by one statement. Both are in the lab in full: views_vulnerable.py and views_secure.py.

A real context is rarely one secret. Pass request.user and {{ user.password }} returns the stored hash (pbkdf2_sha256$...), which is enough for an offline brute-force attack.

The blast radius scales with context depth. A model instance exposes every accessible field by name, and the Django ORM goes further β€” reverse relations ({{ user.groups.all }}, {{ user.logentry_set.all }}, any _set accessor) are reachable just by knowing the schema, no traversal needed; every object in the context is a readable surface, including every object the ORM can reach from it. A second, sharper risk is template-tag execution: every {% tag %} registered in any installed app's templatetags/ package is callable from a user-supplied template string, after a {% load %} the user can write too β€” built-in tags such as {% include %} and {% extends %}, and any custom tags your application registers, including ones that invoke business logic, format sensitive data, or touch the filesystem.

What this is not, in a pure-DTL project, is remote code execution: the parser still refuses to evaluate expressions, so the blast radius stays bounded by the context and by what the installed tags and filters do: data disclosure and tag invocation in this lab, not a shell, though a custom tag that runs commands would change that.


The Analyst's View

To an analyst, a Template(user_input) finding on a DTL view is a disclosure finding whose severity is set by the context, not by the engine. Triage it by listing what the view and the context processors put there: request.user brings the password hash and every relation the ORM can reach from the user, the request context processor brings the request itself, and every registered template tag is callable from the attacker's template. A context holding only a display name is a low finding; the same line rendered through RequestContext in a project with a custom tag that touches the filesystem is not. And the ceiling moves with the engine: the VMware CVE above, the same mistake on an expression-evaluating engine, scores 9.8. A project with a Jinja2 backend needs the same review for every call that compiles a string through that engine's from_string(), where the finding is code execution, not disclosure.

The controls stack the way a defence-in-depth model predicts. Never passing user input to Template() is a preventive, eliminative control β€” it removes the sink rather than filtering the source, and eliminative controls are the strongest tier there is. A minimal context is the layer behind it: whatever a template can name, an injected template can read, so a view that passes one string instead of request.user limits what the next mistake exposes. The vulnerability-management takeaway is that a Template( call with a non-literal argument is a high-value review target: each hit needs someone to confirm who controls the source (the rule's known false positives are listed below), and it is cheap to grep for and expensive to miss, which is the profile that belongs in a CI gate rather than a quarterly review.


Catching It Automatically

Testing Your Defence

One test proves the fix: send the payload to the secure view and check that it comes back as text, not as the secret. This is the one the lab uses:

def test_exploit_is_blocked_on_secure_view(self):
    html = self.client.get("/ssti/secure/", {"tpl": "{{ flag }}"}).content.decode()
    self.assertIn("{{ flag }}", html)  # the payload came back as data...
    self.assertNotIn(FLAG, html)       # ...and the secret was never resolved

setUp stores the secret, FLAG, which both views hold in their context. Point the test at /ssti/vulnerable/ and it fails. In your own project, send a payload that names something you know is in the context β€” {{ user.email }}, say β€” through every field that can end up in a template, and assert it comes back literally. {{7*7}} is the wrong payload for this: DTL never returns 49, so an assertion against 49 only catches Jinja2; against DTL the bug shows up as a TemplateSyntaxError, not as a value you can assert on. The lab's tests.py adds the other two halves: the exploit succeeds on the vulnerable view, and the secure view still renders an ordinary greeting.

Scanning It

Post 1's SQL injection was a class the standard scanners catch cleanly. Post 2's mark_safe XSS was one where they disagreed β€” Bandit over-fired, Semgrep stayed quiet. SSTI through Template() is the third outcome, and the most uncomfortable: both tools go completely silent on a bug that is one payload away from reading anything in the render context.

Bandit β€” the AST scanner from Post 1 β€” reports nothing:

bandit -r labs/post_03_ssti/
# Test results: No issues identified.

It ships checks for mark_safe (B308/B703), Jinja2 autoescape (B701) and Mako (B702) β€” you can read the whole set in its plugin index β€” but it has no check that treats django.template.Template() β€” or Engine.from_string() β€” as a sink. Bandit matches risky calls node by node on the AST and never models this one, so a template compiled from user input matches nothing and the file comes back clean.

Semgrep's community rules β€” the taint scanner from Post 1 β€” do no better:

semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_03_ssti/views_vulnerable.py
# Ran 156 rules on 1 file: 0 findings.

156 rules run (151 Python, 5 multilang), and not one covers this sink. The community packs' Django coverage is built around the autoescape / mark_safe output side; there is no rule wiring a request value into django.template.Template() β€” you can search the community rule registry and confirm the gap. (Semgrep does carry SSTI rules for other stacks β€” Flask's render_template_string, for one β€” but not the Django Template() equivalent.)

Unlike Post 2, the tools miss it outright, which is the other half of the custom-rule trigger. A small custom Semgrep rule closes the gap. The logic is deliberately blunt and syntactic: match every Template() / from_string() call, then subtract the one safe shape β€” a call on a string literal the developer wrote themselves.

# rules/ssti.yaml  (abridged)
patterns:
  - pattern-either:                 # match a template compiled from any value…
      - pattern: Template($X)
      - pattern: $E.from_string($X)
  - pattern-not: Template("...")           # …but not one built from a string literal
  - pattern-not: $E.from_string("...")

$X is a metavariable β€” it matches whatever is passed to the call, a name or an expression. So pattern-either catches the call however the template is built, and the two pattern-not lines then drop the single case where that argument is a literal string. That one distinction β€” literal versus not β€” is the whole rule, and it maps cleanly onto the two views. The vulnerable view passes a variable (Template(tpl)): $X binds to tpl, no pattern-not matches, so it fires. The secure view's source is a literal (Template("{{ message }}")): the first pattern-not matches it exactly, so it is removed and the rule stays silent. It lands where it should:

semgrep --config rules/ssti.yaml labs/post_03_ssti/views_vulnerable.py   # 1 finding, line 33
semgrep --config rules/ssti.yaml labs/post_03_ssti/views_secure.py       # 0 findings

Because it is syntactic, it never traces where a non-literal argument came from. Semgrep does propagate constants, so a literal moved one line up β€” t = "{{ v }}"; Template(t) β€” is still recognised and stays silent, an ok case in the rule's semgrep --test fixture. What it cannot clear is a source that is safe for a reason outside the code: a template file your own repository ships, read with Path(...).read_text(), is a false positive, documented in the fixture as todook. In the lab it never bites: both views compile the template inline, where the rule is exact. It also matches only the call shapes it names: Template(t, engine=e), Template(template_string=t) and template.Template(t) all slip past Template($X). And on a Jinja2 backend it flags a call on a SandboxedEnvironment, such as sandbox.from_string(body), as loudly as the bug β€” it cannot see which environment the variable holds β€” along with any unrelated .from_string() method called on a variable, so each sandboxed call needs a reviewed # nosemgrep. And CI re-runs the fixture and the fire-on-vulnerable / silent-on-secure assert on every commit.

There is deliberately no DAST here, and the reason is the post's whole thesis. SSTI has capable command-line scanners β€” SSTImap, tplmap, Nuclei β€” but they confirm a hit through engine-specific plugins that make the engine evaluate a probe like {{7*7}} or raise that engine's own errors, and SSTImap's engine list, for one, has no Django entry. I did not run them against this lab; their SSTI plugins are not built to notice {{ flag }} walking the secret out the front door. A tailored check would, such as a Nuclei template that sends ?tpl={{ flag }} and matches the known secret, but that is tests.py over HTTP. Expression-probe DAST only becomes a capture worth having against an expression-evaluating engine, a Jinja2 backend, which this lab does not ship. The captured Bandit, Semgrep, and custom-rule runs are committed under scans/ so you can read what each reported; tests.py remains the runnable proof of the leak itself.


What I keep from this post: a template is a file you control, not a string a user sends you β€” so treat any Template() or from_string() call with a non-literal argument the way you'd treat cursor.execute(raw_sql), as a finding to justify or fix before it ships.

Post 4 moves from the template engine to the OS shell itself β€” subprocess and os.system, the shell=True trap, and how user input that reaches the OS command layer achieves RCE even more directly.

Further Reading

Next in this series β†’ Post 4: OS Command Injection: subprocess, os.system, and the Dangers of shell=True

← Back to the series