
Django CVE-2025-64459: Critical SQL Injection in the ORM Explained
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 AND, OR, 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:
- Dictionary expansion of user input
- Django’s acceptance of
_connector(and_negated) as keyword arguments toQand 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 usersIf 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)
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 objectsThis prevents
_connectorand_negatedfrom being passed straight from user input into query construction.Q-object connector validation
In
django/db/models/query_utils.py,Qnow 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
_connectorsomehow reachesQ, 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
ORinto 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).

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.

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.

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.

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_negated) in 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
ORchains where you expectedANDs, 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 unusualORproliferation. - 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(\\*\\*"andQ(**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
- Stop expanding untrusted dictionaries into ORM calls.
- Whitelist allowed fields and map each to known lookups.
- Validate & normalize using forms/serializers or explicit schemas before building queries.
- 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)

