-Infinity
0

Vendor Risk Score

See how vllm compares to other vendors in security performance

View Risk Score →
Severity
7.5
AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H

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.

First published (updated )
Severity
5.3
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
Input Validation
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

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.

First published (updated )
Severity
5.3
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L

vLLM versions >= 0.6.3 and < 0.9.0 contain multiple regular expression denial of service (ReDoS) vulnerabilities. Several regex patterns — in vllm/lora/utils.py, the phi4mini tool parser, and the OpenAI-compatible serving chat endpoint — are susceptible to catastrophic backtracking. An attacker submitting crafted input with nested or repeated structures can trigger severe CPU consumption and performance degradation, resulting in denial of service.

First published (updated )
Severity
2.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L/E:P/RL:X/RC:C

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.

First published (updated )
Severity
6.5
Input Validation, Incorrect Type Cast
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

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

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

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).

1 / 2
Source: GitHub
First published (updated )
Severity
3.1
Infoleak
AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N

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

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

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

1 / 2
Source: GitHub
First published (updated )
Severity
4.2
AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:L

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

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
Input Validation
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

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

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
Input Validation
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

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

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
Out-of-bounds Read
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

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).

First published (updated )
Severity
6.5
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

Summary The audio decode-duration guard (maxdurations, env VLLMMAXAUDIODECODEDURATIONS, default 600s) that protects against audio decompression-bomb DoS is wired into only the speech-to-text path (/v1/audio/transcriptions). The chat audio path (/v1/chat/completions, inputaudio content parts) calls the same decoder with no limit, so an unauthenticated client can submit a few-KB compressed audio file that expands to multiple GB of float32 PCM at decode time, OOM-killing the worker. This is a distinct sibling of CVE-2026-5497 (video frame-count bomb, VideoMediaIO.loadbase64) and GHSA-pq5c-rjhq-qp7p (image) in the same media subsystem.

Verified against main at HEAD d78650c (2026-06-16); applicable to the latest release v0.23.0.

Details The guard rejects long audio during decode (before allocation), implemented in vllm/multimodal/media/audio.py: - loadaudiopyav — metadata reject (~82-98) and live sample-count reject (~129-136) - loadaudiosoundfile — frames reject (~165-174)

All are gated on if maxdurations is not None.

It is passed in exactly one place — the transcription serving layer: python .../speechtotext/base/serving.py:~170-174 loadaudio(buf, sr=..., maxdurations=self.maxaudiodecodedurations) self.maxaudiodecodedurations = envs.VLLMMAXAUDIODECODEDURATIONS (default 600)

The chat path never threads it: python vllm/multimodal/media/audio.py:237-238 def loadbytes(self, data: bytes) -> tuple[npt.NDArray, float]: return loadaudio(BytesIO(data), sr=None) # no maxdurations -> every guard above is skipped

Unauthenticated reachability chain (chat): parseinputaudio (chatutils.py) -> parseaudio -> connector.fetchaudio -> AudioMediaIO.loaddataurl -> loadbase64 -> loadbytes -> loadaudio(..., sr=None). The connector never passes maxdurations, and inline data: URLs need no HTTP fetch (so VLLMAUDIOFETCHTIMEOUT does not bound them). The OpenAI-compatible server has no auth by default (auth only when --api-key / VLLMAPIKEY is set).

Impact Unauthenticated remote denial of service (availability) via memory amplification on a default-no-auth endpoint, on any deployment serving an audio-capable model. Same class and impact as the sibling CVE-2026-5497 (video). CWE-770 / CWE-409.

Fix A fix was introduced in this MR: https://github.com/vllm-project/vllm/pull/45908

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
SSRF
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

Summary

Current vLLM main lets an inference request choose the PyNvVideoCodec GPU video decoder through mediaiokwargs.video.videobackend, but engine GPU memory reservation is computed only from static startup configuration and VLLMVIDEOLOADERBACKEND. If the server starts with the default OpenCV/software backend and no --mm-ipc-gpu-memory-gb budget, a client can still route a video request into the PyNvVideoCodec path after startup, causing frontend CUDA-context, decoder-surface, and decoded-frame GPU allocations that were not carved out of the engine KV-cache budget.

