{"id":"GHSA-pq5c-rjhq-qp7p","summary":"vLLM: Denial of Service via Unbounded Frame Count in video/jpeg Base64 Processing","details":"## Summary\n\nThe `VideoMediaIO.load_base64()` method at `vllm/multimodal/media/video.py:51-62` splits `video/jpeg` data URLs by comma to extract individual JPEG frames, but does not enforce a frame count limit. The `num_frames` parameter (default: 32), which is enforced by the `load_bytes()` code path at line 47-48, is completely bypassed in the `video/jpeg` base64 path. An attacker can send a single API request containing thousands of comma-separated base64-encoded JPEG frames, causing the server to decode all frames into memory and crash with OOM.\n\n## Details\n\n### Vulnerable code\n\n```python\n# video.py:51-62\ndef load_base64(self, media_type: str, data: str) -\u003e tuple[npt.NDArray, dict[str, Any]]:\n    if media_type.lower() == \"video/jpeg\":\n        load_frame = partial(self.image_io.load_base64, \"image/jpeg\")\n        return np.stack(\n            [np.asarray(load_frame(frame_data)) for frame_data in data.split(\",\")]\n            #                                                       ^^^^^^^^^^\n            # Unbounded split — no frame count limit\n        ), {}\n    return self.load_bytes(base64.b64decode(data))\n```\n\nThe `load_bytes()` path (line 47-48) properly delegates to a video loader that respects `self.num_frames` (default 32). The `load_base64(\"video/jpeg\", ...)` path bypasses this limit entirely — `data.split(\",\")` produces an unbounded list and every frame is decoded into a numpy array.\n\n### video/jpeg is part of vLLM's public API\n\n`video/jpeg` is a vLLM-specific MIME type, not IANA-registered. However it is part of the public API surface:\n\n- `encode_video_url()` at `vllm/multimodal/utils.py:96-108` generates `data:video/jpeg;base64,...` URLs\n- Official test suites at `tests/entrypoints/openai/test_video.py:62` and `tests/entrypoints/test_chat_utils.py:153` both use this format\n\n### Memory amplification\n\nEach JPEG frame decodes to a full numpy array. For 640x480 RGB images, each frame is ~921 KB decoded. 5000 frames = ~4.6 GB. `np.stack()` then creates an additional copy. The compressed JPEG payload is small (~100 KB for 5000 frames) but decompresses to gigabytes.\n\n### Data flow\n\n```\nPOST /v1/chat/completions\n  → chat_utils.py:1434   video_url type → mm_parser.parse_video()\n  → chat_utils.py:872    parse_video() → self._connector.fetch_video()\n  → connector.py:295     fetch_video() → load_from_url(url, self.video_io)\n  → connector.py:91      _load_data_url(): url_spec.path.split(\",\", 1)\n                          → media_type = \"video/jpeg\"\n                          → data = \"\u003cframe1\u003e,\u003cframe2\u003e,...,\u003cframe10000\u003e\"\n  → connector.py:100     media_io.load_base64(\"video/jpeg\", data)\n  → video.py:54          data.split(\",\")  ← UNBOUNDED\n  → video.py:55-57       all frames decoded into numpy arrays\n  → video.py:56          np.stack([...])  ← massive combined array → OOM\n```\n\n`connector.py:91` uses `split(\",\", 1)` which splits on only the first comma. All remaining commas stay in `data` and are later split by `video.py:54`.\n\n### Comparison with existing protections\n\n| Code Path | Frame Limit | File |\n|-----------|-------------|------|\n| `load_bytes()` (binary video) | Yes — `num_frames` (default 32) | video.py:46-49 |\n| `load_base64(\"video/jpeg\", ...)` | No — unlimited `data.split(\",\")` | video.py:51-62 |","aliases":["CVE-2026-34755","PYSEC-2026-144"],"modified":"2026-09-10T03:51:02.464620651Z","published":"2026-04-03T21:51:35Z","database_specific":{"cwe_ids":["CWE-770"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-04-03T21:51:35Z","nvd_published_at":"2026-04-06T16:16:36Z"},"references":[{"type":"WEB","url":"https://github.com/vllm-project/vllm/security/advisories/GHSA-pq5c-rjhq-qp7p"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-34755"},{"type":"WEB","url":"https://github.com/vllm-project/vllm/pull/38636"},{"type":"WEB","url":"https://github.com/vllm-project/vllm/commit/58ee61422169ce17e08248f8efa1e9df434fe395"},{"type":"WEB","url":"https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-34755.json"},{"type":"PACKAGE","url":"https://github.com/vllm-project/vllm"},{"type":"WEB","url":"https://github.com/pypa/advisory-database/tree/main/vulns/vllm/PYSEC-2026-144.yaml"},{"type":"WEB","url":"https://bugzilla.redhat.com/show_bug.cgi?id=2455403"},{"type":"WEB","url":"https://access.redhat.com/security/cve/CVE-2026-34755"},{"type":"WEB","url":"https://access.redhat.com/errata/RHSA-2026:59151"},{"type":"WEB","url":"https://access.redhat.com/errata/RHSA-2026:59144"},{"type":"WEB","url":"https://access.redhat.com/errata/RHSA-2026:57390"},{"type":"WEB","url":"https://access.redhat.com/errata/RHSA-2026:57389"},{"type":"WEB","url":"https://access.redhat.com/errata/RHSA-2026:57387"},{"type":"WEB","url":"https://access.redhat.com/errata/RHSA-2026:57380"},{"type":"WEB","url":"https://access.redhat.com/errata/RHSA-2026:36006"},{"type":"WEB","url":"https://access.redhat.com/errata/RHSA-2026:36005"}],"affected":[{"package":{"name":"vllm","ecosystem":"PyPI","purl":"pkg:pypi/vllm"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0.7.0"},{"fixed":"0.19.0"}]}],"versions":["0.10.0","0.10.1","0.10.1.1","0.10.2","0.11.0","0.11.1","0.11.2","0.12.0","0.13.0","0.14.0","0.14.1","0.15.0","0.15.1","0.16.0","0.17.0","0.17.1","0.18.0","0.18.1","0.7.0","0.7.1","0.7.2","0.7.3","0.8.0","0.8.1","0.8.2","0.8.3","0.8.4","0.8.5","0.8.5.post1","0.9.0","0.9.0.1","0.9.1","0.9.2"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/04/GHSA-pq5c-rjhq-qp7p/GHSA-pq5c-rjhq-qp7p.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H"}]}