Django CVE-2025-64459: Critical SQL Injection in the ORM Explained

Sakibul Ali Khan
Sakibul Ali Khan Research

Introduction

Django powers everything from seed-stage SaaS backends to Fortune-scale platforms, thanks to its batteries-included stack and mature ORM. That ORM is also where CVE-2025-64459 landed: a SQL injection bug triggered when internal query-control arguments leak into query construction. Specifically, if an application expands user-controlled dictionaries into QuerySet.filter()exclude()get(), or Q() calls, an attacker can slip in the _connector keyword and change the logic the ORM emits to the database. In practical apps, this shows up in common filtering patterns, often on unauthenticated endpoints—so the blast radius includes data theft and auth bypass. Django assigned this a high severity under its policy and shipped fixes on November 5, 2025. (Django Project)

Vulnerability Overview

How Django normally builds queries

Under normal conditions, Django’s ORM turns keyword arguments into SQL in a very controlled way:

# Intended, safe-style usage
User.objects.filter(
    is_active=True,
    email__icontains="example.com",
)

Each keyword maps to a field lookup. Django takes care of escaping values and building a WHERE clause like:

WHERE "user"."is_active" = TRUE
  AND "user"."email" ILIKE '%example.com%'

For more complex logic, developers use Q objects to build boolean expression trees:

from django.db.models import Q
qs = User.objects.filter(
    Q(is_active=True) & (Q(role="admin") | Q(role="staff"))
)

Internally, Q keeps a list of children and a connector that tells Django whether to join them with ANDOR, or XOR. There is also a _negated flag to invert a subtree.(Endor Labs)

These knobs – _connector and _negated – are internal control parameters that are not meant to be controlled by end users.

Where things went wrong: _connector + dictionary expansion

The problem appears when applications combine two things:

  1. Dictionary expansion of user input
  2. Django’s acceptance of _connector (and _negated) as keyword arguments to Q and QuerySet methods

A very common pattern in real Django apps is “dynamic filtering”:

# ⚠️ Risky pattern if request.GET is not validated
def search_users(request):
    filters = request.GET.dict()      # user-controlled
    users = User.objects.filter(**filters)
    return users

If a user sends:

?email__icontains=@corp.example&_connector=...

then before the patch, Django would accept _connector as a control parameter for how conditions are combined inside the query tree. That means user input is no longer just data — it can start to influence the structure of the SQL that gets generated.(Endor Labs)

The same thing happens if you build a Q object from a raw dict:

from django.db.models import Q
def post_list(request):
    query_params = dict(request.GET.items())  # user-controlled
    q_filter = Q(**query_params)              # also dangerous
    posts = Post.objects.filter(q_filter)
    ...

With a crafted _connector value, an attacker can force parts of the query tree to be joined in unexpected ways, and in some cases, smuggle SQL fragments into the connector itself.(shivasurya.me)

What Django changed in the patch

Django’s fix operates on two layers:(Endor Labs)

  1. QuerySet-level validation

    In django/db/models/query.py, Django now maintains a set of prohibited keyword arguments and rejects them early:

    PROHIBITED_FILTER_KWARGS = frozenset(["_connector", "_negated"])
    def _filter_or_exclude_inplace(self, negate, args, kwargs):
        if invalid_kwargs := PROHIBITED_FILTER_KWARGS.intersection(kwargs):
            # raises TypeError if internal kwargs appear in user input
            ...
        # then proceeds to add Q objects

    This prevents _connector and _negated from being passed straight from user input into query construction.

  2. Q-object connector validation

    In django/db/models/query_utils.pyQ now validates the connector more strictly:

    class Q(tree.Node):
        connectors = (None, AND, OR, XOR)
        def __init__(self, *args, _connector=None, _negated=False, **kwargs):
            if _connector not in self.connectors:
                # raises ValueError for arbitrary connector values
                ...
            ...

    So even if _connector somehow reaches Q, its value is forced to be one of a small set of expected tokens instead of arbitrary text.

Together, these changes stop user input from controlling query structure via _connector or _negated.

Threat model & real-world impact

What an attacker needs

  • Network access to the web app (internet-exposed endpoints are prime).
  • An endpoint that takes request parameters and expands them into ORM calls (typical in search, reporting, admin grids, and API filters).

Why this matters

  • Data exfiltration: weaken owner/tenant filters and return everything (personally identifiable info, tokens, secrets).
  • Auth/authorization bypass: force an OR into a login/permission check or invert a predicate, turning a deny into allow.
  • Privilege escalation & pivot: once inside, query shaping can open paths into higher-trust code paths.
  • Severity: CISA’s ADP scored it CVSS v3.1 9.1 (CRITICAL) (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N). Django labels it high in their security policy. The pattern is common, making this widely relevant. (NVD)

Exploitation Walk-Through

Dive in the issue in a controlled TryHackMe room tailored to CVE-2025-64459. The lab intentionally expands query parameters into a Q(**query_params) and then into a filter(). This mirrors code we keep seeing in real codebases. (TryHackMe).

Django: CVE-2025-64459 | Hidden Investigations

These examples are provided so we can experiment and see how query parameters influence Django web applications with respect to this vulnerability.

  • ?title=Some Title - to filter by title
  • ?author=Some Author - to filter by author
  • ?title__icontains=draft - use Django lookup expressions (like __icontains)
  • ?created_date__year=2024 - filter by parts of the date

Identifying All Posts

To display posts authored by a particular individual such as all posts written by “API Specialist”. We can append the parameter ?author=API%20Specialist to the URL. For example: http://10.48.128.63:8000/poc/?author=API%20Specialist. In this instance, the query returned one post.