Technical Details

The vulnerable boundary is the split between request-time media decoding choices in the API server and startup-time memory budgeting in the engine worker. Request bodies for Chat Completions and Responses expose mediaiokwargs, and those values are forwarded to the shared media connector. For video inputs, MediaConnector.fetchvideo() copies self.mediaiokwargs["video"] into videoiokwargs, only setting a model-derived backend when videobackend is absent. VideoMediaIO.init() then consumes videobackend from those kwargs and loads that backend from VIDEOLOADERREGISTRY.

The relevant request-side source path is:

python videoiokwargs = dict(self.mediaiokwargs.get("video", {})) if "videobackend" not in videoiokwargs and ( videobackend := getvideoloaderbackendforprocessor(videoprocessor) ): videoiokwargs["videobackend"] = videobackend videoio = VideoMediaIO(imageio, videoiokwargs)

python videoloaderbackend = ( kwargs.pop("videobackend", None) or envs.VLLMVIDEOLOADERBACKEND ) self.videoloader = VIDEOLOADERREGISTRY.load(videoloaderbackend)

VideoBackend.loadbytes() then dispatches backend == "pynvvideocodec" into decodeframespynvvideocodec(), which constructs a PyNvVideoCodec decoder, creates or uses a CUDA stream, reads stream metadata, decodes selected frames on the GPU, and copies those frames into pinned host memory. The new frontend GPU memory pool accounts only for raw decoded frame bytes when a pool exists; it does not make request-time backend selection safe when no startup reservation was made.

The engine-side reservation code makes its decision from static model config and environment only:

python def usespynvvideocodecvideobackend(mmconfig) -> bool: videokwargs = mmconfig.mediaiokwargs.get("video", {}) videoloaderbackend = ( videokwargs.get("videobackend") or envs.VLLMVIDEOLOADERBACKEND ) codecbackend = videokwargs.get("backend") return ( videoloaderbackend == PYNVVIDEOCODECVIDEOBACKEND or codecbackend == PYNVVIDEOCODECVIDEOBACKEND )

python decoderreservedbytes = ( numapiservers perserverdecoderbytes if self.usespynvvideocodecvideobackend(mmconfig) else 0 ) reservedbytes = rawframereservedbytes + decoderreservedbytes if reservedbytes <= 0: return availablekvcachememorybytes

With default static video configuration, mmconfig.mediaiokwargs["video"] does not name PyNvVideoCodec and VLLMVIDEOLOADERBACKEND defaults to OpenCV/software decoding. The worker therefore reserves no PyNv decoder/CUDA-context bytes. A later request can still set mediaiokwargs.video.videobackend="pynvvideocodec" and reach the GPU decoder path because that runtime field is intentionally honored by VideoMediaIO.

PoV

An ordinary multimodal inference request can carry the backend override in the request body:

json { "model": "served-vlm", "messages": [ { "role": "user", "content": [ {"type": "text", "text": "summarize this clip"}, {"type": "videourl", "videourl": {"url": "data:video/mp4;base64,<small-mp4>"}} ] } ], "mediaiokwargs": { "video": { "videobackend": "pynvvideocodec" } } }

The following bounded source-level check confirms the code path without allocating GPU memory:

bash git clone --filter=blob:none https://github.com/vllm-project/vllm.git cd vllm git checkout ddd3855a28a561a5bb54d380c6e6b8b1e883cc4a python3 checkpynvbackendreservation.py --repo .

PoC

The bounded check validates current source markers, simulates the exact static reservation predicate, and compares vulnerable and negative-control configurations. Key output:

json { "vulnerable": true, "head": "ddd3855a28a561a5bb54d380c6e6b8b1e883cc4a", "reservationsimulation": { "envvideoloaderbackend": "opencv", "requestselectspynvafterstartup": true, "vulnerablestaticreservedbytes": 0, "negativecontrolstaticpynvreservedbytes": 2066953011, "rawframeonlycontrolreservedbytes": 268435456, "unreserveddecoderbyteswhenonlyrequestselectspynv": 2066953011 } }

