Request an audit

// write-up

A ceiling on the wrong key: uncapped video frames in vLLM.

Seventy-four bytes of JSON took a vLLM API server's peak memory from 2,271 MiB to 13,629 MiB, and the frame-count ceiling that should have stopped it was sitting on a key the Qwen video samplers never read.

Published
Reading time
7 min
Illustration: a heavily barred gate stands shut while video frames flood through an open gap in the wall beside it.

A frame-count ceiling is the obvious control for a video decoder exposed to the network, and vLLM has one. CVE-2026-105758 is not about that ceiling being wrong. It is about the ceiling naming a key that two of the project’s stock video samplers never read. The Qwen2-VL and Qwen3-VL loaders size their work from max_frames and fps, both of which arrive in the request body, and nothing clamped either. Seventy-four extra bytes of JSON, posted to an endpoint that needs no credential, took the API server process from a 2,271 MiB high-water mark to 13,629 MiB.

The target

vLLM is an inference and serving engine for large language models; its OpenAI-compatible front end takes images and video inline in a chat message. Two mechanisms matter here. Requests carry media_io_kwargs (vllm/entrypoints/openai/chat_completion/protocol.py:366 on the chat model, vllm/entrypoints/serve/tokenize/protocol.py:100 on the tokenize model), a dictionary that overrides the engine’s media decoding options, keyed by modality. The per-model video sampler decides which frames of a clip get decoded. The two meet in one merged dictionary.

The work was done against main at b23433088 (13 September 2026), 629 commits past v0.29.0 (98dff2a81). Every path and line below is that tree.

Three keys, two samplers

The engine’s frame ceiling is num_frames, which defaults to 32 (vllm/multimodal/media/video.py:95). A request can override it: the request dictionary and the startup dictionary are combined per modality by merge_media_io_kwargs (vllm/multimodal/media/connector.py:127) and then by the video-specific merge_kwargs (vllm/multimodal/media/video.py:42), which drops startup-only keys and resolves the fps and num_frames interaction. A pull request proposing a hard server-side clamp on num_frames at exactly that boundary, #51969, has been open since 12 August 2026 and is still unmerged today.

None of that reaches the Qwen samplers, because they do not read num_frames at all. Qwen2VLVideoBackend says so in its own docstring, which heads the class as the Qwen2-VL and Qwen2.5-VL fps based backend (vllm/multimodal/video.py:409) and records that num_frames is ignored (vllm/multimodal/video.py:418). What it reads instead is max_frames (vllm/multimodal/video.py:436) and a target frame rate applied to the clip’s duration, total_frames_num / original_fps (vllm/multimodal/video.py:450). Qwen3VLVideoBackend is the same shape in four lines:

min_frames = kwargs.get("min_frames", 4)
max_frames = kwargs.get("max_frames", 768)
num_frames = int(total_frames_num / original_fps * fps)
num_frames = min(max(num_frames, min_frames), max_frames, total_frames_num)

kwargs there is the merged dictionary, so max_frames and fps are attacker values, and once they are raised the only surviving bound is total_frames_num (vllm/multimodal/video.py:377): every frame in the container the attacker supplied. Decoded cost is frames times pixels times three, and the frame count is the one factor with no server-side ceiling.

The remedy was already in the same file. GLMGAVideoBackend carries _MAX_FRAMES and _MAX_FPS as class constants (vllm/multimodal/video.py:799-800) and clamps both request values against them (vllm/multimodal/video.py:827, vllm/multimodal/video.py:829), added as an explicit security fix in 8b6de0eb9 on 4 September 2026. Qwen had neither.

Confirming the clamp did not reach it

A sampler reading an uncapped key proves nothing if the merge layer above it strips or clamps it first. So the comparison was run with the proposed num_frames clamp applied locally, both paths driven through the real merge chain at b23433088:

request media_io_kwargs.videomerged kwargsframes decodedpeak RSS
absentnone32706 MiB
{"video_backend":"opencv","num_frames":-1}num_frames: 3232, clamped706 MiB
{"video_backend":"qwen3_vl"}num_frames: 3260779 MiB
{"video_backend":"qwen3_vl","max_frames":1e9,"fps":1e6}max_frames: 1e9, fps: 1e6, num_frames: 329002,994 MiB

Row two is the control: the clamp does exactly what it claims for its own key. Row four is the finding: it writes num_frames: 32 into the dictionary, the Qwen sampler ignores it, and max_frames and fps pass through untouched.

Then the same thing over HTTP, against the in-tree GPU-less render server serving Qwen/Qwen2-VL-2B-Instruct, no API key configured and no Authorization header sent. The request is an ordinary chat payload carrying a data:video/mp4;base64 clip, plus one field:

"media_io_kwargs": {"video": {"max_frames": 1000000000, "fps": 1000000}}

