vLLM before 0.29.0 passes uploaded audio to the PyAV/FFmpeg av.open() fallback with only a file-size check, exposing FFmpeg's IAMF parser heap overflow. Authenticated remote attackers can repeatedly upload malformed IAMF containers to /v1/audio/transcriptions, corrupting the heap and crashing the API process.
A security flaw has been discovered in vllm-project vLLM up to 0.31.0. This impacts the function gettokenbincountsandmask of the file vllm/modelexecutor/layers/utils.py of the component Penalty Handler. Performing a manipulation results in denial of service. Remote exploitation of the attack is possible. The exploit has been released to the public and may be used for attacks. The project was informed of the problem early through an issue report but has not responded yet.
Summary
The OpenAI-compatible chat endpoint accepts request-level video loader options through mediaiokwargs. A caller can select the GLMGA sampler and provide large fps and maxframes values. GLMGA first constructs an attacker-sized Python index list and then deduplicates it, even when the supplied video contains only a few frames. This work occurs in the shared media-loading executor before frame reads, allowing a tiny valid video and compact JSON options to consume disproportionate CPU time and memory and delay unrelated media requests.
The demonstrated impact is partial denial of service. No memory corruption, data disclosure, or code execution is claimed.
Affected configuration
The server must expose chat completions for a video-capable model and accept request-level mediaiokwargs. The model does not need to use GLMGA by default because the request value overrides the model's loader mapping. The proof of concept uses an inline two-frame video and explicitly selects the OpenCV decoder, so no external media host or GPU decoder is required.
The request is reachable without vLLM credentials when no API key is configured. With API-key authentication enabled, any caller holding a valid key can reach the same path.
Attack surface
A remote caller submits a valid chat-completion request with a small video and the following request-level options:
json { "mediaiokwargs": { "video": { "videobackend": "glmga", "backend": "opencv", "fps": 500000, "maxframes": 500000 } } }
Increasing both numeric values increases temporary list construction and deduplication work even when the final decoded frame set remains unchanged.
Root cause
1. ChatCompletionRequest exposes mediaiokwargs as request-controlled nested values: protocol.py#L365-L371. 2. The request options are carried into chat parameters without a numeric work bound on GLMGA's fps or maxframes: protocol.py#L571-L599. 3. Media options are merged so request values override configured defaults, including videobackend: connector.py#L577-L605. 4. The connector selects a registered video loader and runs media loading through the shared executor: video.py#L28-L66 and connector.py#L44-L47. 5. GLMGA calculates extractt = min(int(duration fps), maxframes), builds a list with that many entries, and deduplicates it before decoding frames: video.py#L667-L740. 6. The OpenCV decoder receives the already-deduplicated indices, so a two-frame file can trigger a large intermediate allocation while producing only two decoded frames: opencv.py#L28-L50.
The missing invariant is a strict upper bound on sampling work before the index list is constructed. Bounding decoded output after deduplication does not bound the vulnerable intermediate computation.
Suggested remediation
Validate request-level video sampling options before dispatching work to the media executor. Enforce conservative absolute limits for fps, maxframes, and especially the computed candidate count. Reject non-finite, negative, or otherwise invalid numeric values.
Avoid constructing O(extractt) intermediate Python lists. Generate bounded unique frame indices directly from the source frame count and output-frame limit. Apply the limit before list construction and before shared executor submission. Add tests showing that extreme request values are rejected or consume constant memory for a fixed output-frame count.
Workarounds
- Remove or filter request-level videobackend, fps, and maxframes options at the gateway. - Do not allow untrusted callers to select GLMGA. - Apply authentication, rate limiting, request concurrency limits, and process memory isolation. - Use a separate constrained media-loading worker pool where operationally feasible. - Existing media byte, pixel, or decoded-frame limits do not necessarily bound this pre-decode candidate-index allocation.
Related advisory
GHSA-cqm8-jxg6-fqfq concerns partial denial of service in the DeepStream video backend through backend confusion and insufficient pixel guarding. This report concerns a different CPU-side GLMGA algorithm: request-controlled sampling values create a large redundant index list before the OpenCV decoder reads a fixed, tiny frame set. The vulnerable backend, resource sink, and required remediation differ.
vLLM is an inference and serving engine for large language models. Prior to 0.30.0, the Rust frontend's trackhttpmetrics middleware records the raw HTTP method token as a Prometheus label for requests reaching registered routes. An unauthenticated attacker can send unique arbitrary method tokens to unguarded routes such as /tokenize, causing Prometheus's Family::getorcreate function to permanently create counter and histogram label sets. Those label sets increase process memory usage and enlarge the /metrics response until the service or monitoring path is exhausted. This issue is fixed in version 0.30.0.
Summary
An unauthenticated remote attacker can exhaust the memory of the vLLM API-server process by raising the request-level mediaiokwargs.video.maxframes and fps knobs on any deployment serving a Qwen2-VL or Qwen3-VL model. 74 extra bytes of JSON took the server's peak RSS from 2 271 MiB to 13 629 MiB over unauthenticated POST /tokenize.
The numframes ceiling reported in GHSA-vxqj-p4gw-9h4c and fixed by open PR #51969 does not reach this path: the Qwen samplers do not read numframes at all. The same knobs were already capped upstream for GLMGAVideoBackend as an accepted security fix in 8b6de0eb9 (PR #54935, merged 2026-09-04); that cap never reached Qwen.
Details
Relationship to GHSA-vxqj-p4gw-9h4c and PR #51969 (read this first)
GHSA-vxqj-p4gw-9h4c reported that request-level mediaiokwargs.video.numframes overrides the engine frame-count ceiling, and open PR #51969 fixes it by clamping numframes inside VideoMediaIO.mergekwargs.
That clamp does not reach the Qwen samplers. Qwen2VLVideoBackend and Qwen3VLVideoBackend do not read numframes at all — Qwen2VLVideoBackend's own docstring says so ("numframes is ignored (fps-driven, like the Qwen3-VL loader)"). They bound on maxframes, read from the same merged dict with no ceiling:
python vllm/multimodal/video.py — Qwen3VLVideoBackend.computeframesindextosample minframes = kwargs.get("minframes", 4) maxframes = kwargs.get("maxframes", 768) numframes = int(totalframesnum / originalfps fps) numframes = min(max(numframes, minframes), maxframes, totalframesnum)
With maxframes raised from the request, the only remaining bound is totalframesnum — every frame in the container.
I applied PR #51969's patch locally and re-ran both paths through the real merge layer (mergemediaiokwargs → VideoMediaIO.mergekwargs → MediaConnector), against main @ b23433088:
| request mediaiokwargs.video | merged kwargs after #51969 | frames decoded | peak RSS | |---|---|---|---| | (absent) | None | 32 | 706 MiB | | {"videobackend":"opencv","numframes":-1} | {…,"numframes":32} | 32 — fixed | 706 MiB | | {"videobackend":"qwen3vl"} | {…,"numframes":32} | 60 | 779 MiB | | {"videobackend":"qwen3vl","maxframes":1e9,"fps":1e6} | {…,"maxframes":1000000000,"fps":1000000,"numframes":32} | 900 — survives | 2 994 MiB |
#51969 does exactly what it claims for numframes; the clamp writes numframes: 32 into the merged dict and the Qwen sampler ignores it, while maxframes and fps pass through untouched.
The codebase already has the fix pattern, on other backends
This is not a new control being proposed. Commit 8b6de0eb9 — "[Security] Cap GLMGA video sampling to prevent request-driven resource exhaustion" (PR #54935, merged 2026-09-04, same author as #51969) — caps precisely these two knobs for GLMGAVideoBackend:
python MAXFRAMES: ClassVar[int] = 640 MAXFPS: ClassVar[int] = 30 ... targetfps = min(target.fps, cls.MAXFPS) maxframes = min(kwargs.get("maxframes", cls.MAXFRAMES), cls.MAXFRAMES)
Its description states the root cause as "both targetfps and maxframes are controllable via request-level mediaiokwargs", and says it follows "the pattern established by GLM46VVideoBackend" (which caps via MAXFRAMECOUNTDYNAMIC = 640 and MAXDURATION = 2400).
So two backends cap request-controlled fps/maxframes as an accepted security measure. Qwen2VLVideoBackend and Qwen3VLVideoBackend — the most widely deployed video models on vLLM — cap neither. In GLMGA the uncapped knobs sized an intermediate index list; in the Qwen samplers they size the decoded frame buffer, which is larger by the per-frame pixel count.
Reachability — default configuration, no authentication
mediaiokwargs is a request body field on ChatCompletionRequest and is carried by /v1/chat/completions, /v1/embeddings, /v1/responses, /tokenize and /invocations. No flag gates it. apikey defaults to None (vllm/entrypoints/launchers/cliargs.py), and AuthenticationMiddleware is installed only when a key is configured — a default vllm serve is entirely unauthenticated. Even with --api-key set, GUARDEDPREFIX = ("/v1", "/v2", "/inference", "/cohere") (vllm/entrypoints/serve/middleware/authenticate.py:11), so /invocations — which validates the same ChatCompletionRequest body — and /tokenize — which performs full media ingestion — remain unauthenticated. MediaConnector.fetchvideo applies the model's registered sampler only when the request did not name one (if "videobackend" not in videoiokwargs), so videobackend: "qwen3vl" is selectable on any deployment; request-level selection of stock sampler subclasses is already established as reachable by GHSA-j682-9xp5-rrf3. On a Qwen deployment no videobackend key is needed at all. Default-on for any video-capable Qwen model.
The Rust frontend is not affected. rust/src/server/src/routes/openai/chatcompletions/validate.rs:90 rejects mediaiokwargs with "mediaiokwargs is not supported." No second front is needed.
Impact
Unauthenticated remote denial of service by memory exhaustion of the API-server process. The decode runs in the frontend during chat parsing, before scheduling or admission control, so every tenant on the instance is affected. --limit-mm-per-prompt does not apply — it bounds media items, not frames within an item. CWE-770 / CWE-400.
Measured over unauthenticated HTTP: 1.43 MiB request body, 74 extra bytes of JSON, server peak RSS 2 271 → 13 629 MiB. In-process, the same request takes the decode from 60 to 900 frames. The frame count is bounded only by totalframesnum, which is the attacker's choice of video, and decoded bytes are frames × H × W × 3.
Context, measured on the numframes path (GHSA-vxqj-p4gw-9h4c's path), not this one — these figures show what an unbounded frame count costs once the source video is chosen for it, and they transfer to this path because both converge on the same readframesnorecovery allocation:
5.74 MiB request body → 9.27 GiB decoded, 9.73 GiB of new resident memory (1 736×). 117.6 MiB video/jpeg payload → process OOM-killed: Out of memory: Killed process 182667 (python) total-vm:38665704kB, anon-rss:21121616kB
The two were not re-run at the larger sizes through the Qwen sampler; the 900-frame figure above is what I measured on this path.
Suggested fix
Extend the ceiling to the sampler-side knobs rather than clamping the single numframes key. Two options, either acceptable:
1. Per-backend caps, matching 8b6de0eb9. Give Qwen2VLVideoBackend and Qwen3VLVideoBackend the MAXFRAMES / MAXFPS treatment already applied to GLMGAVideoBackend, so kwargs.get("maxframes", …) and target.fps are clamped to class constants. Smallest change; consistent with the accepted precedent. It leaves Glm5NextVideoBackend, Molmo2VideoBackend, NemotronVLVideoBackend, DynamicVideoBackend and OpenCVDynamicOpenPanguVideoBackend to be audited one by one, which is the current trajectory (#55727, #56207, #56390). 2. Strip the frame-count knobs at the merge boundary. In VideoMediaIO.mergekwargs, drop maxframes / minframes / fps from runtimekwargs the way hwdecoders, poolsize, device and unconfigured GPU backends are already dropped there. That treats the whole frame-count family as startup-only in one place and is robust to future sampler subclasses, at the cost of removing a request-level knob some users may rely on. A clamp-don't-strip variant (request may lower, never raise) preserves the feature.
Option 2 composes with #51969 and needs no per-backend audit; I would favour it, but option 1 is the more conservative change and matches what has already been merged.
Affected versions
>= 0.24.0, through v0.29.1rc0 and main @ b23433088.
Lower bound established by probing release tags through the GitHub contents API; no version below is inferred, each was read out of the file at that tag.
Confirmed present — Qwen samplers reading unclamped maxframes (maxframes = kwargs.get("maxframes", 768) inside Qwen2VLVideoBackend / Qwen3VLVideoBackend in vllm/multimodal/video.py):
| ref | Qwen3VLVideoBackend present | unclamped maxframes | |---|---|---| | v0.23.0 | no (class does not exist) | n/a | | v0.24.0 | yes | yes | | v0.25.0, v0.26.0, v0.27.0, v0.28.0, v0.29.0, v0.29.1rc0 | yes | yes | | main @ b23433088 | yes | yes |
Confirmed present — request-level mediaiokwargs (field on the chat request model, and VideoMediaIO.mergekwargs present): every ref probed, v0.19.0 through v0.29.1rc0 and main @ b23433088.
Confirmed absent — any numframes ceiling clamp (PR #51969 unmerged): every ref probed, v0.19.0 through v0.29.1rc0 and main @ b23433088.
Not resolved, and why:
1. The introducing commit/PR for Qwen2VLVideoBackend / Qwen3VLVideoBackend. My clone is shallow (--depth=300), so git log -S'maxframes' cannot reach it; the v0.23.0 → v0.24.0 boundary above is the tightest bound I established by probing release tags. 2. The rc tags between v0.23.0 and v0.24.0, to tighten the lower bound to a specific release candidate. 3. Whether a maxframes knob on a differently named pre-v0.24.0 backend is separately affected — at v0.23.0 GLMGAVideoBackend already carried maxframes = kwargs.get("maxframes", 640) with no clamp, and that clamp was only added on 2026-09-04 by 8b6de0eb9. Versions between are plausibly affected through GLMGA rather than Qwen; I did not test that path. 4. Whether GHSA-vxqj-p4gw-9h4c's own affected range differs, which I cannot see — the advisory returns 404 to me.
No dependency versions are asserted anywhere in this report.
Weaknesses of this report, stated upfront
No GPU was used. This host has no CUDA device, so the HTTP results come from the in-tree GPU-less render server rather than a full vllm serve. That server runs the real frontend — the same request model, the same mergemediaiokwargs → VideoMediaIO.mergekwargs → MediaConnector.fetchvideo chain, the same /tokenize route — and media decoding happens entirely in the frontend, so I do not believe the engine's presence changes the result. I have not confirmed that on a GPU deployment, and a reviewer may reasonably want that repeated under vllm serve. The in-process measurements (the #51969 comparison table) were taken by importing the tree directly at b23433088, verified by file, with no vllm wheel installed. Amplification figures are a floor. The OpenCV build available here offers only mp4v/XVID, giving ~2 200× compression on static content. An attacker using x264/x265 would do materially better for the same frame count. The two large figures in the Impact section (9.73 GiB RSS; the OOM kill) were measured on the numframes path, not this one, and are labelled as such. Per-frame pixels remain bounded by VLLMMAXIMAGEPIXELS; this concerns the unbounded frame count, which multiplies it. VLLMMAXMEDIADOWNLOADSIZEMB (default 256) caps the compressed size of an HTTP-fetched video but not a data: URI, which MediaConnector.loadfromurl dispatches before any size logic is reached.
---
Prepared with AI assistance (Claude), per the repository's contributing guidance on disclosing AI-assisted contributions. All findings were verified by executing vLLM's own code at the commits cited; the analysis and the claims are my own.
Reported by Eva Crystal / 0xiviel (XSource Security).
Affected
- Ecosystem / package: pip / vllm - Affected versions: vLLM ≤ 0.25.1 (confirmed on 0.25.1, commit 752a3a504485). The lower bound predates 0.25.1; maintainers can confirm how far back each path reaches.
Summary
Three structured-output request paths let an ordinary request reach a condition that raises an uncaught, engine-fatal exception instead of a per-request validation error. The failure is not confined to the request that caused it: it escapes into EngineCore's busy loop and triggers a fatal sendenginedead(), so one malformed structured-output request denies service to all concurrent and subsequent tenants of that engine. The requests are ordinary API calls; the client does not need any privileged configuration beyond the (often default) structured-output feature.
The shared root cause is the absence of a per-request exception boundary around structured-output grammar/token handling: a value that should fail as a request-scoped validation error instead propagates as an uncaught exception (or bypasses frontend validation entirely) and reaches the scheduler/engine loop, which treats the failure as fatal.
These sites are distinct from the published fixes for GHSA-6qc9-v4r8-22xg and GHSA-8wr5-jm2h-8r4f: GHSA-6qc9 (PR #17623) added frontend json-schema/regex/type validation but the completion check on v0.25.1 still catches only TimeoutError, so Site 1's valid duplicate-root EBNF via the latched xgrammar backend still re-raises and kills EngineCore; GHSA-8wr5 (PR #44744) fixes recovered-token state in the eagle spec-decode path and explicitly does not treat trailing -1 padding as the fault, so Site 2's -1 reaching backendguidance.validatetokens() via ngramgpu survives it.
Affected code
Links pinned to the confirmed commit 752a3a504485 (v0.25.1). The three sites are:
Site 1 — latched-backend compile exception escapes. StructuredOutputManager permanently latches one backend on the first structured-output request and always compiles through it, ignoring the per-request auto backend selection. A grammar that auto accepts only via a fallback backend (e.g. a duplicate-root EBNF) then makes the latched xgrammar compiler raise; the exception is stored in the grammar Future, and the completion check catches only TimeoutError, so Future.result() re-raises during scheduler promotion and kills EngineCore.
- Backend defaults to "auto": vllm/config/structuredoutputs.py#L21. - The auto validator tries xgrammar and silently falls back, recording the resolved backend on the request: vllm/samplingparams.py#L1004-L1035 (fields at #L85-L88). - The manager latches one backend and always compiles through it: vllm/v1/structuredoutput/init.py#L127-L159 and creategrammar() at #L173-L184. - The xgrammar sink calls the native compiler unguarded: vllm/v1/structuredoutput/backendxgrammar.py#L78-L110 (grammar path at #L90). - The completion check catches only TimeoutError: vllm/v1/structuredoutput/request.py#L48-L63 (result(timeout=0.0001) at #L55, except TimeoutError at #L57). - Scheduler promotion dereferences the grammar with no exception boundary: vllm/v1/core/sched/scheduler.py#L2441-L2460 (reached from schedule() at #L396). - The uncaught exception is treated as fatal: vllm/v1/engine/core.py#L1229-L1234 (sendenginedead() at #L1470).
The escaping-exception sink: the completion check only handles TimeoutError, so any compile exception stored in the Future re-raises out of result() during scheduler promotion.
python vllm/v1/structuredoutput/request.py Lines 48-59 def checkgrammarcompletion(self) -> bool: # NOTE: We have to lazy import to gate circular imports from vllm.v1.request import RequestStatus
if isinstance(self.grammar, Future): try: # We will check whether the future is ready within 100 us self.grammar = self.grammar.result(timeout=0.0001) self.status = RequestStatus.WAITING except TimeoutError: return False return True
Site 2 — guidance treats ngramgpu -1 padding as a token id. The ngramgpu proposer pads a fixed-width draft-token row with -1 and separately records numvaliddrafttokens, but the scheduler forwards the untrimmed padded row into GuidanceGrammar.validatetokens(), which passes -1 to the native llguidance matcher; the matcher cannot convert a negative value to its unsigned token type and raises an OverflowError, uncaught and fatal.
- The proposer fills with -1 and computes numvaliddrafttokens: vllm/v1/specdecode/ngramproposergpu.py#L189-L209. - getdrafttokenidscpu() materializes the full padded row, not trimmed to the valid count: vllm/v1/worker/gpumodelrunner.py#L4835-L4849. - Two scheduler call sites pass the padded row into validatetokens(...): vllm/v1/core/sched/scheduler.py#L1967 and #L1997. - The sink has no negative-value filter: vllm/v1/structuredoutput/backendguidance.py#L181-L192 (validatetokens(tokens) at #L192). - The uncaught OverflowError is treated as fatal: vllm/v1/engine/core.py#L1229-L1234.
The sink passes the padded row straight to the native matcher with no lower-bound check on token ids:
python vllm/v1/structuredoutput/backendguidance.py Lines 181-196 def validatetokens(self, tokens: list[int]) -> list[int]: """Checks if the list of tokens are accepted by the parser in sequence. Will not advance the parser.
Returns the prefix list of tokens that are accepted by the parser. """ if len(tokens) == 0: return [] if self.llmatcher.isstopped(): return []
numtokens = self.llmatcher.validatetokens(tokens)
self.checkerror()
return tokens[:numtokens]
Site 3 — Rust frontend accepts an empty structured-output value the Python frontend rejects. The opt-in Rust frontend admits an empty structuredoutputs.json / structuredoutputs.grammar string that the Python frontend rejects; the empty value passes through the wire-params conversion without validation, reaches the engine, and marks EngineCore dead.
- impl TryFrom<WireStructuredOutputsParams> for StructuredOutputsParams: rust/src/engine-core-client/src/protocol/structuredoutputs.rs#L135-L163 — tryfrom at L138 maps raw.json to a Json constraint (L159) and raw.grammar to a Grammar constraint (L162) with no non-empty check, so an empty value is forwarded unchanged. WireStructuredOutputsParams is defined at #L115.
The conversion maps each field to a constraint with no non-empty check, so an empty json/grammar string is forwarded unchanged:
rust // rust/src/engine-core-client/src/protocol/structuredoutputs.rs Lines 135-162 impl TryFrom<WireStructuredOutputsParams> for StructuredOutputsParams { type Error = Error;
fn tryfrom(raw: WireStructuredOutputsParams) -> Result<Self> { use StructuredOutputConstraint::;
let mut constraint = None;
macrorules! insertconstraint { ($name:literal, $value:expr) => { if let Some(value) = $value { if let Some((existing, )) = constraint { return Err(Error::InvalidStructuredOutputsParams { message: format!( "multiple structured output constraints specified: {existing}, {}", $name ), }); } constraint = Some(($name, value)); } }; }
insertconstraint!("json", raw.json.map(Json)); insertconstraint!("regex", raw.regex.map(Regex)); insertconstraint!("choice", raw.choice.map(Choice)); insertconstraint!("grammar", raw.grammar.map(Grammar));
Impact
Any client able to send an ordinary structured-output request (two requests for Site 1) can terminate the shared EngineCore, denying service to all tenants of that engine instance. Availability only; no code execution, memory corruption, or data disclosure.
- Site 1 affects the default "auto" structured-output backend; no speculative decoding or non-default configuration is required. - Site 2 requires a deployment using the guidance backend together with ngramgpu speculative decoding; the -1 sentinel is produced by the proposer itself, so an ordinary constrained request suffices — the client does not craft the negative token. - Site 3 requires the opt-in Rust frontend; a single request with an empty constraint value is enough. API-key middleware narrows the attacker from unauthenticated to authenticated but does not restore the missing validation.
Suggested Fix
Convert structured-output failures into request-scoped errors at the boundary. Each site is a distinct fix shape; the core hunk for each is below.
Site 1 — resolve the backend per request instead of latching one process-wide, so a grammar is always compiled with the backend that auto actually selected for it (and a compile failure is that request's error, not the engine's):
python vllm/v1/structuredoutput/init.py — grammarinit(), replacing the latched-backend path backend = self.getorcreatebackend(request.structuredoutputrequest.backend) if self.useasyncgrammarcompilation: grammar = self.executor.submit(self.creategrammar, request, backend) else: grammar = self.creategrammar(request, backend)
Site 2 — drop negative sentinels before handing the row to the native matcher, inside validatetokens():
python vllm/v1/structuredoutput/backendguidance.py — validatetokens(), before the llmatcher call for i, token in enumerate(tokens): if token < 0: tokens = tokens[:i] break if len(tokens) == 0: return []
Site 3 — reject empty json/grammar strings in the Rust wire-params conversion, matching the Python frontend, before the value reaches the engine:
rust // rust/src/engine-core-client/src/protocol/structuredoutputs.rs — tryfrom(), before constraint mapping if matches!(&raw.json, Some(Value::String(value)) if value.trim().isempty()) { return Err(Error::InvalidStructuredOutputsParams { message: "structuredoutputs.json cannot be an empty string".tostring(), }); } if matches!(&raw.grammar, Some(value) if value.trim().isempty()) { return Err(Error::InvalidStructuredOutputsParams { message: "structuredoutputs.grammar cannot be an empty string".tostring(), }); }
The three sites share one root cause and the same fix shape (bound the failure to the request), so they are filed as a single advisory; happy to split into per-component advisories if the maintainers prefer. Each fix carries a regression test.
Credit
Reported by: Patch the Planet (Trail of Bits + OpenAI collaboration)
These vulnerabilities were discovered using GPT-5.5-Cyber as part of the Patch the Planet security initiative.
---
Proposed fix: a fix for this issue is proposed in a public pull request: https://github.com/vllm-project/vllm/pull/51450
Affected
- Ecosystem / package: pip / vllm - Affected versions: vLLM ≤ 0.25.1 (confirmed on 0.25.1, commit 752a3a504485). The lower bound predates 0.25.1; maintainers can confirm how far back the loose cachesalt validator and unguarded scheduling-path lookup reach.
Summary
vLLM's OpenAI-compatible request models (Completions, Chat Completions, Responses) accept a client-supplied cachesalt field and validate it only as "must be a non-empty string" — no character or length restrictions. On a deployment with the built-in LMCache-MP KV connector enabled, that value is stored verbatim on the request tracker and forwarded unguarded as a keyword argument into the scheduler's per-step cache lookup. The downstream LMCache library applies a stricter check in IPCCacheServerKey.postinit, which raises ValueError for any cachesalt containing @, /, \, or NUL (or longer than 128 characters).
Neither the LMCache-MP connector lookup call site nor Scheduler.schedule() wraps that call in a request-scoped try/except, so the ValueError propagates uncaught into EngineCore's top-level handler, which treats any uncaught exception as fatal and kills the whole engine process. A single publicly reachable request with, for example, cachesalt="/" therefore takes down the engine for all concurrent users. vLLM's boundary validator is looser than the downstream consumer's, and the gap is never converted into a request-scoped failure on the scheduling path.
Affected code
Links pinned to the confirmed commit 752a3a504485 (v0.25.1):
- The three checkcachesaltsupport validators require only a non-empty string — no character or length bound: vllm/entrypoints/openai/completion/protocol.py#L502-L508 (field at #L172), vllm/entrypoints/openai/chatcompletion/protocol.py#L913-L919 (field at #L425), and vllm/entrypoints/openai/responses/protocol.py#L459-L465 (field at #L235). The loose test itself is at completion #L503-L505, chatcompletion #L914-L916, responses #L460-L462. - Two further request models accept cachesalt with the same or weaker checking, and should be hardened at the same time: vllm/entrypoints/pooling/base/protocol.py#L74-L85 carries the identical non-empty-string-only validator (field at #L59), and the token-in-token-out scale-out GenerateRequest exposes the field with no cachesalt validator at all: vllm/entrypoints/scaleout/tokenintokenout/protocol.py#L110. - The LMCache-MP request tracker stores the salt verbatim: vllm/distributed/kvtransfer/kvconnector/v1/lmcachempconnector.py#L189 (LMCacheMPRequestTracker), assignment at #L223. - The lookup call forwards cachesalt with no surrounding try: vllm/distributed/kvtransfer/kvconnector/v1/lmcachempconnector.py#L733-L774 (getnumnewmatchedtokens → maybesubmitlookuprequest(...) at #L770). - The scheduler invokes the connector unguarded: vllm/v1/core/sched/scheduler.py#L739 (self.connector.getnumnewmatchedtokens(...), inside schedule() at #L396). - The generic top-level handler treats the exception as fatal: vllm/v1/engine/core.py#L1229-L1235 — the except Exception in runenginecore (#L1154) logs EngineCore encountered a fatal error., calls sendenginedead(), and re-raises. - Downstream strict validator (external LMCache library, not vLLM): IPCCacheServerKey.postinit in lmcache/v1/multiprocess/customtypes.py rejects @ / \ NUL and >128-char salts (SALTFORBIDDENCHARS = frozenset("@/\\\x00")) by raising ValueError.
The three OpenAI checkcachesaltsupport validators are identical; the completion one is representative — a non-empty-string test with no character or length bound:
python vllm/entrypoints/openai/completion/protocol.py Lines 503-509 if data.get("cachesalt") is not None and ( not isinstance(data["cachesalt"], str) or not data["cachesalt"] ): raise VLLMValidationError( "Parameter 'cachesalt' must be a non-empty string if provided.", parameter="cachesalt", )
On the scheduling path the connector is invoked with no surrounding try — a ValueError from the downstream salt check propagates straight out of schedule():
python vllm/v1/core/sched/scheduler.py Lines 736-742 # Get externally-cached tokens if using a KVConnector. if self.connector is not None: exttokens, loadkvasync = ( self.connector.getnumnewmatchedtokens( request, numnewlocalcomputedtokens ) )
runenginecore's generic handler — the next except up the stack — treats that as fatal, marks the engine dead, and re-raises:
python vllm/v1/engine/core.py Lines 1229-1235 except Exception as e: if enginecore is None: logger.exception("EngineCore failed to start.") else: logger.exception("EngineCore encountered a fatal error.") enginecore.sendenginedead() raise e
Impact
Availability only. cachesalt is an attacker-controlled, publicly reachable request field that vLLM validates too loosely. A value such as "/" passes vLLM's check, reaches the stricter downstream validator, and its ValueError is never converted into a request-scoped failure — instead it kills the EngineCore process, a denial of service for every concurrent request on that server (HTTP failures, then /health failing).
Applicability: the built-in LMCache-MP KV connector must be enabled (lmcache >= 0.4.4), which is itself an opt-in KV-connector boundary. On such deployments no other special configuration is required, and the crash is a resource-availability failure rather than expected behavior of the opt-in feature.
Suggested Fix
Two independent fixes:
1. Tighten admission — add one shared validatecachesalt() helper (for example in vllm/entrypoints/openai/engine/protocol.py) that matches or exceeds the downstream IPCCacheServerKey rules — reject @, /, \, NUL, and >128-character salts at the HTTP boundary with a 4xx — and route every request model that exposes cachesalt through it: the three checkcachesaltsupport validators above, the pooling base request, and the token-in-token-out GenerateRequest (which has no validator today). 2. Defense in depth — wrap the LMCache-MP lookup call reached from Scheduler.schedule() so a downstream validator ValueError becomes a request-scoped failure instead of an EngineCore-fatal exception. This is the fix that also covers future divergence between vLLM's and LMCache's salt rules; the right failure semantics (fail the request vs. fall back to a cold lookup) is a maintainer design call.
The core of fix 1 is a single shared helper; each checkcachesaltsupport body then becomes validatecachesalt(data.get("cachesalt")), and GenerateRequest gains an equivalent mode="before" validator:
python vllm/entrypoints/openai/engine/protocol.py — new shared helper CACHESALTFORBIDDENCHARS = frozenset("@/\\\x00") MAXCACHESALTLENGTH = 128
def validatecachesalt(cachesalt: object) -> None: """Validate cache salts before they reach downstream cache backends.""" if cachesalt is None: return if not isinstance(cachesalt, str) or not cachesalt: raise VLLMValidationError( "Parameter 'cachesalt' must be a non-empty string if provided.", parameter="cachesalt", ) if len(cachesalt) > MAXCACHESALTLENGTH or any( char in CACHESALTFORBIDDENCHARS for char in cachesalt ): raise VLLMValidationError( "Parameter 'cachesalt' must be at most 128 characters and must " "not contain '@', '/', '\\\\', or NUL.", parameter="cachesalt", )
This distinguishes the finding from GHSA-6qc9-v4r8-22xg: that advisory fixed only the guidedjson/xgrammar trigger of the same schedule()-into-runenginecore fatal-handler family, so the cachesalt admission gap and the schedule()-level defense-in-depth (fix 2) survive its published fix. A patch implementing fix 1 across all five request models, with a regression test covering the rejected ("/", 129-char) and accepted salt shapes, applies to v0.25.1 with line offsets and no fuzz.
Credit
Reported by: Patch the Planet (Trail of Bits + OpenAI collaboration)
This vulnerability was discovered using GPT-5.5-Cyber as part of the Patch the Planet security initiative.
---
Proposed fix: a fix for this issue is proposed in a public pull request: https://github.com/vllm-project/vllm/pull/51444
Affected
- Ecosystem / package: pip / vllm - Affected versions: vLLM ≤ 0.25.1 (confirmed on 0.25.1, commit 752a3a504485). The lower bound predates 0.25.1; maintainers can confirm how far back the flash late-interaction query cache reaches.
Summary
On late-interaction /score and /rerank deployments with flash late interaction enabled (the default for supported models), the worker caches per-request query embeddings under a key derived from the caller-controlled X-Request-Id header. A second concurrent request that reuses the victim's header value replaces the victim's cached query embedding before document scoring — so the victim's documents are scored against the attacker's query. Because the data-parallel router pins all requests sharing a cache key to the same engine, the collision is deterministic for an attacker who reuses the victim's X-Request-Id. Depending on timing, one request can also consume the shared use counter and force the other request into a late-interaction cache-miss error.
This is a remotely reachable, request-controlled cross-request integrity break on the standard scoring and reranking endpoints. It requires only that flash late interaction be enabled, which is the default for supported models.
Affected code
Links pinned to the confirmed commit 752a3a504485 (v0.25.1):
- vllm/entrypoints/serve/engine/serving.py#L117-L124 — baserequestid() copies the public X-Request-Id header directly. - vllm/entrypoints/pooling/base/serving.py#L109 — the frontend request id is f"{self.requestidprefix}-{self.baserequestid(rawrequest)}". - vllm/entrypoints/pooling/scoring/serving.py#L211 — flashlateinteraction() (at L191) derives worker cache keys directly from that id: querykeys = [f"{ctx.requestid}-query-{i}" for i in range(nqueries)]. - vllm/v1/pool/lateinteraction.py#L30-L36 — the data-parallel routing helper pins all requests sharing a querykey to the same engine via crc32(querykey), making collisions deterministic. - vllm/v1/worker/gpu/pool/lateinteractionrunner.py#L95 — the worker stores query embeddings in a process-local cache keyed only by that string: self.querycache[querykey] = output.clone().
The caller-controlled header enters as the request id, and the flash late-interaction path derives the worker cache key directly from it:
python vllm/entrypoints/serve/engine/serving.py Lines 116-126 @staticmethod def baserequestid( rawrequest: Request | None, default: str | None = None ) -> str | None: """Pulls the request id to use from a header, if provided""" if rawrequest is not None and ( (reqid := rawrequest.headers.get("X-Request-Id")) is not None ): return reqid
return randomuuid() if default is None else default
python vllm/entrypoints/pooling/scoring/serving.py Lines 207-212 nqueries = ctx.nqueries ndocs = len(ctx.engineinputs) - nqueries queryengineinputs = ctx.engineinputs[:nqueries]
querykeys = [f"{ctx.requestid}-query-{i}" for i in range(nqueries)] queryuses = [ndocs if nqueries == 1 else 1] nqueries
The worker then stores and reads the query embedding under that string with no check that the reader owns the entry — a colliding key returns another request's cached query, or (once the use counter is exhausted) raises a cache-miss error:
python vllm/v1/worker/gpu/pool/lateinteractionrunner.py Lines 91-107 if mode == LATEINTERACTIONMODECACHEQUERY: assert queryuses is not None # output can be a view into the current step's hidden-states # buffer, so clone it before storing across scheduling steps. self.querycache[querykey] = output.clone() self.queryuses[querykey] = queryuses outputs[i] = torch.zeros((), device=output.device, dtype=torch.float32) continue
if mode == LATEINTERACTIONMODESCOREDOC: queryoutput = self.querycache.get(querykey) if queryoutput is None: raise ValueError( "late-interaction query cache miss for key " f"{querykey!r}. Ensure query requests are executed " "before their paired document requests." )
The bug is specific to the flash late-interaction path. Non-flash late-interaction scoring computes MaxSim directly from one request's in-memory outputs and does not create a cross-request worker cache key.
Impact
A network client of the standard scoring API can, on a flash late-interaction /score or /rerank deployment:
1. Corrupt another user's results — by reusing the victim's X-Request-Id, the attacker's query embedding overwrites the victim's cached entry, so the victim's documents are scored against the attacker's query (a cross-request integrity break). 2. Induce errors — depending on timing, one request consumes the shared use counter and forces the other request into a late-interaction cache-miss error.
Both consequences follow deterministically from reusing the victim's header value, because same-key work is pinned to one engine. This is reachable through normal request handling and does not depend on any trusted inter-node network.
Suggested Fix
Derive the flash late-interaction query-cache key from a server-generated, unforgeable per-request identifier (a randomuuid() namespace) rather than the caller-supplied X-Request-Id, and thread that key through the PoolingServeContext to the doc-scoring pass so both passes reuse the same key and a caller cannot address another request's cache entry.
In vllm/entrypoints/pooling/scoring/serving.py, the encode-queries pass mints a fresh namespace and stashes the keys on the context:
python - querykeys = [f"{ctx.requestid}-query-{i}" for i in range(nqueries)] + querynamespace = randomuuid() + querykeys = [ + f"late-interaction-{querynamespace}-query-{i}" for i in range(nqueries) + ] + ctx.lateinteractionquerykeys = querykeys
and the encode-docs pass reads those stored keys instead of re-deriving them from ctx.requestid:
python - querykeys = [f"{ctx.requestid}-query-{i}" for i in range(nqueries)] + querykeys = ctx.lateinteractionquerykeys + if querykeys is None: + raise RuntimeError("Late-interaction query keys were not initialized.")
This requires adding the lateinteractionquerykeys: list[str] | None = None field to PoolingServeContext (vllm/entrypoints/pooling/typing.py). Because the namespace is a server-generated UUID, colliding X-Request-Id values no longer produce a shared cache key; a regression test asserting exactly that (colliding request ids yield distinct query-cache keys) accompanies the change.
Credit
Reported by: Patch the Planet (Trail of Bits + OpenAI collaboration)
This vulnerability was discovered using GPT-5.5-Cyber as part of the Patch the Planet security initiative.
---
Proposed fix: a fix for this issue is proposed in a public pull request: https://github.com/vllm-project/vllm/pull/51445
Affected
- Ecosystem / package: pip / vllm - Affected versions: vLLM ≤ 0.25.1 (confirmed on 0.25.1, commit 752a3a504485). The lower bound predates 0.25.1; maintainers can confirm how far back the scale-out transport path reaches.
Summary
vLLM's disaggregated scale-out transport splits a multimodal request into a trusted render step (POST /v1/chat/completions/render) and a separate generate step (POST /inference/v1/generate). The generate route decodes a caller-supplied features object — serialized encoder tensors (kwargsdata), multimodal hashes (mmhashes), placeholder ranges (mmplaceholders), and the internal field-processor selection — and forwards it into the engine as if it had come from the trusted renderer, with no rebinding to (or validation against) the active model's renderer contract. Because the two routes are ordinary auth-guarded HTTP endpoints (the /inference prefix is registered by default on generate-capable servers), any authenticated caller can submit an otherwise-valid render body with a single forged field.
Depending on which field is forged, this produces:
- an engine-fatal crash of the shared EngineCore process (denial of service), reproduced as a CUDA illegal-memory-access, a post-admission rank-mismatch ValueError, and a hard assert — three independent forged fields (sites 1, 2, 3); - silent cross-request encoder-cache poisoning / disclosure of shared encoder state when the cache-key hash is not bound to the payload (site 4); - transport-level integrity loss when sparse placeholder masks are dropped during render-to-generate replay (site 5).
All five share one root cause and one fix shape: the reconstructed multimodal state on the scale-out path is trusted without being rebound to, and validated against, the active model's renderer output before it reaches the engine.
These sites are distinct from prior multimodal hardening. Site 1 survives GHSA-wv77-2vpf-vmmg (that fix validates full tensor shape in MultiModalDataParser/getinputembeddings on the prompt-embeds path), because our request forges imagegridthw metadata with the pixel bytes intact and reaches the Qwen2 vision RoPE/cuseqlens and imageembeds.split sink, which the shape-check fix does not rebind. Site 4 is distinct from GHSA-c65p-x677-fgj6 (which folds metadata into MultiModalHasher.serializeitem to stop hash collisions), because the scale-out generate path trusts a caller-supplied mmhash as the cache key with no origin binding, so that fix does not stop a caller from submitting a victim's hash or a kwargsdata=None cache read.
Affected code
Links pinned to the confirmed commit 752a3a504485 (v0.25.1).
Shared entry point and control surface for all five sites:
- POST /inference/v1/generate route: vllm/entrypoints/scaleout/tokenintokenout/apirouter.py#L46-L75. - Public features schema (kwargsdata, mmhashes, mmplaceholders): vllm/entrypoints/scaleout/tokenintokenout/protocol.py#L42-L63. - ServingTokens.servetokens() copies the decoded geometry and hashes into engine structures without rebinding to the renderer schema: vllm/entrypoints/scaleout/tokenintokenout/serving.py#L145-L172. - The scale-out routers are registered by default on generate-capable servers and /inference is treated as an ordinary auth-guarded prefix: vllm/entrypoints/openai/apiserver.py#L217-L219.
Site 1 — forged Qwen grid geometry (engine-fatal DoS). The decoded imagegridthw is never rebound to the rendered pixel-tensor element count.
- Reconstruction with no model-specific geometry invariant: serving.py#L147-L172. - Qwen2VisionTransformer.prepareencodermetadata() derives RoPE tables, cuseqlens, and the FlashAttention maxseqlen from the caller-supplied grid: qwen2vl.py#L658-L712, consumed in forward() (#L713-L751). - processimageinput() computes imageembeds.split(sizes) from the same untrusted metadata: qwen2vl.py#L1348-L1369; M-RoPE positions at qwen2vl.py#L1223.
The only shape check is assert gridthw.ndim == 2; the split sizes and the vision-encoder call are then derived directly from the caller-supplied grid, with no cross-check against the pixel-tensor row count:
python vllm/modelexecutor/models/qwen2vl.py Lines 1348-1369 def processimageinput( self, imageinput: Qwen2VLImageInputs ) -> tuple[torch.Tensor, ...]: gridthw = imageinput["imagegridthw"] assert gridthw.ndim == 2
if imageinput["type"] == "imageembeds": imageembeds = imageinput["imageembeds"] else: pixelvalues = imageinput["pixelvalues"]
if self.usedataparallel: return rundpshardedmropevisionmodel( self.visual, pixelvalues, gridthw.tolist(), ropetype="rope3d" ) else: imageembeds = self.visual(pixelvalues, gridthw=gridthw)
# Split concatenated embeddings for each image item. mergesize = self.visual.spatialmergesize sizes = (gridthw.prod(-1) // mergesize // mergesize).tolist() return imageembeds.split(sizes)
Site 2 — wire-selected field-processor type confusion (engine-fatal DoS). MsgpackDecoder.decodemmfieldelem() trusts a wire-selected field-factory name and constructs the internal field processor directly from caller data.
- vllm/v1/serialutils.py#L440-L454 reads factorymethname, factorykw = obj["field"] and calls getattr(MultiModalFieldConfig, factorymethname). - Qwen2-VL field/schema contract: qwen2vl.py#L763-L791 and Qwen2VLImagePixelInputs (#L119-L144); parse/validate at qwen2vl.py#L1300. - Rank mismatch raised post-admission: vllm/utils/tensorschema.py#L155-L171, reached from TensorSchema.init → validate() (#L63).
Site 3 — non-positive placeholder length (engine-fatal DoS via reachable assert). PlaceholderRangeInfo{offset,length} is accepted as unconstrained integers and copied verbatim into the engine's PlaceholderRange.
- Schema: protocol.py#L28-L35. - Copied into PlaceholderRange: serving.py#L147-L153. - The only guard is an upper bound on embed count (numembeds > mmencodercachesize), with no non-positive check: vllm/v1/engine/inputprocessor.py#L459-L464; raw length returned by vllm/multimodal/inputs.py#L152-L154; window selection assumes non-empty ranges at vllm/multimodal/utils.py#L114-L134. - Sink: assert startidx < endidx at vllm/v1/worker/gpu/mm/encoderrunner.py#L114 (duplicated at vllm/v1/worker/gpumodelrunner.py#L3192); turned into a fatal shutdown by EngineCore's uncaught-exception path at vllm/v1/engine/core.py#L1229-L1233.
A length of 0 makes numencodertokens == 0, so endidx collapses to 0 and the bare assert fires inside the engine worker:
python vllm/v1/worker/gpu/mm/encoderrunner.py Lines 108-114 posinfo = mmfeature.mmposition startpos = posinfo.offset numencodertokens = posinfo.length
startidx = max(curquerystart - startpos, 0) endidx = min(curqueryend - startpos, numencodertokens) assert startidx < endidx
Site 4 — cache hash not bound to payload (integrity / disclosure). features.mmhashes (the cache key) and kwargsdata (the tensor) are independent fields with no origin or integrity binding.
- Schema exposing the caller-controlled hash and the None = resolve-from-cache semantics: protocol.py#L42-L63. - Handler forwards the caller's hashes unchanged into mminput(...): serving.py#L164-L170. - Input processing copies the caller hash into the feature identifier with only a string-type check: vllm/v1/engine/inputprocessor.py#L165-L181. - Sink — the receiver cache returns the cached tensor solely by that key (cachekey = feature.mmhash or feature.identifier): vllm/multimodal/cache.py#L602-L607.
The cache key is the caller-supplied hash with no verification against the tensor bytes, so a forged mmhash both stores under and reads back another request's slot:
python vllm/multimodal/cache.py Lines 601-607 for feature in mmfeatures: cachekey = feature.mmhash or feature.identifier self.touchreceivercacheitem(cachekey, feature.data)
for feature in mmfeatures: cachekey = feature.mmhash or feature.identifier feature.data = self.getandupdateitem(feature.data, cachekey) return mmfeatures
Site 5 — dropped sparse placeholder mask (transport integrity loss). The render path serializes placeholders as only offset/length, so models relying on sparse isembed masks lose the mask during render-to-generate replay.
- ServingRender.extractmmfeatures() builds each PlaceholderRangeInfo(offset=p.offset, length=p.length), discarding isembed: vllm/entrypoints/scaleout/render/serving.py#L212-L229. - Transport schema carries no field for the mask: protocol.py#L28. - ServingTokens.servetokens() reconstructs a dense PlaceholderRange regardless of the original: serving.py#L148-L152.
Impact
A single authenticated request to a scale-out multimodal deployment can:
- Crash the shared EngineCore process (sites 1, 2, 3), taking the served model down for every tenant (/health → 503). Availability-only; no code execution or data disclosure demonstrated for these sites. - Silently poison or read back another request's shared encoder-cache state (site 4) — an integrity/disclosure primitive. Attack complexity is High because the attacker must know or induce the victim's content hash; there is no availability impact for this site. - Corrupt backend-visible placeholder semantics across the render-to-generate boundary (site 5) for models that depend on sparse isembed masks.
The forged multimodal payload is small; only the trust in its self-declared geometry/identity is the defect.
Suggested Fix
On the scale-out path, do not trust caller-supplied multimodal state as renderer-produced. After decoding features, rebind and validate the reconstructed MultiModalKwargsItem against the active model's renderer contract at the HTTP boundary:
1. Reject any request whose decoded grid geometry is inconsistent with the rendered pixel-tensor element count and declared placeholder span, before it reaches prepareencodermetadata() (site 1). 2. Rebind each field's processor type to the schema the active model's renderer declares (or reject if it differs), instead of reconstructing internal field processors from wire-selected factory names (site 2). 3. Reject any PlaceholderRangeInfo with length <= 0 (or out-of-range offset) with a request-scoped 4xx, and convert the encoder-runner invariant into a checked, request-scoped error rather than a process-fatal assert (site 3). 4. Recompute or verify the content hash for submitted kwargsdata before using it as a cache key, and namespace receiver-cache keys to a server-generated or principal scope, refusing cache-reads for hashes the caller did not legitimately produce (site 4). 5. Serialize isembed in PlaceholderRangeInfo, validate its length against the placeholder span, and reconstruct it on replay (site 5).
Site 1 — validate grid geometry before the vision encoder. Replace the bare assert gridthw.ndim == 2 in processimageinput()/processvideoinput() with a shared helper that recomputes the split sizes and rejects a grid whose patch-row count does not match the pixel tensor (and rejects non-positive / non-merge-divisible dims), so the mismatch never reaches imageembeds.split():
python vllm/modelexecutor/models/qwen2vl.py — processimageinput() - gridthw = imageinput["imagegridthw"] - assert gridthw.ndim == 2 + gridthw = imageinput["imagegridthw"] + inputtype = imageinput["type"] + inputtensor = ( + imageinput["imageembeds"] + if inputtype == "imageembeds" + else imageinput["pixelvalues"] + ) + sizes = validateqwen2vlinputgeometry( + modality="image", + inputtype=inputtype, + inputtensor=inputtensor, + gridthw=gridthw, + spatialmergesize=self.visual.spatialmergesize, + ) ... - # Split concatenated embeddings for each image item. - mergesize = self.visual.spatialmergesize - sizes = (gridthw.prod(-1) // mergesize // mergesize).tolist() return imageembeds.split(sizes)
where the helper raises before the encoder runs:
python vllm/modelexecutor/models/qwen2vl.py — new validateqwen2vlinputgeometry() if t <= 0 or h <= 0 or w <= 0: raise ValueError(f"{modality} gridthw row {index} must be positive ...") if h % spatialmergesize != 0 or w % spatialmergesize != 0: raise ValueError(f"{modality} gridthw row {index} must be divisible ...") ... if actualrows != expectedrows: raise ValueError( f"{modality} {rowkind} do not match gridthw: " f"expected {expectedrows}, got {actualrows}." )
Site 3 — constrain the placeholder schema. Make PlaceholderRangeInfo reject non-positive lengths and negative offsets at the Pydantic boundary (plus parallel-length, non-overlapping, and within-prompt validators), turning the process-fatal assert into a request-scoped 422:
python vllm/entrypoints/scaleout/tokenintokenout/protocol.py — PlaceholderRangeInfo - offset: int - length: int + offset: int = Field(ge=0) + length: int = Field(gt=0)
Site 4 — bind the cache key to the payload. Derive each cache key by hashing the submitted serialized tensor (ignoring the caller's mmhashes) and refuse cache-only reads, so a forged hash can neither poison nor read a victim's slot:
python vllm/entrypoints/scaleout/tokenintokenout/serving.py — servetokens() + mmhashes = bindmmhashestokwargsdata( + features.mmhashes, features.kwargsdata, + ) engineinput = mminput( prompttokenids=request.tokenids, mmkwargs=MultiModalKwargsItems(mmkwargs), - mmhashes=features.mmhashes, + mmhashes=mmhashes, mmplaceholders=mmplaceholders, cachesalt=request.cachesalt, )
where bindmmhashestokwargsdata() raises on kwargsdata is None (cache-only read) and derives sha256(modality || "\0" || serializeditem) per item. Site 2 applies the same rebind-to-declared-schema pattern in mmserde.py (passing modality + mmprocessor into decodemmkwargsitem), and site 5 adds an isembed field to PlaceholderRangeInfo with fromplaceholderrange/toplaceholderrange helpers so the sparse mask survives render-to-generate replay. Each site fix ships with a regression test. This packet groups the five sites because they share one entry point (/inference/v1/generate + /render) and one root cause; we are happy to split it into per-component advisories (for example, engine-fatal input-validation vs. cache-key binding vs. transport-schema integrity) if the vLLM team prefers.
Credit
Reported by: Patch the Planet (Trail of Bits + OpenAI collaboration)
These vulnerabilities were discovered using GPT-5.5-Cyber as part of the Patch the Planet security initiative.
---
Proposed fix: a fix for this issue is proposed in a public pull request: https://github.com/vllm-project/vllm/pull/51898
Affected
- Ecosystem / package: pip / vllm - Affected versions: vLLM ≤ 0.25.1 (confirmed on 0.25.1, commit 752a3a504485). The lower bound predates 0.25.1; maintainers can confirm how far back the mirrored sender/receiver cache protocol reaches.
Summary
vLLM's default multimodal cache (mmprocessorcachetype="lru") mirrors state across two processes: the frontend (P0) holds only metadata (MultiModalProcessorSenderCache) while the engine core (P1) holds the real payload (MultiModalReceiverCache). The design invariant is that getandupdate() runs on P0 and P1 in lockstep for every request, so eviction order stays mirrored and P0 can answer "is this cached in P1?" without talking to P1.
That invariant breaks when a request is rejected after P0 has rendered and hashed the multimodal input (populating the P0 cache) but before P1 receives the item — for example, an oversized chat prompt rejected on maxmodellen after rendering. P0 now believes the media is cached while P1 never got it. A later request reusing the same media hash gets a P0 hit, so P0 sends None instead of the payload, and P1 — which has nothing cached — trips assert mmitem is not None, f"Expected a cached item for {mmhash=}".
This is a remotely reachable, request-controlled cache-mirroring desync on the standard multimodal inference path. It requires only the default cache configuration.
Affected code
Links pinned to the confirmed commit 752a3a504485 (v0.25.1):
- P0 metadata cache — MultiModalProcessorSenderCache at vllm/multimodal/cache.py#L379; getandupdateitem at #L410-L421, commit assertion at #L418. - P1 payload cache (the actual sink) — MultiModalReceiverCache at vllm/multimodal/cache.py#L630; getandupdateitem at #L652-L663, with the failing assert mmitem is not None, f"Expected a cached item for {mmhash=}" at #L660. - Default mmprocessorcachetype = "lru" at vllm/config/multimodal.py#L132; dispatch in vllm/multimodal/registry.py#L294-L307 (sender) and #L322-L331 (receiver). The processoronly, disabled-caching, and shm paths are not affected. - Rejection-after-render window — rendering happens before length validation in vllm/entrypoints/openai/chatcompletion/serving.py#L206-L231 (renderchatrequest), and the length check raises after the render in vllm/entrypoints/serve/utils/apiutils.py#L171-L189. - Cache-commit call site — vllm/multimodal/processing/processor.py#L1347 (mergemmkwargs) commits the P0 sender entry during render. - The separate stale-order eviction hang is already fixed via vllm/utils/cache.py#L120-L121 (LRUCache.touch() guarding if key in self:) — a different mechanism that does not touch the sender/receiver commit-ordering protocol and does not remediate this assertion.
The P1 receiver sink — the assertion that fires when P1 receives None for a hash it never cached:
python vllm/multimodal/cache.py Lines 651-663 @override def getandupdateitem( self, mmitem: MultiModalKwargsItem | None, mmhash: str, ) -> MultiModalKwargsItem: if (cacheditem := self.cache.get(mmhash)) is not None: return cacheditem
assert mmitem is not None, f"Expected a cached item for {mmhash=}"
self.cache[mmhash] = mmitem return mmitem
The P0 sender — on a hit it drops the payload (returns None) and, on a miss during render, unconditionally commits the metadata entry with no rollback tied to admission:
python vllm/multimodal/cache.py Lines 409-422 @override def getandupdateitem( self, mmitem: MultiModalProcessorCacheInItem, mmhash: str, ) -> MultiModalProcessorCacheOutItem: if (cacheditem := self.cache.get(mmhash)) is not None: return None, cacheditem.promptupdates
assert mmitem is not None, f"Expected a cached item for {mmhash=}"
self.cache[mmhash] = MultiModalProcessorCacheItemMetadata(mmitem)
return mmitem
Impact
A remote client submitting multimodal requests can poison a cache identity — render a media item successfully, then have that request rejected — so a later request reusing the same media hash fails on the P1 receiver assertion. This is an availability failure against a shared serving instance. No code execution, memory corruption, or data disclosure is claimed.
On this revision the failure is scoped as a request-level preprocessing error (the engine core catches around preprocessaddrequest); public reports show the same assertion cascading into further engine-loop assertions on other revisions. It applies to multimodal models running the default mirrored lru cache.
Suggested Fix
Two complementary changes:
1. Make the mirrored commit atomic with admission — insert into the P0 sender cache only after the request has passed all admission checks (length, limits) and P1 has acknowledged the item, or roll back the P0 insert on rejection. 2. Defense in depth — convert the P1 receiver assert mmitem is not None into a checked, request-scoped error (fetch-on-miss from P0) so a desync degrades a single request rather than asserting in the engine loop.
The core of the rollback half: wrap the post-render length check so a ValueError rejection discards the P0 entries the render just committed, before re-raising. Add a discardsendercacheitem() on the processor cache (no-op default, pop on the sender) and a Renderer.discardmmcacheentries() that walks a rendered request's mmhashes:
python vllm/entrypoints/openai/chatcompletion/serving.py (createchatcompletion) - maxtokens = getmaxtokens( - maxmodellen, - ..., - truncateprompttokens=request.truncateprompttokens, - ) + try: + maxtokens = getmaxtokens( + maxmodellen, + ..., + truncateprompttokens=request.truncateprompttokens, + ) + except ValueError: + for renderedinput in engineinputs: + if mmhashes := renderedinput.get("mmhashes"): + self.renderer.discardmmcacheentries(mmhashes) + raise
python vllm/multimodal/cache.py (MultiModalProcessorSenderCache) + @override + def discardsendercacheitem(self, mmhash: str) -> None: + self.cache.pop(mmhash, None)
This closes the maxmodellen rejection path; because any other rejection-after-render path reopens the same window, pairing it with the defense-in-depth change above (making the P1 assert a checked, request-scoped error) is recommended.
Credit
Reported by: Patch the Planet (Trail of Bits + OpenAI collaboration)
This vulnerability was discovered using GPT-5.5-Cyber as part of the Patch the Planet security initiative.
---
Proposed fix: a fix for this issue is proposed in a public pull request: https://github.com/vllm-project/vllm/pull/51897
Affected
- Ecosystem / package: pip / vllm - Affected versions: vLLM ≤ 0.25.1 (confirmed on 0.25.1, commit 752a3a504485). The lower bound predates 0.25.1; maintainers can confirm how far back the tool-continuation re-submission has omitted the salt.
Summary
On the GPT-OSS "Harmony" path (POST /v1/responses), a request that uses a built-in or MCP tool runs as a multi-turn loop: after each tool call vLLM re-renders the full next-turn Harmony prompt and re-submits it to the engine. Turn 1 correctly carries request.cachesalt, but the tool-continuation re-submission rebuilds the engine input via tokensinput(tokenids) with no cachesalt. The continuation prefix is therefore cached in the global unsalted namespace even though the caller opted into salting. A second tenant who can guess the low-entropy post-tool history submits the reconstructed continuation (unsalted) and reads exact per-turn cached-token counts from the Responses usage — restoring the prompt-membership oracle that cachesalt is documented to prevent.
Silently dropping a preserved salt after the supported tool workflow is enabled is a broken isolation control: the caller enabled salting and every turn should stay isolated, but continuation turns leak into the shared cache.
This is distinct from GHSA-4qjh-9fv9-r85r (CVE-2025-46570): that advisory is the prefix-cache membership oracle for which cachesalt is the documented mitigation, and its PR-17045 fix does not close this site — the Harmony tool continuation silently drops the preserved salt, caching in the unsalted namespace and leaking exact cachedtokensperturn counts from a different sink (the Responses serving continuation, not general TTFT timing).
Affected code
Links pinned to the confirmed commit 752a3a504485 (v0.25.1):
- The drop (sink): vllm/entrypoints/openai/responses/serving.py#L712-L713 — tokenids = context.renderforcompletion() then engineinput = tokensinput(tokenids), with no cachesalt. - Correct turn-1 call for contrast: vllm/entrypoints/openai/responses/serving.py#L755 — tokensinput(prompttokenids, cachesalt=request.cachesalt). - tokensinput stores the salt only if passed: vllm/inputs/engine.py#L51-L66 (if cachesalt is not None: inputs["cachesalt"] = cachesalt). - The engine request copies only the current input's salt: vllm/v1/engine/inputprocessor.py#L380 (cachesalt=decoderinputs.get("cachesalt") → None for the continuation). - Prefix-cache hashing keys on the salt only when present: vllm/v1/core/kvcacheutils.py#L560-L561 ([request.cachesalt] if (starttokenidx == 0 and request.cachesalt) else []). - The oracle the attacker reads: vllm/entrypoints/openai/responses/serving.py#L909 (cachedtokensperturn). - The documented control being defeated: vllm/entrypoints/openai/responses/protocol.py#L235 (cachesalt field).
The tool-continuation re-submission rebuilds the engine input with no cachesalt:
python vllm/entrypoints/openai/responses/serving.py Lines 711-715 if isinstance(context, HarmonyContext): tokenids = context.renderforcompletion() engineinput = tokensinput(tokenids)
samplingparams.maxtokens = maxmodellen - len(tokenids)
Contrast with the correct turn-1 call, which does preserve the caller's salt:
python vllm/entrypoints/openai/responses/serving.py Lines 754-755 prompttokenids = renderforcompletion(messages) engineinput = tokensinput(prompttokenids, cachesalt=request.cachesalt)
tokensinput stores the salt on the engine input only when it is passed, so the continuation input carries none and lands in the unsalted namespace:
python vllm/inputs/engine.py Lines 51-66 def tokensinput( prompttokenids: list[int], , prompt: str | None = None, cachesalt: str | None = None, ) -> TokensInput: """ Construct [TokensInput][vllm.inputs.engine.TokensInput] from optional values. """ inputs = TokensInput(type="token", prompttokenids=prompttokenids)
if prompt is not None: inputs["prompt"] = prompt if cachesalt is not None: inputs["cachesalt"] = cachesalt
Impact
An authenticated tenant of a shared deployment can recover whether a guessed post-tool prompt or history was processed by another tenant, with exact cached-token counts rather than noisy latency — the exact prompt-membership oracle cachesalt is documented to prevent. It defeats the multi-user prefix-cache isolation guarantee for salted Harmony tool sessions.
Preconditions: a GPT-OSS Harmony model on /v1/responses; prefix caching enabled (default); an operator-enabled built-in or MCP tool server; the victim sets cachesalt and triggers at least one tool continuation; and the attacker can reconstruct the post-tool history closely enough to match the token prefix. The AC:H metric reflects that guessable-history precondition.
Suggested Fix
Propagate request.cachesalt into every Harmony (and Parsable) tool-continuation re-submission — at the continuation call site call tokensinput(tokenids, cachesalt=request.cachesalt), mirroring the correct turn-1 call. Carry the salt on the HarmonyContext (thread the originating request into the context) so no continuation path can omit it:
diff vllm/entrypoints/openai/responses/serving.py if isinstance(context, HarmonyContext): tokenids = context.renderforcompletion() - engineinput = tokensinput(tokenids) + engineinput = tokensinput( + tokenids, + cachesalt=( + context.request.cachesalt + if context.request is not None + else None + ), + )
with HarmonyContext.init gaining a request: ResponsesRequest | None = None parameter (stored as self.request) that createresponses passes when constructing the context. The continuation prefix is then cached in the victim's salted namespace, mirroring turn 1.
Suggested regression test: assert cachedtokensperturn == 0 for a different-salt probe against a salted victim continuation (the four-way control from the proof of concept).
Credit
Reported by: Patch the Planet (Trail of Bits + OpenAI collaboration)
This vulnerability was discovered using GPT-5.5-Cyber as part of the Patch the Planet security initiative.
---
Proposed fix: a fix for this issue is proposed in a public pull request: https://github.com/vllm-project/vllm/pull/51818
A flaw has been found in vllm-project vLLM up to 0.26.0. This vulnerability affects unknown code of the file rust/src/parser/src/unified/gemma4.rs of the component Gemma4UnifiedParser. Executing a manipulation can lead to denial of service. The attack may be launched remotely. The exploit has been published and may be used. Upgrading to version 0.29.1rc0 is able to resolve this issue. This patch is called 3439bad37e68ba9755a46f4f6b44a4aeaf1f60a9. Upgrading the affected component is advised.
vLLM before 0.29.0 accepts user-controlled stoptokenids on the OpenAI-compatible POST /v1/completions and POST /v1/chat/completions endpoints but validates only that the values are integers, not that each token id is within the model vocabulary/logits range. When mintokens > 0, the stop token ids are used as logits indices to suppress stop tokens, so an out-of-range id reaches a CUDA indexing operation (indexput) and triggers a device-side assertion. An authenticated API user can send a single malformed completion request that returns 500 Internal Server Error and puts EngineCore into a fatal state, causing subsequent requests to fail until the service is restarted (denial of service).
vLLM is an inference and serving engine for large language models. In versions from 0.22.1 through 0.28.0, the operator-supplied model revision pin (--revision / --code-revision) is not propagated to several Hugging Face artifact loads for the FunAudioChat and Tarsier2 architectures: the WhisperFeatureExtractor and speechtokenizer PreTrainedTokenizerFast loads in vllm/modelexecutor/models/funaudiochat.py and the Qwen2VLConfig.frompretrained call used by Tarsier2ProcessingInfo in vllm/modelexecutor/models/qwen2vl.py. As a result, deployments pinned to a reviewed revision still resolve these behavior-affecting processor, tokenizer, and config artifacts from the repository's default revision, so a later change to the upstream default branch can alter audio preprocessing, speech tokenizer behavior, or Tarsier2 configuration without any change to the operator's configured pin. This is a supply-chain integrity and reproducibility failure for pinned deployments; it is residual to the earlier fix tracked as GHSA-3ww4-5jv9-j5gm / CVE-2026-47155 and does not constitute remote code execution or a trustremotecode=False bypass. The issue is fixed in version 0.28.0.
vLLM before 0.29.0 fails to enforce decoder prompt-length validation on the disaggregated serving endpoint /inference/v1/generate. When the request contains a 'features' (multimodal) payload, vllm/entrypoints/serve/disagg/serving.py builds a multimodal EngineInput directly from the caller-supplied tokenids, and GenerateRequest.tokenids (vllm/entrypoints/serve/disagg/protocol.py) is not checked against modelconfig.maxmodellen. For multimodal processors that report skippromptlengthcheck=True (for example Nemotron Parse, Whisper, and FireRedLID), InputProcessor.validatepromptlen() returns immediately for both encoder and decoder prompts, so an overlong prompt becomes an EngineCoreRequest and reaches the worker input-batch copy into a fixed maxmodellen-wide NumPy row. A client able to reach the endpoint on an affected model configuration can therefore submit an overlong tokenids list to trigger a worker failure and denial of service. Fixed in 0.29.0.
vLLM versions 0.22.0 through 0.23.0 fail to validate stoptokenids against vocabulary bounds in Rust HTTP and gRPC frontends, allowing out-of-vocabulary token IDs to reach MinTokensLogitsProcessor. Attackers can submit requests with mintokens greater than zero and out-of-vocabulary stoptokenids to trigger CUDA tensor indexing failures that leave EngineCore in a fatal state requiring service restart.
vLLM through 0.29.0 fetches and fully materializes remote or inline media before enforcing its documented media controls (the VLLMMAXAUDIOCLIPFILESIZEMB compressed-audio size cap, default 25 MB, and the per-modality --limit-mm-per-prompt item limits). Across four ingress paths — the shared media-acquisition layer (HTTPConnection.getbytes()/asyncgetbytes()), the chat completions audiourl/base64 path, the batch speech runner, and the Rust frontend POST /tokenize route — the server reads the entire HTTP response body, base64-decodes the inline payload, or spawns one fetch/decode task per media part, and only then applies the limit (or, on some paths, never applies it). A remote attacker can therefore cause the API server or batch-runner process to allocate memory and consume outbound bandwidth proportional to an attacker-chosen body size or media item count before the request is rejected, resulting in pre-inference memory and bandwidth exhaustion (denial of service). The chat and batch surfaces require an API key when one is configured; the Rust frontend /tokenize route is unauthenticated by design. There is no code execution or data disclosure impact.
vLLM before 0.29.0 contains a resource-limit bypass vulnerability in PyNvVideoCodec decoder allocation where sampler subclass shadowing allows independent counter increments. Unauthenticated attackers can select different sampler subclasses in video requests to exceed configured decoder limits and exhaust unaccounted GPU memory.
vllm before 0.29.0 fails to enforce VLLMMAXAUDIOCLIPFILESIZEMB limit in multimodal chat audio decoding, allowing unauthenticated clients to bypass file size restrictions. Attackers can submit oversized audio files through chat endpoints to consume excessive memory and CPU resources during decoding.
vLLM versions before 0.29.0 contain a denial-of-service vulnerability in the cachesalt parameter accepted on OpenAI-compatible and Anthropic API endpoints, which lacks maximum length validation and is processed on the single EngineCore scheduler thread. Unauthenticated attackers can send HTTP requests with multi-hundred-megabyte salt values that trigger expensive pickle serialization and SHA-256 hashing, stalling the scheduler thread and denying service to all concurrent requests.
vLLM Mooncake connector through 0.29.0 fails to properly manage GPU KV cache block ownership when concurrent child requests share a single transfer ID in prefill/decode disaggregated deployments. Attackers can trigger GPU memory exhaustion by submitting completion requests with multiple prompts, causing orphaned KV cache blocks to accumulate until process restart and eventually preventing legitimate requests from executing.
vLLM through 0.29.0 fails to validate the tpsize parameter in kvtransferparams on OpenAI-compatible completion endpoints, allowing attackers to allocate unbounded memory. Attackers can supply arbitrary tpsize values in prefill/decode disaggregated deployments to exhaust memory and trigger kernel OOM-kill of the decode worker process.
vLLM through 0.29.0 contains a resource exhaustion vulnerability in MooncakeConnector where rejected prefill requests create ownerless transfer placeholders that are never reclaimed. Attackers can send rejected requests to exhaust sender task pools, causing valid requests to be delayed by up to 480 seconds while health checks continue returning success.
vLLM through 0.29.0 contains a denial of service vulnerability in P2P KV offloading when OffloadingConnector is configured with TieringOffloadingSpec and a peer-to-peer secondary tier. Attackers can supply arbitrary remote host and port values in kvtransferparams to create unreachable peer sessions that retain ZeroMQ sockets until the context quota is exhausted, causing an uncaught ZMQError that crashes EngineCore and stops all inference.
vLLM through 0.29.0 contains a denial of service vulnerability in the NIXL connector's prefix caching implementation that fails to properly validate block counts across multi-prompt completion requests in prefill/decode disaggregated deployments. Attackers can trigger an assertion failure in NixlBaseConnectorWorker.applyprefixcaching by submitting completion requests with multiple prompts of varying lengths, causing the decode worker to terminate and become unavailable until restarted.
vLLM versions through 0.29.0 contain a denial of service vulnerability in the NIXL connector's metadata handling for prefill/decode disaggregated deployments. Attackers can send requests with incomplete kvtransferparams dictionary entries to trigger an uncaught KeyError in EngineCore scheduling, causing the decode engine to terminate and making all routed requests fail until manual restart.
vLLM through 0.29.0 fails to properly validate badwords token indices against the model's generation output width in SamplingParams.updatefromtokenizer(). Attackers can supply out-of-bounds token indices that corrupt logits memory of concurrent requests, causing different in-flight HTTP requests to return incorrect tokens.
vLLM before 0.29.0 validates allowedtokenids against tokenizer length instead of model output logits width in SamplingParams.validateallowedtokenids(). Attackers can supply token IDs above the output vocabulary that pass validation, causing LogitBiasState to corrupt GPU logits state and allow concurrent requests to sample tokens outside their allowlists.
vLLM through 0.29.0 contains a memory corruption vulnerability in the Triton bincountkernel where prompt token IDs index the penalty prompt-presence bitset without bounds checking against vocabulary size. Attackers can submit multimodal audio requests with tokens equal to vocabulary size, causing out-of-bounds writes that corrupt concurrent requests' sampler state and alter repetition penalty behavior.
vLLM versions before 0.28.0 fail to validate the lower bound of token IDs in the /v1/embeddings and /pooling endpoints, allowing unauthenticated attackers to crash the engine by submitting negative token IDs. A single request with a negative token ID triggers a CUDA device-side assertion that poisons the GPU context, causing all subsequent requests to fail until the process restarts.