The negative control is important: when PyNvVideoCodec is selected statically, the worker reserves 2066953011 bytes per API process for decoder surfaces plus CUDA context. The vulnerable case reserves 0 bytes for the same decoder overhead because PyNvVideoCodec is selected only by the later request. A second control with static OpenCV plus mmipcgpumemorygb=0.25 reserves only the raw-frame semaphore budget and still does not reserve PyNv decoder/CUDA-context bytes.

Impact

An attacker who can submit video requests to a vLLM deployment with PyNvVideoCodec available can force frontend GPU decoding even when the engine did not reserve memory for that decoder during startup. On high-utilization serving deployments, the unreserved CUDA context, retained decoder surfaces, and decoded-frame allocations can reduce or exhaust GPU memory that the engine assumed was available for weights, activations, or KV cache, causing request failures, worker crashes, or service-level denial of service.

Suggested severity is Medium with conservative CVSS v3.1 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H (6.5). If a deployment exposes the affected API without authentication, PR:N would raise the deployment-specific score. Suggested weaknesses are CWE-770 (Allocation of Resources Without Limits or Throttling) and CWE-400 (Uncontrolled Resource Consumption). This should not be rated Low because the affected resource is shared GPU memory in the serving path and the code already treats the PyNv decoder/CUDA-context footprint as large enough to reserve at startup when statically configured.

Limitations: exploitation requires a GPU deployment where PyNvVideoCodec is installed and usable, and the request must reach a video-capable model/path. The issue does not claim code execution, data disclosure, or SSRF.

Suggested Fix

Do not allow untrusted request fields to select a GPU decoder that was not included in startup memory reservation. The simplest fix is to reject request-level mediaiokwargs.video.videobackend="pynvvideocodec" unless the static server configuration already selected PyNvVideoCodec and reserved its decoder/CUDA-context budget.

If dynamic backend selection remains supported, split software and GPU decoder policies: allow request selection among CPU/software decoders only, require an explicit operator allowlist for GPU decoders, and include every request-selectable GPU decoder in the startup reservation predicate. Add regression coverage for static OpenCV startup config plus request-level PyNvVideoCodec override, and preserve the negative control where static PyNvVideoCodec configuration reserves decoder/CUDA-context bytes.

Affected Package/Versions

Package: vllm from vllm-project/vllm.

Confirmed affected: current main at ddd3855a28a561a5bb54d380c6e6b8b1e883cc4a.