Django: CVE-2025-64459 | Hidden Investigations

Let's try same search for "DevOps Engineer" and we can append the parameter ?author=DevOps%20Engineer to the URL. For example: http://10.48.128.63:8000/poc/?author=DevOps%20Engineer. In this instance, the query returned no post.

Django: CVE-2025-64459 | Hidden Investigations

We can exploit this vulnerability by getting all the posts saved within the database. This can be achieved by manipulating the _connector parameter to inject an SQL condition, allowing us to retrieve all the posts. One working example is to set _connector to OR 1=1 OR, which will result in a match for all posts by all authors, including non-public posts. As one can tell, this falls under data exfiltration. The constructed URL is http://10.48.128.63:8000/poc/?author=DevOps%20Engineer&_connector=OR%201=1%20OR.

Django: CVE-2025-64459 | Hidden Investigations

Because unpatched Django didn’t strictly validate _connector in this pathway, a sufficiently crafted value could be used to reshape the final SQL in ways the developer never intended.(shivasurya.me)

Experiments were done inside isolated lab environments and the TryHackMe room set up specifically for this CVE.

Detection & hunting

What to watch:

  • HTTP edge: Look for _connector (and _negatedin query strings or POST bodies. Even a single hit is suspicious. WAF/IDS rules can alert or block on these keys for Django apps. 
  • App logs: If you log filter construction or enable ORM debug logging in lower environments, hunt for unexpected OR chains where you expected ANDs, or for errors involving invalid connectors. (Django Project)
  • DB side: Sudden spikes in tautological predicates, broader result sets, or queries that ignore tenant/owner columns. Consider sampling SQL text (e.g., Postgres pg_stat_statements) for unusual OR proliferation.
  • Threat intel & advisories: Track distro notices and vendor write-ups; they provide patch cut-offs and context you can match against your asset inventory. (Ubuntu)

Quick hunting ideas:

  • Reverse proxy/WAF grep: "_connector=" and "_negated=" in recent access logs.
  • SAST/grep in repos: grep -R "\.filter(\\*\\*" and Q(** to find endpoints that expand dictionaries.
  • Runtime sampling: Temporarily add app middleware to log & reject requests carrying internal keys (in QA/staging first).

Remediation & hardening

Patch first

Upgrade to Django 5.2.8, 5.1.14, or 4.2.26 (or newer). The security releases land guards that reject internal control kwargs flowing from user input. Distros and advisories all point to these cut-offs. (Django Project)

Code-level fixes

  1. Stop expanding untrusted dictionaries into ORM calls.
  2. Whitelist allowed fields and map each to known lookups.
  3. Validate & normalize using forms/serializers or explicit schemas before building queries.
  4. If you truly need dynamic filters, translate user parameters to safe field names/lookup expressions and keep the join logic internal.

Insecure (for contrast):

# ❌ Do not do this with user input
filters = request.GET.dict()
qs = Order.objects.filter(**filters)

Safer mapping with a whitelist:

# ✅ Explicit field allow-list and lookup mapping
ALLOWED_FILTERS = {
    "status": "status",
    "customer_id": "customer_id",
    "created_before": "created_at__lt",
    "created_after": "created_at__gt",
}
def safe_filters_from(params: dict) -> dict:
    out = {}
    for key, value in params.items():
        if key in ("_connector", "_negated"):  # belt & suspenders
            continue
        if key in ALLOWED_FILTERS:
            out[ALLOWED_FILTERS[key]] = value
    return out
def search(request):
    params = safe_filters_from(request.GET)
    return Order.objects.filter(**params)

If you use Q objects, keep control flags internal:

# ✅ Build Q explicitly; never pass user _connector/_negated
q = Q()
if "status" in params:       q &= Q(status=params["status"])
if "customer_id" in params:  q &= Q(customer_id=params["customer_id"])
return Order.objects.filter(q)

These patterns align with the vendor guidance and community write-ups that emphasize upgrading and rejecting internal kwargs (_connector, and analyses also highlight _negated). (Django Project)

Broader takeaways

  • ORM ≠ immunity. Abstractions help, but internal control parameters exposed to user input is a recurring failure mode across ecosystems.
  • Dictionary expansion is dangerous at trust boundaries. Convenience patterns (**request.GET) turn into policy escape hatches.
  • Threat model join logic, not just values. We spend time sanitizing values; we must also lock down how conditions are combined.
  • Defend in depth: static checks to ban ** into ORM calls, request-time guards for internal keys, query sampling at the DB, and continuous dependency hygiene.

Conclusion

In short, CVE-2025-64459 shows how quickly Django’s ORM can become an attack surface when internal control flags like _connector and _negated are allowed to mix with user input via patterns like filter(**request.GET) or Q(**params). A single crafted _connector is enough to turn a simple filter into full-table data exfiltration. The path forward is clear: patch to the fixed Django versions, hunt for any use of untrusted dictionary expansion into ORM calls, and refactor toward explicit allow-lists and server-controlled query logic. If we treat query structure as part of the threat model, and not just the values, then issues like this remain contained incidents instead of full-blown data breaches.

Appendix —> Sources & further reading

  • Django security release (Nov 5, 2025) — details, fixed versions, severity. (Django Project)
  • Django 4.2.26 / 5.2.8 notes — CVE text and fixes. (Django Project)
  • NVD entry — version cut-offs, credit, and CVSS 9.1 (CISA ADP). (NVD)
  • Wiz / Endor Labs / CyCognito analyses — risk framing and common patterns. (wiz.io)
  • Ubuntu USN & CVE page — distro tracking. (Ubuntu)
  • Community write-up (Shivasurya) — examples and fix discussion. (shivasurya.me)