Django QuerySet/Q Object SQL Injection via `_connector` Kwarg (CVE-2025-64459)
by Joshua Lent (joshualent) · 2026-07-06
- Severity
- Critical
- CVE
- CVE-2025-64459
- Category
- web
- Affected product
- Django ORM (QuerySet.filter() / Q object construction)
- Affected versions
- Multiple Django versions prior to the official security-patch release (per source repository); this demo pins django==5.2.7 to reproduce the issue
- Disclosed
- 2026-07-06
- Patch status
- patched
Archive entry
intelseclab/poc-archiveMetadata
| Field | Value |
|---|---|
| Date Added | 2026-07-06 |
| Last Updated | 2026-07-06 |
| Author / Researcher | Joshua Lent (joshualent) |
| CVE / Advisory | CVE-2025-64459 |
| Category | web |
| Severity | Critical |
| CVSS Score | 9.1 (per NVD) |
| Status | Weaponized |
| Tags | django, python, sql-injection, queryset, q-object, orm, cwe-89, data-exposure, sqlite |
| Related | N/A |
Affected Target
| Field | Value |
|---|---|
| Software / System | Django ORM (QuerySet.filter() / Q object construction) |
| Versions Affected | Multiple Django versions prior to the official security-patch release (per source repository); this demo pins django==5.2.7 to reproduce the issue |
| Language / Platform | Python / Django 5.2, demo blog app served via manage.py runserver or Gunicorn |
| Authentication Required | No |
| Network Access Required | Yes (standard HTTP access to the vulnerable route) |
Summary
This is a SQL injection vulnerability in Django’s QuerySet/Q object construction: when application code builds a Q(**kwargs) filter directly from user-controlled input (such as a raw request.GET QueryDict) without allow-listing keys, an attacker can supply the reserved _connector (and _negated) keyword that Django’s Q constructor uses internally to choose the raw SQL boolean join operator (AND/OR) between conditions. Because that value is spliced into the compiled SQL as a literal keyword rather than being validated against AND/OR/XOR, an attacker-supplied string is inserted verbatim into the WHERE clause. The bundled demo app is a small Django blog whose post_list view builds its filter dict straight from request.GET, blocking only a handful of named fields (status, is_archived, id, pk) — leaving _connector open. Visiting the home route with ?_connector=OR 1=1 OR turns the intended “published, non-archived posts only” filter into an always-true condition, exposing draft and archived posts that should not be publicly visible, and demonstrates a general primitive for SQL fragment injection through the ORM.
Vulnerability Details
Root Cause
posts/views.py builds the filter dict directly from request.GET, only excluding a short blocklist of field names, then passes it straight into Q(**query_params):
| |
Django’s Q.__init__(*args, _connector=None, _negated=False, **kwargs) accepts _connector/_negated as special keys controlling how conditions are logically joined/negated when the Q tree is compiled to SQL. Because the view’s blocklist only covers ordinary model field names, an attacker can pass _connector as a query-string parameter, and its value is used as the literal join keyword between WHERE conditions rather than being validated as one of the safe AND/OR/XOR tokens — allowing arbitrary SQL fragments to be spliced into the compiled query.
Attack Vector
- The vulnerable view constructs a
Qfilter from unsanitizedrequest.GETdata, blocking only a small, explicit list of field names rather than allow-listing expected keys. - Django’s
Q(**kwargs)constructor treats_connector(and_negated/_prefix) as internal control parameters rather than ordinary filter fields, and does not validate_connectoragainst the safe set of boolean keywords when it originates from arbitrary kwargs. - Attacker requests the home route with a crafted query string:
?_connector=OR 1=1 OR(URL-encoded). - The supplied string is used verbatim as the SQL boolean connector joining the compiled
WHEREconditions, turning the intended... AND status='published' AND is_archived=falsefilter into a condition that is always true (or otherwise attacker-controlled). - The response returns rows the application intended to hide — in this demo, draft and archived blog posts — demonstrating data exposure; more elaborate
_connectorpayloads could extend this into broader boolean-based SQL injection.
Impact
- Bypass of application-level row filtering (exposure of draft/archived/private records).
- General SQL-fragment injection primitive through the Django ORM wherever request-controlled dicts are passed as
Q/filter(**kwargs)arguments without allow-listing, with potential for further boolean-based data exfiltration depending on the surrounding schema and query.
Environment / Lab Setup
- Python 3.13+, Django 5.2.7 (pinned in pyproject.toml)
- Dependency management via `uv` (uv.lock included), or pip install of
pyproject.toml dependencies directly
- SQLite database (db.sqlite3 is intentionally tracked in the repo to
pre-populate demo posts, including draft/archived ones)
- .env (also intentionally tracked) supplies SECRET_KEY and DEBUG=True for
local/demo purposes only
Proof of Concept
PoC Script
This is a full runnable Django application — see
manage.py,django_project/, andposts/(particularlyposts/views.py) in this folder.
| |
The repository’s author also hosts a live instance demonstrating the same payload: https://cve.hyperf.app/?_connector=OR%201=1%20OR.
Detection & Indicators of Compromise
- Web/WAF/access logs showing query strings or POST bodies containing
"_connector=", "_negated=", or "_prefix=" combined with SQL keywords
(OR, AND, UNION, --, 1=1) targeting Django application endpoints
- Django SQL query logs (DEBUG=True / django-debug-toolbar) showing WHERE
clauses with unexpected boolean literals or malformed AND/OR tokens
- Anonymous/unauthenticated requests returning records that should be
filtered out (drafts, archived items, soft-deleted rows)
Signs of compromise:
- Spikes in requests containing
_connector/_negatedquery parameters against endpoints that build ORM filters from request data. - Draft/archived/private content appearing in public listing responses.
- Anomalous database read volume correlated with repeated near-identical requests differing only in a trailing boolean expression (classic blind-SQLi probing pattern).
Remediation
| Action | Detail |
|---|---|
| Primary fix | Upgrade to the Django security release that validates/rejects unexpected _connector/_negated/_prefix values passed into Q()/QuerySet.filter(**kwargs) (see Django’s official security advisory for CVE-2025-64459). |
| Interim mitigation | Never pass a raw request.GET/request.POST QueryDict (or any user-controlled dict) directly as **kwargs into Q() or .filter(). Explicitly allow-list the exact field names an endpoint should accept, and reject/strip any key beginning with _ before constructing ORM filters. |
References
Notes
Mirrored from https://github.com/joshualent/django-cve-2025-64459 on 2026-07-06. This is a fully runnable Django demo application (not a thin script) reproducing the QuerySet/Q object SQL injection end-to-end, including a pre-seeded SQLite database with draft/archived posts to demonstrate the data exposure. db.sqlite3 and .env are intentionally committed by the upstream author to make the demo runnable out of the box — treat the tracked SECRET_KEY/DEBUG=True values as demo-only, not for production reuse.
| |