That field, with its separator, is 74 bytes. Against a 1.43 MiB body it moved an unauthenticated POST /tokenize from HTTP 200 in 0.48 seconds returning 35,902 prompt tokens to HTTP 200 in 5.81 seconds returning 538,222, while the server’s VmHWM went from 2,271 MiB to 13,629 MiB. The token count rose 15.0x, matching the 60 to 900 frame ratio, and the baseline request, identical but for those 74 bytes, is the control in the same run.

Three limits on that run. There was no GPU here, so the HTTP figures come from the render server, not a full vllm serve; it runs the real request models, merge chain and /tokenize route, but the run was never repeated on CUDA. The amplification is a floor, since the only local codecs were mp4v and XVID. And total_frames_num is bounded by the clip an attacker can get in, so the claim is exhaustion of one process, not unbounded allocation.

Preconditions, stated plainly

  • a deployment serving a video-capable Qwen model, where the vulnerable sampler is the registered default
  • network reach to a route carrying media_io_kwargs, and no credential, because GUARDED_PREFIX covers only /v1, /v2, /inference and /cohere (vllm/entrypoints/serve/middleware/authenticate.py:11), leaving /tokenize unauthenticated even when an API key is set
  • a clip the attacker uploads, holding more frames than the sampler would otherwise select, and above 768 frames the default cap is cleared outright

That is the whole list, and the 256 MiB VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB cap (vllm/envs.py:78) does not cover a data: URI, which is how the measured run delivered the clip. The Rust front end is not affected: it rejects media_io_kwargs outright with “media_io_kwargs is not supported.” (rust/src/server/src/routes/openai/chat_completions/validate.rs:92). A request can also name a sampler with a video_backend key, since the registered backend applies only when the request did not (vllm/multimodal/media/connector.py:565), though on a Qwen deployment that is unnecessary.

What it scored

Moderate, 5.3, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L, CWE-770. Network reachable, no privileges, no user interaction, no confidentiality or integrity effect. vLLM rates denial of service Moderate by policy (SECURITY.md:41).

Disclosure

Reported 13 September 2026 at 21:18 UTC as a private GitHub Security Advisory. A fix pull request was open 49 minutes later, #56729, “[Security] Cap Qwen-VL video sampling knobs”, and it merged 13 hours after the report as ea723c81: ten lines added, four removed, one file. The fix is the GLMGA pattern applied to both Qwen classes, _MAX_FRAMES of 768 and _MAX_FPS of 30, with both request values clamped against them. The affected range >= 0.24.0, < 0.30.0 holds up against the tags: v0.23.0 has no Qwen3-VL backend, v0.24.0 reads max_frames unclamped, v0.29.0 still does, and v0.30.0 of 22 September 2026 is the first stable release containing ea723c81, which reached a tag four days earlier in v0.30.0rc1. The advisory was published as GHSA-x6mc-67gf-chw4 on 23 September 2026, and CVE-2026-105758 followed on 5 October 2026. Credited to 0xiviel / Eva Crystal (XSource Security).

The fix being public nine days before the advisory is policy, not a slip: vLLM develops Moderate and Low severity fixes as ordinary public pull requests with no embargo (SECURITY.md:52), on the reasoning that visibility accelerates adoption.

The pattern worth keeping

A guard is exactly as wide as the key it names. The ceiling here was real and correct, and it was written for num_frames, while two consumers of the same dictionary sized their allocations from max_frames and fps. None of that is visible from the guard’s own site. You see it only by listing the consumers.

So the question to ask of an input bound is not “is this value capped” but “which key does each consumer actually read, and is the cap on all of them”. The mechanical version is cheap: grep the key the guard clamps, grep every kwargs.get downstream of it, and compare the sets. Anything in the second set and not the first is an uncapped request value wearing the guard’s reputation.

And a per-class cap is a per-class promise: a bound expressed once, where the dictionary is merged, needs no enumeration of siblings.

Disclosure record

GHSA-x6mc-67gf-chw4 / CVE-2026-105758

vLLM: Qwen2-VL / Qwen3-VL video samplers bound on request-controlled max_frames, which the num_frames ceiling does not reach

Disclosure record

CVE-2026-105758
vLLM Qwen2-VL / Qwen3-VL video sampler memory exhaustion

Record page →

Same scrutiny, your code

Send the repository or product, the component you care about most, the build under review and the window you have in mind. A written scope and a fixed price come back before any work begins.

Request an audit →

← All write-ups

More write-ups

All write-ups →

Inside the Lenovo SMM escalation chain

Part 1 of 4. The Lenovo WmiBiosSetupSmm disclosure (CVE-2026-10587 to -10590), coordinated with Lenovo PSIRT under case LEN-213632 and published in advisory LEN-220440 on 14 July 2026. Most vulnerability write-ups live in the operating system, the…

Read write-up →

Validating the unvalidated: SMI input handling

Part 2 of 4: the Lenovo WmiBiosSetupSmm disclosure. Start with Part 1 for the architecture and the escalation chain. Part 1 named the target: WmiBiosSetupSmm, software SMI 0xDE, on the Lenovo Legion 5 15IRX10 (BIOS QNCN33WW v1.33, InsydeH2O). This part opens the handler and…

Read write-up →