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.
| 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:
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.
docker compose für die native arm64-Architektur
bauen (Standard). Das Erzwingen von --platform linux/amd64 führt den
Container unter Emulation aus, wo der abbrechende Prozess hängt statt
zu beenden und der Absturz schwerer zu beobachten ist.Nur für Bildungszwecke und autorisierte Sicherheitstests. Führen Sie es gegen das Labor in diesem Repository oder gegen Systeme aus, für die Sie eine ausdrückliche Testgenehmigung haben.