Introduced by: af16446bf39de047ab57649c933063cf1cbf1e50, Vram semaphore infra (#44465), committed 2026-06-26T17:32:51-07:00.

Release status checked: git tag --contains af16446bf returned no release tags in the fresh checkout. GitHub repository metadata reported latest published release v0.23.0 published 2026-06-15T05:27:20Z; the local v0.24.0 tag also does not contain the introducing commit. The affected range should therefore be current main builds containing af16446bf until fixed, rather than a confirmed released-version range.

Advisory History

Public vLLM advisories checked included audio decompression-bomb DoS, unbounded video/jpeg frame-count DoS, MediaConnector SSRF, video processing RCE, multimodal embedding DoS/RCE, GGUF GPU memory exposure, multimodal hashing, and other request-parameter DoS classes. None matched request-selected PyNvVideoCodec or the static VRAM reservation mismatch.

Prior local/private vLLM report families checked included request-level mediaiokwargs reopening video/jpeg frame fanout, GLM video metadata amplification, and audio media decode duration-limit bypass. Those reports share the request-level media kwargs boundary, but they target CPU/media decode limits or model metadata amplification. This report targets a different privileged asset and fix surface: GPU decoder selection after engine startup memory reservation.

Focused GitHub issue/PR searches for pynvvideocodec, mmipcgpumemory, videobackend mediaiokwargs, Vram semaphore infra, and frontend multimodal GPU decoding found the PyNvVideoCodec zero-copy RFC, an old do-not-review prototype, merged PR #44465, and an unrelated TorchCodec backend PR. No public issue or PR described this security boundary.

Appendix: Bounded Source-Level Check

python #!/usr/bin/env python3 from future import annotations

import argparse import json import re import subprocess from pathlib import Path

MIB = 1024 1024 GIB = 1024 MIB

def read(repo: Path, rel: str) -> str: return (repo / rel).readtext(encoding="utf-8")

def constint(source: str, name: str) -> int: expr = re.search(rf"^{name}\s=\s(.+)$", source, flags=re.MULTILINE).group(1).strip() if expr == "128 MiBbytes": return 128 MIB if expr == "int(1.8 1024 MiBbytes)": return int(1.8 1024 MIB) if expr == "1": return 1 raise AssertionError(expr)

def usespynvstatic(staticmediaiokwargs: dict[str, dict[str, str]], envbackend: str) -> bool: videokwargs = staticmediaiokwargs.get("video", {}) videoloaderbackend = videokwargs.get("videobackend") or envbackend codecbackend = videokwargs.get("backend") return videoloaderbackend == "pynvvideocodec" or codecbackend == "pynvvideocodec"

def reservebytes(staticmediaiokwargs, envbackend, mmipcgpumemorygb, decoderbytes, cudacontextbytes, retaineddecoders): rawframereservedbytes = int(mmipcgpumemorygb GIB) perserverdecoderbytes = decoderbytes retaineddecoders + cudacontextbytes decoderreservedbytes = perserverdecoderbytes if usespynvstatic(staticmediaiokwargs, envbackend) else 0 return rawframereservedbytes + decoderreservedbytes

parser = argparse.ArgumentParser() parser.addargument("--repo", required=True, type=Path) repo = parser.parseargs().repo.resolve()

mediavideo = read(repo, "vllm/multimodal/media/video.py") connector = read(repo, "vllm/multimodal/media/connector.py") chatprotocol = read(repo, "vllm/entrypoints/openai/chatcompletion/protocol.py") responsesprotocol = read(repo, "vllm/entrypoints/openai/responses/protocol.py") gpuworker = read(repo, "vllm/v1/worker/gpuworker.py") videocore = read(repo, "vllm/multimodal/video.py")

assert "mediaiokwargs: dict[str, dict[str, Any]] | None = Field(" in chatprotocol assert "mediaiokwargs: dict[str, dict[str, Any]] | None = Field(" in responsesprotocol assert 'videoiokwargs = dict(self.mediaiokwargs.get("video", {}))' in connector assert 'if "videobackend" not in videoiokwargs and (' in connector assert 'kwargs.pop("videobackend", None) or envs.VLLMVIDEOLOADERBACKEND' in mediavideo assert "elif backend == PYNVVIDEOCODECVIDEOBACKEND:" in videocore assert 'videokwargs = mmconfig.mediaiokwargs.get("video", {})' in gpuworker

decoderbytes = constint(videocore, "PYNVVIDEOCODECDECODERGPUMEMORYBYTES") retaineddecoders = constint(videocore, "PYNVVIDEOCODECMAXRETAINEDDECODERS") cudacontextbytes = constint(videocore, "PYNVVIDEOCODECCUDACONTEXTBYTES") perserverdecoderbytes = decoderbytes retaineddecoders + cudacontextbytes

vulnerablestaticreserved = reservebytes({}, "opencv", 0.0, decoderbytes, cudacontextbytes, retaineddecoders) negativecontrolreserved = reservebytes({"video": {"videobackend": "pynvvideocodec"}}, "opencv", 0.0, decoderbytes, cudacontextbytes, retaineddecoders) rawframeonlycontrol = reservebytes({}, "opencv", 0.25, decoderbytes, cudacontextbytes, retaineddecoders)

head = subprocess.checkoutput(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip() print(json.dumps({ "head": head, "vulnerable": vulnerablestaticreserved == 0 and negativecontrolreserved == perserverdecoderbytes, "reservationsimulation": { "envvideoloaderbackend": "opencv", "requestselectspynvafterstartup": True, "vulnerablestaticreservedbytes": vulnerablestaticreserved, "negativecontrolstaticpynvreservedbytes": negativecontrolreserved, "rawframeonlycontrolreservedbytes": rawframeonlycontrol, "unreserveddecoderbyteswhenonlyrequestselectspynv": perserverdecoderbytes, }, }, indent=2, sortkeys=True))

1 / 2
Source: GitHub
First published (updated )
Severity
6.9
Input Validation
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

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.

First published (updated )
Severity
6.9
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

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.

First published (updated )
Severity
6.3
AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L

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.

First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

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.

First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

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.

First published (updated )
Severity
8.2
Input Validation
AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H

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.

First published (updated )
Severity
5.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L/E:P/RL:O/RC:C

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.

First published (updated )
Severity
4.3
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L

Summary

The /v1/completions/derender and /v1/chat/completions/derender endpoints accept caller-supplied GenerateResponse objects and postprocess every nested choices[].tokenids list directly. Unlike the normal render/generate path, derender does not enforce model context length, resolved maxtokens, maxnumseqs, choice-count, or response-size bounds before detokenizing and returning the supplied token IDs. An authenticated API client can therefore make the CPU-only render frontend, or any server exposing these /v1 derender routes, spend CPU and memory proportional to attacker-chosen generated-output-shaped JSON rather than to a bounded generation result.

Technical Details

The render router registers /v1/chat/completions/derender and /v1/completions/derender in vllm/entrypoints/serve/render/apirouter.py, and the OpenAI API server attaches this router whenever "generate" or "render" is in supportedtasks (vllm/entrypoints/openai/apiserver.py). The routes are under /v1, so they are part of the OpenAI-compatible HTTP API surface and are protected by the API-key middleware when --api-key is configured.

The request types trust generated-output-shaped data from the client. In vllm/entrypoints/serve/disagg/protocol.py, GenerateResponseChoice accepts tokenids: list[int] | None = None, GenerateResponse accepts choices: list[GenerateResponseChoice], and DerenderCompletionRequest accepts generateresponses: list[GenerateResponse]. These fields have no max length, max item count, or relationship to a prior GenerateRequest.

The sink is OnlineDerenderer. derendercompletion() iterates every supplied generateresponses entry and every nested choice, calls tokenizer.decode(choice.tokenids, skipspecialtokens=True), appends the decoded text to the response choices, and increments totalcompletiontokens from the same supplied list length. derenderchat() has the same shape for a single supplied generateresponse, and can also feed the decoded text into tool/reasoning parsers when a parser and chatrequest are present. ServingRender.derendercompletionresponse() calls onlinederenderer.derendercompletion(request.generateresponses, request.prompttokens) before applying any completion-level validation beyond the model check.

Normal render and generation paths derive output limits from maxmodellen, the rendered prompt length, request maxtokens / maxcompletiontokens, and scheduler limits. Derender bypasses that invariant because it accepts the already-generated output shape directly from the HTTP caller. The missing invariant is: derender should only postprocess bounded generated output, and client-supplied derender payloads must be rejected if their nested generated token/logprob structures exceed the same limits that generation would have enforced.

PoV

The following bounded PoV can be run from a current vLLM checkout containing PR #43606. It asserts the current source facts for the derender routes, unchecked request fields, and decode sink, then simulates the same derender loop with a counting tokenizer. The negative control is a one-choice, 32-token response. The amplified payload keeps the test bounded but demonstrates that all decoded work and returned text scale directly with caller-supplied GenerateResponse contents.

python #!/usr/bin/env python3 import subprocess from dataclasses import dataclass from pathlib import Path

SOURCE = Path(".")

def requiresourcefact(path: str, needles: list[str]) -> None: text = (SOURCE / path).readtext() missing = [needle for needle in needles if needle not in text] if missing: raise AssertionError(f"{path} missing expected facts: {missing}")

def sourcehead() -> str: return subprocess.checkoutput(["git", "rev-parse", "HEAD"], cwd=SOURCE, text=True).strip()

@dataclass class Choice: index: int tokenids: list[int]

@dataclass class GenerateResponse: requestid: str choices: list[Choice]

class CountingTokenizer: def init(self) -> None: self.decodecalls = 0 self.decodedids = 0 def decode(self, tokenids: list[int], , skipspecialtokens: bool = True) -> str: self.decodecalls += 1 self.decodedids += len(tokenids) return "x" len(tokenids)

def derendercompletionlikecurrenthead(generateresponses: list[GenerateResponse], tokenizer: CountingTokenizer) -> tuple[int, int, int]: outputchars = 0 choices = 0 totalcompletiontokens = 0 for gen in generateresponses: for choice in gen.choices: if not choice.tokenids: raise ValueError("choice has empty or null tokenids") decodedtext = tokenizer.decode(choice.tokenids, skipspecialtokens=True) outputchars += len(decodedtext) totalcompletiontokens += len(choice.tokenids) choices += 1 return choices, totalcompletiontokens, outputchars

def makepayload(responses: int, choicesperresponse: int, tokensperchoice: int) -> list[GenerateResponse]: tokenids = [42] tokensperchoice return [GenerateResponse(requestid=f"gen-{r}", choices=[Choice(index=c, tokenids=list(tokenids)) for c in range(choicesperresponse)]) for r in range(responses)]

def runcase(name: str, payload: list[GenerateResponse]) -> None: tokenizer = CountingTokenizer() choices, completiontokens, outputchars = derendercompletionlikecurrenthead(payload, tokenizer) print(f"{name}: responses={len(payload)} choices={choices} decodecalls={tokenizer.decodecalls} decodedtokenids={tokenizer.decodedids} completiontokens={completiontokens} outputchars={outputchars}")

requiresourcefact("vllm/entrypoints/serve/render/apirouter.py", ['"/v1/completions/derender"', '"/v1/chat/completions/derender"', "app.includerouter(router)"]) requiresourcefact("vllm/entrypoints/serve/disagg/protocol.py", ["class GenerateResponseChoice(BaseModel):", "tokenids: list[int] | None = None", "class GenerateResponse(BaseModel):", "choices: list[GenerateResponseChoice]", "class DerenderCompletionRequest(BaseModel):", "generateresponses: list[GenerateResponse]"]) requiresourcefact("vllm/renderers/onlinederenderer.py", ["async def derendercompletion(", "for gen, pt in zip(generateresponses, prompttokenslist):", "for choice in gen.choices:", "decodedtext = tokenizer.decode(", "totalcompletiontokens += len(choice.tokenids)"]) print("sourcechecks=ok") print(f"sourcehead={sourcehead()}") runcase("negativecontrol", makepayload(responses=1, choicesperresponse=1, tokensperchoice=32)) runcase("amplifiedpayload", makepayload(responses=16, choicesperresponse=4, tokensperchoice=8192)) print("observation=derender decodes every caller-supplied token id before any maxmodellen, maxtokens, maxnumseqs, or response-size check")

Impact

An attacker with access to the /v1 API can send derender requests that consume CPU and memory in the frontend/postprocessing process and can cause large responses unrelated to any bounded generation. In disaggregated deployments, this affects the CPU-only render frontend; in servers where the render router is attached alongside generation, it affects the same OpenAI-compatible server process that handles normal client traffic. This can degrade availability for other clients sharing the process.

Likely CWE: CWE-400 (Uncontrolled Resource Consumption) / CWE-770 (Allocation of Resources Without Limits or Throttling). Conservative CVSS v3.1: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L (4.3). This is not Low severity because a regular network API client can induce availability impact in a shared service without local access, invalid model artifacts, or special runtime privileges. If the server is deployed without API-key enforcement for /v1, the privileges component becomes PR:N.

Suggested Fix

Validate derender payloads before any detokenization or parser invocation. Apply bounded limits to generateresponse(s), choices, tokenids, promptlogprobs, logprobs.content, toplogprobs, and routedexperts that are at least as strict as the corresponding generation-side limits. For completions, reject generateresponses counts above the number of prompts that /v1/completions/render would have produced, and reject total nested choice counts above maxnumseqs / n limits. For each choice, reject tokenids longer than the resolved output-token budget, or require derender callers to submit the original bounded GenerateRequest / sampling metadata and validate the GenerateResponse against it before decoding.

Add regression tests for both derender endpoints. The tests should show that a normal bounded derender payload succeeds, while oversized generateresponses, oversized choices, oversized tokenids, and oversized logprob/top-logprob structures are rejected before tokenizer.decode() or parser execution.

Affected Package/Versions

Confirmed affected: current main at ddd3855a28a561a5bb54d380c6e6b8b1e883cc4a and downstream/nightly builds that include the derender endpoints introduced by PR #43606. The derender router, request models, decode sink, render serving bridge, and OpenAI API router attachment have no relevant diff from 00e045b7c7b82599f626779e111233abd4d0a64e to ddd3855a28a561a5bb54d380c6e6b8b1e883cc4a.

Latest release checked: v0.23.0, published on 2026-06-15. Its vllm/entrypoints/serve/render/apirouter.py does not expose /v1/completions/derender or /v1/chat/completions/derender, so v0.23.0 was not confirmed affected.

Advisory History

PR #43606 ("[Render] Add /derender endpoints for disaggregated postprocessing") introduced the derender endpoints on main. PR #44285 later refactored the render serving code, and current head still contains the unchecked derender flow.

Public issue search for derender GenerateResponse tokenids returned no reports. Public search for "/v1/completions/derender" returned the derender feature RFC #42729 and unrelated bugs, but no size-bound, DoS, or generated-output postprocessing issue.

Related public request-fanout and resource-bound advisories are distinct:

- GHSA-3mwp-wvh9-7528 covers an unbounded n parameter on the normal OpenAI completion/chat generation routes. Its root cause is missing upper-bound validation for generated sequence count, its sink is request fanout and request-object copying into the async engine path before scheduling, its precondition is a caller-controlled n, and its fix surface is a cap on generated sequence count. This report reaches /v1/completions/derender and /v1/chat/completions/derender, not the normal generate routes; its root cause is unchecked caller-supplied GenerateResponse / choices / tokenids structures, its sink is OnlineDerenderer detokenization and response construction after generation, its precondition is access to the derender API with generated-output-shaped JSON, and its fix surface is derender payload validation before decode. - PR #45390 includes the GHSA-83mh-6mwq-3hg9 batch-message fanout fix class: it bounds the outer BatchChatCompletionRequest.messages conversation list to prevent one request from creating many conversation/request objects before normal generation. This report has no batch conversation list and does not rely on n; one derender request can instead supply oversized nested GenerateResponse choices and token IDs that are detokenized and returned directly. A batch-message maxlength limit would not bound derender generateresponse(s) or per-choice token/logprob structures.

The completed local report titled "Explicit truncationside disables tokenizer-level prompt truncation" is also distinct. That report used /v1/completions and /v1/chat/completions with ordinary prompt text plus truncateprompttokens and explicit truncationside; its root cause was the renderer omitting tokenizer-level maxlength and the pre-tokenization character guard before post-token slicing; its sink was prompt tokenization; and its fix surface was preserving tokenizer-level truncation or rejecting over-budget prompts before tokenization. This derender report uses /v1 derender routes, has no prompt text tokenization or truncation-side control, starts from caller-supplied generated-output token IDs, and needs aggregate bounds on derender generateresponse(s), choices, token IDs, logprobs, parser inputs, and response construction before detokenization.

Other adjacent vLLM advisories for Rust/gRPC token-id and logprob bounds, structured-output grammar amplification, repetition-detection windows, and pooling/rerank batch fanout are distinct. Those issues affect Rust/gRPC request conversion, grammar compilation, scheduler loops, or engine fanout. This issue affects /v1 derender postprocessing of caller-supplied generated-output objects and requires derender-specific request validation before detokenization.

Resources

- vllm/entrypoints/serve/render/apirouter.py - vllm/entrypoints/serve/disagg/protocol.py - vllm/renderers/onlinederenderer.py - vllm/entrypoints/serve/render/serving.py - vllm/entrypoints/openai/apiserver.py - PR #43606: https://github.com/vllm-project/vllm/pull/43606 - PR #44285: https://github.com/vllm-project/vllm/pull/44285 - GHSA-3mwp-wvh9-7528: https://github.com/vllm-project/vllm/security/advisories/GHSA-3mwp-wvh9-7528 - PR #45390: https://github.com/vllm-project/vllm/pull/45390 - Release v0.23.0: https://github.com/vllm-project/vllm/releases/tag/v0.23.0

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

Summary

When the vLLM API receives a malformed request (e.g., invalid JSON or missing required fields), FastAPI raises a Pydantic RequestValidationError. The validationexceptionhandler in vllm/entrypoints/openai/serverutils.py converts this exception to a string via str(exc), which includes the internal file path and line number of the handler function. The existing sanitizemessage() function in vllm/entrypoints/utils.py strips memory addresses (e.g., 0x7f...) but does not strip File "...", line X patterns. The result is a user-facing HTTP response that leaks internal system information.

Impact

An unauthenticated attacker can extract the following with a single malformed request:

- OS username running the vLLM process (e.g., ubuntu) - Home directory path (e.g., /home/ubuntu/) - Virtual environment path (e.g., vllm-env/) - Python version (e.g., 3.12) - Internal package structure and line numbers (e.g., vllm/entrypoints/openai/chatcompletion/apirouter.py) - Handler function names per endpoint, enabling precise version fingerprinting

This information aids attackers in constructing targeted exploits: environment paths narrow the attack surface, and handler function names + line numbers enable exact version identification even when the /version endpoint is disabled.

All POST endpoints that accept JSON bodies are affected, including /v1/chat/completions, /v1/completions, /tokenize, and /detokenize.

Workarounds

Deploying vLLM behind a reverse proxy that rewrites error response bodies to strip file paths would mitigate this, though it is fragile.

Remediation Recommendation

Two possible fixes (either suffices):

Option A — Fix validationexceptionhandler: Construct the error message from exc.errors() (the structured Pydantic error list) rather than str(exc). This avoids the traceback-style string entirely.

Option B — Fix sanitizemessage: Add a regex to strip File "...", line \d+ patterns, similar to how memory addresses are already stripped:

python import re msg = re.sub(r'File ".?", line \d+, in \w+', '[internal]', msg)

Option A is preferred as it addresses the root cause rather than filtering symptoms.

Environment Tested

- vLLM 0.20.1 (pip install, latest stable as of May 2026) - Python 3.12 - Ubuntu 22.04 - Model: Qwen/Qwen2-0.5B (text-only; bug is model-independent)

This was fixed here: https://github.com/vllm-project/vllm/commit/e87521626f

1 / 2
Source: GitHub
First published (updated )
Severity
8.3
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N

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.

First published (updated )
Severity
6.9
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

vLLM before 0.27.0 fails to properly classify DeepStream as a GPU backend and omits pixel-limit enforcement in its decode path. Unauthenticated attackers can activate DeepStream at request time to initialize the process-wide GPU decode pool and submit video that bypasses resource controls, causing partial denial of service for concurrent requests.

First published (updated )
Severity
7.5
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

vLLM up to and including 0.17.0 allows remote attackers to cause a Denial of Service via memory exhaustion. The AsyncMediaIO.fetchaudio and AsyncMediaIO.fetchimage functions in multimodal/inputs.py fetch user-supplied media URLs using aiohttp and call r.read() without enforcing a maximum response size, allowing an attacker to exhaust server memory by providing a URL to an arbitrarily large file.

First published (updated )
Severity
8.7
EPSS
0.63%
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

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.

First published (updated )
Severity
8.7
EPSS
0.63%
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

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.

First published (updated )
Severity
8.7
EPSS
0.63%
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

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.

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203