vLLM through 0.29.0 fails to validate the tpsize parameter in kvtransferparams on OpenAI-compatible completion endpoints, allowing attackers to allocate unbounded memory. Attackers can supply arbitrary tpsize values in prefill/decode disaggregated deployments to exhaust memory and trigger kernel OOM-kill of the decode worker process.
vLLM before 0.29.0 validates allowedtokenids against tokenizer length instead of model output logits width in SamplingParams.validateallowedtokenids(). Attackers can supply token IDs above the output vocabulary that pass validation, causing LogitBiasState to corrupt GPU logits state and allow concurrent requests to sample tokens outside their allowlists.
vLLM through 0.29.0 contains a resource exhaustion vulnerability in MooncakeConnector where rejected prefill requests create ownerless transfer placeholders that are never reclaimed. Attackers can send rejected requests to exhaust sender task pools, causing valid requests to be delayed by up to 480 seconds while health checks continue returning success.
vLLM through 0.29.0 fails to properly validate badwords token indices against the model's generation output width in SamplingParams.updatefromtokenizer(). Attackers can supply out-of-bounds token indices that corrupt logits memory of concurrent requests, causing different in-flight HTTP requests to return incorrect tokens.
vLLM through 0.29.0 contains a denial of service vulnerability in P2P KV offloading when OffloadingConnector is configured with TieringOffloadingSpec and a peer-to-peer secondary tier. Attackers can supply arbitrary remote host and port values in kvtransferparams to create unreachable peer sessions that retain ZeroMQ sockets until the context quota is exhausted, causing an uncaught ZMQError that crashes EngineCore and stops all inference.
vLLM Mooncake connector through 0.29.0 fails to properly manage GPU KV cache block ownership when concurrent child requests share a single transfer ID in prefill/decode disaggregated deployments. Attackers can trigger GPU memory exhaustion by submitting completion requests with multiple prompts, causing orphaned KV cache blocks to accumulate until process restart and eventually preventing legitimate requests from executing.
vLLM 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.
Summary
vllm/transformersutils/processors/mimov2omni.py — the multimodal processor for MiMoV2OmniForCausalLM — issues requests.get(...) directly on user-supplied image and audio URL strings and Image.open(...) on user-supplied local paths, without the SSRF / allowedlocalmediapath checks that vllm.multimodal.utils.MediaConnector was hardened with in GHSA-qh4c-xf7m-gxfc, GHSA-v359-jj2v-j536, and GHSA-pf3h-qjgv-vcpr.
This is the same bug class as those three published advisories, in a code path the patches missed. When a user passes a URL or local-file string through multimodaldata (e.g. LLM.generate(multimodaldata={"image": "http://..."})), the processor takes the unsanitized string and dispatches it without any URL-scheme allowlist, network-target allowlist, size cap, or local-path allowlist.
Details
File: vllm/transformersutils/processors/mimov2omni.py (current main)
Sink 1 — image SSRF + local-file read (fetchimage, lines 231–249):
python def fetchimage(src: Any) -> Image.Image: if isinstance(src, Image.Image): return torgb(src) if isinstance(src, bytes): return torgb(copy.deepcopy(Image.open(BytesIO(src)))) if isinstance(src, str): if src.startswith(("http://", "https://")): r = requests.get(src, timeout=30) # SSRF: no allowlist, follows redirects r.raiseforstatus() return torgb(copy.deepcopy(Image.open(BytesIO(r.content)))) if src.startswith("file://"): return torgb(Image.open(src[7:])) # arbitrary local file read if src.startswith("data:image"): ... return torgb(Image.open(src)) # fallback also opens local files raise ValueError(f"Unrecognized image source: {type(src)}")
Sink 2 — audio SSRF (around line 471):
python elif audio.startswith(("http://", "https://")): r = requests.get(audio, timeout=30) # SSRF: same pattern r.raiseforstatus() fileobj = io.BytesIO(r.content)
Reachability. fetchimage is invoked from MiMoVLProcessor.processimage:
python def processimage(self, image: ImageInput) -> torch.Tensor: kw = self.resolveimgkw(image) src = image.image if isinstance(src, (str, bytes)): src = fetchimage(src) ...
MiMoVLProcessor is wrapped by MiMoV2OmniMultiModalProcessor and registered for the MiMoV2OmniForCausalLM model architecture (vllm/modelexecutor/models/mimov2omni.py:1169). Whenever a user passes a string into multimodaldata["image"] (or ["audio"]) for this model, the unsanitized URL/path reaches the sink.
Comparison to the recent fixes. The remediation pattern adopted in the three earlier advisories was to route every external resource fetch through MediaConnector, which checks allowedlocalmediapath and applies SSRF protection before issuing the network request. chatutils.py (lines 838, 902, 924, 963, 1053, 1081) already uses self.connector.fetchimage / fetchaudio / fetchvideo. The model processor in mimov2omni.py was added later and skipped the connector — it calls requests.get and Image.open directly. Result: the public OpenAI chat-completion path is protected, but library use (LLM.generate(multimodaldata=...)), batch processing, and any other path that lets a string reach the processor receive no protection.
Impact
1. SSRF — internal-network probing / cloud-metadata theft. Standard requests.get follows redirects and accepts any URL. An attacker who controls a multimodaldata value can: - read AWS / GCP / Azure instance metadata (e.g. http://169.254.169.254/latest/meta-data/iam/security-credentials/), - probe internal services on the vLLM host (http://127.0.0.1:<port>, http://10.x.y.z), - exfiltrate via DNS / HTTP timing oracles even when the body is rejected by Image.open. 2. Arbitrary local file read via file://path (line 242) and the unguarded fallback Image.open(src) (line 248). Any file readable by the vLLM process is reachable through the model pipeline; with suitable formats this exposes /etc/passwd, ~/.aws/credentials, etc. 3. Server-side traffic generation / amplification by hammering arbitrary URLs from the vLLM host, with a 30-second timeout per request.
Suggested remediation
Replace direct requests.get and bare Image.open paths with MediaConnector.fetchimage / fetchaudioasync (or pass the inputs through MediaConnector before they reach the processor):
python vllm/transformersutils/processors/mimov2omni.py from vllm.multimodal.utils import MediaConnector
connector = MediaConnector()
def fetchimage(src): if isinstance(src, Image.Image): return torgb(src) if isinstance(src, bytes): return torgb(copy.deepcopy(Image.open(BytesIO(src)))) if isinstance(src, str): return torgb(connector.fetchimage(src)) # delegates to the hardened path raise ValueError(f"Unrecognized image source: {type(src)}")
Same change for the audio loader at line 471. This re-uses the SSRF allowlist, allowedlocalmediapath policy, and size caps that the previous patches added.
Alternative: forbid str src from reaching the processor and require all multi-modal pre-processing to go through chatutils.py / MediaConnector before hitting the model. Larger surface change, but completes the architectural fix.
Discovery
Static review on vllm@main (HEAD as of 2026-04-30) — found by triaging the file list against the three recent SSRF advisories: the mimov2omni.py processor, added after those fixes, reintroduced the same bypass class.
Reporter
Ievgen Bondarenko — sactransport2000@gmail.com — GitHub @ibondarenko1
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))
vLLM versions before 0.28.0 fail to validate audio sample rate headers in the transcription endpoint, allowing authenticated clients to bypass duration checks. Attackers can submit forged FLAC headers with inflated sample rates to trigger excessive memory allocation and crash the API server process affecting all tenants.
A security flaw has been discovered in vllm-project vLLM up to 0.29.0. The affected element is the function TiktokenTokenizer::new of the file rust/src/text/src/backend/hf/mod.rs of the component tiktoken vocab File Handler. The manipulation results in denial of service. The attack is only possible with local access. The exploit has been released to the public and may be used for attacks. The pull request to fix this issue awaits acceptance.
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.
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
Summary An integer overflow in the actandmulkernel kernel can cause the output of one user request to be incorporated into the response of another request within the same inference batch. Under certain conditions, the last request in a batch can receive a partial or complete copy of the first user's inference result, resulting in cross-user data leakage.
Details The root cause is an integer overflow in the expression blockIdx.x 2 d at https://github.com/vllm-project/vllm/blob/ff712f6447093d07747c88680b9d006b119f5890/csrc/activationkernels.cu#L82.
As a result, the computation for one user (User A) can incorrectly consume input data from another user (User B). In particular, when 2^32 is divisible by d, the overflow can cause User A's output to contain portions of User B's inference result. In some cases, User B's response may be copied entirely into User A's response.
This constitutes a severe cross-user information disclosure vulnerability and is straightforward to trigger. PoC We reproduced the issue using meta-llama/Llama-3.2-1B-Instruct, for which d = 8192.
Using the following configuration:
Batch size: 17 Sequence length: 16384
The final response in the batch becomes an exact copy of the first response in the batch, demonstrating complete cross-user data leakage.
Impact This vulnerability enables cross-user information disclosure. An attacker can intentionally craft requests that are processed within the same inference batch as a victim's request and cause the victim's inference output to be copied into the attacker's response.
As a result, sensitive information contained in another user's model response may be exposed to an unauthorized party.
Versions
For versions prior and equal to 0.21.0, the bug is in csrc/activationkernels.cu, and for versions later than 0.21.0, the bug is in csrc/libtorchstable/activationkernels.cu.
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
Executive Summary
The follow-up protection for CVE-2025-62164 is incomplete at vLLM revision 26587f9519e22a5c4549ead7595ad9ca3229c4fd. It wraps serialized prompt-embedding reconstruction and dense conversion in torch.sparse.checksparsetensorinvariants(), but PyTorch 2.11.0 implements that context with save/enable/restore operations over process-global state. Two prompt-embedding parts in one /v1/chat/completions request are gathered concurrently on the event loop's default executor. When one context exits before the other loads its tensor, it can restore the global flag to False while the second part remains inside its guard.
In a deterministic run against hash-verified source from the affected revision, the actual target loader rejected an invalid sparse payload as a negative control. The frozen chat tracker then scheduled benign and malicious parts on distinct asyncio0 and asyncio1 threads. The benign context exited, the malicious loader observed the invariant flag disabled, and torch.load(weightsonly=True) reconstructed indices [[10], [10]] for a declared shape of [3, 3]. The run intercepted the target's todense() call before it operated on the invalid tensor.
This primary trigger requires --enable-prompt-embeds, which is default-off, but it does not require renderernumworkers > 1, a multimodal model, or --enable-mm-embeds. API authentication is optional in the stock server: middleware is installed only when CLI or environment API keys are supplied.
The lab proves bypass of the follow-up guard, invalid sparse reconstruction, and guarded-sink reachability. Crash and memory-corruption consequences are conditional on the behavior documented by the published CVE.
Background
CVE-2025-62164 / GHSA-mrw7-hf4f-83pf concerns client-controlled serialized promptembeds reaching torch.load(weightsonly=True) and an invalid sparse tensor reaching todense(). The advisory attributes memory corruption, denial of service, and potential code execution to that historical unsafe operation.
The remediation chronology matters for duplicate handling:
- PR #27204, merge commit 58fab50d82838d5014f4a14d991fdb9352c9c84b on 2025-10-22, introduced the default-off enablepromptembeds gate. It did not add the sparse-invariant context. - Commit 84e23d103d3483f944780d0d42bcf0993fd27e3a on 2025-12-15, titled additional protection for CVE-2025-62164 (#30649), added the process-global sparse-invariant context around load, type check, and dense conversion. - Refactor commit f0a1c8453ad1c664c8a04c83fe545195fcd556eb on 2026-01-31 moved the guarded loader into vllm/renderers/embedutils.py while preserving the same context. - Chat content-part commit 14043dfecd35dd2f12b4d51eb9fa166184a0ca0f on 2026-05-01 introduced promptembeds chat parts and the concurrent one-request schedule described here.
This report therefore does not present the malformed sparse payload or todense() sink as new. It reports a distinct concurrency root cause and trigger: unsynchronized save/enable/restore of the process-global follow-up guard, reachable through the later multi-part chat scheduler.
The affected revision pins PyTorch 2.11.0 in pyproject.toml:10.
Vulnerability Details
The target's safeloadpromptembeds performs the guarded operation in vllm/renderers/embedutils.py:16-39:
python with torch.sparse.checksparsetensorinvariants(): tensor = torch.load( BytesIO(pybase64.b64decode(embed, validate=True)), weightsonly=True, maplocation=torch.device("cpu"), ) if not isinstance(tensor, torch.Tensor): raise VLLMValidationError(...) tensor = tensor.todense()
The context is not request-local. With the global flag initially disabled, we can describe the verified interleaving:
1. Benign part A enters, saves False, and enables the flag. 2. Malicious part B enters, saves True, and leaves the flag enabled. 3. A completes its load and exits, restoring its saved False value. 4. B remains lexically inside its context but observes the actual global flag as False. 5. B's torch.load(..., weightsonly=True) reconstructs the malformed sparse tensor. 6. The target reaches tensor.todense() before later rank, hidden-size, and dtype checks.
weightsonly=True constrains deserialization types; it does not compensate for a sparse invariant check that another request has disabled.
The complete stock actor-to-sink chain, traced in the affected source, is:
POST /v1/chat/completions (vllm/entrypoints/openai/chatcompletion/apirouter.py:41-61) -> OpenAIServingChat.createchatcompletion -> createchatcompletion -> renderchatrequest (vllm/entrypoints/openai/chatcompletion/serving.py:206-280) -> OnlineRenderer.renderchat (vllm/renderers/onlinerenderer.py:95-190) -> preprocesschat (vllm/renderers/onlinerenderer.py:335-380) -> BaseRenderer.renderchatasync (vllm/renderers/base.py:1070-1105) -> HfRenderer.rendermessagesasync (vllm/renderers/hf.py:1049-1085) -> parsechatmessagesasync (vllm/entrypoints/chatutils.py:1911-1945) -> content-part parsepromptembeds and loadpromptembedsasync (vllm/entrypoints/chatutils.py:1099-1120) -> AsyncMultiModalItemTracker.resolveitems (vllm/entrypoints/chatutils.py:818-835) -> asyncio.gather of both prompt parts -> safeloadpromptembedsasync -> makeasync -> loop.runinexecutor(executor=None, ...) (vllm/utils/asyncutils.py:28-45) -> guarded torch.load -> todense().
The prompt async helper is created without an explicit executor, so it uses the event loop's default executor. This path is separate from the renderer's configurable pool. The deterministic scheduler run observed the two parts on distinct default-executor threads while leaving renderernumworkers at its default of one.
promptembeds bypasses multimodal processing, and the tracker explicitly permits it when ismultimodalmodel=False (vllm/entrypoints/chatutils.py:793-837). Consequently, the primary trigger needs neither a multimodal model nor enablemmembeds.
The source also states that async wrappers must be thread-safe (vllm/utils/asyncutils.py:28-38), while a target test acknowledges that the sparse flag is not thread-local and concurrent users can leak state (tests/renderers/testsparsetensorvalidation.py:58-61).
Exploitability Analysis
The following evidence labels separate what was demonstrated from what remains conditional:
| Label | Claim | | --- | --- | | Verified by run | PyTorch 2.11.0 rejects the identical invalid payload through the actual target loader without the race. | | Verified by run | The hash-verified frozen tracker schedules two prompt parts on distinct default-executor threads, races the flag to False, reconstructs the invalid sparse tensor, and reaches the target todense() call while the interception prevents execution. | | Traced in source | A client can supply multiple promptembeds content parts through the stock /v1/chat/completions route and the function chain above. | | Traced in source | enablepromptembeds defaults to False (vllm/config/model.py:255-260), so the operator must opt in. enablemmembeds and non-default renderer workers are not preconditions for this path. | | Traced in source | apikey defaults to None (vllm/entrypoints/openai/cliargs.py:264), and authentication middleware is installed only when a CLI or environment key is present (vllm/entrypoints/openai/apiserver.py:306-310). With a configured key, the attacker must authenticate; without one, the stock route has no API-key middleware. | | Unrun | A live HTTP/GPU server, real-world race win rate, unsafe dense conversion, process crash, memory corruption, and reliable code execution. |
The feature is documented for trusted users, which narrows intended exposure. It is not a memory-safety boundary: a user authorized to submit embedding inputs should not be able to disable a process-wide invariant for concurrent work.
The current run proves the same invalid sparse object can cross the guard and reach the historical sink. If executing that sink retains the behavior described in CVE-2025-62164 for the deployed PyTorch build, denial of service or memory corruption may follow. This is a conditional impact statement, not a reproduced outcome. Reliable RCE is not claimed.
The opt-in feature, scheduling requirement, and absence of a measured live win rate support Medium/P2 despite the serious historical sink class. No additional deployment assumptions are required for the one-request scheduler beyond stock default-executor concurrency being available.
Remediation
The immediate fix is one shared process-wide lock around every use of this process-global sparse guard. The lock must cover invariant enabling, deserialization, tensor type validation, and dense conversion:
python with sharedsparseloadlock: with torch.sparse.checksparsetensorinvariants(): tensor = torch.load(..., weightsonly=True, maplocation="cpu") validatetensortype(tensor) tensor = tensor.todense()
Every prompt, image, and audio loader that manipulates the same global flag must use the same lock. A lock only around torch.load, separate per-loader locks, or a lock omitted from the chat helper would leave overlapping save/restore sequences possible.
The stronger design is to avoid mutable process-global validation state in concurrent request code. Prefer a PyTorch per-call invariant check if one is available, or reconstruct and validate serialized embeddings inside a deliberately serialized boundary before any sparse operation.
Regression coverage should:
- Preserve the actual-target negative control using the identical malformed payload. - Force A-enter, B-enter, A-exit, B-load and assert B remains protected. - Execute the multi-part chat tracker with the event loop's default executor and renderernumworkers=1. - Cover cross-loader overlap so later prompt, image, or audio changes cannot bypass a shared fix. - Assert the global flag is restored after success and exceptions. - Reject invalid tensors before any dense conversion.
Until a fix is deployed, leaving enablepromptembeds disabled removes this stock source path.
Summary
The affected vLLM revision uses a process-global PyTorch context as the follow-up protection for CVE-2025-62164. A later chat feature causes two prompt-embedding parts from one request to run concurrently on the default executor. One context can restore the flag to False while the other is still guarded, allowing the historical malformed sparse payload class to reach the historical todense() sink. The new issue is the concurrent guard bypass and shipped trigger, not the payload or sink. Runtime validation proves the bypass and safe sink reachability on PyTorch 2.11.0; historical crash and memory-corruption effects remain conditional, and RCE was not tested or claimed.
Summary
The /v1/completions request model accepts prompt as a list of text prompts or a list of token-id prompts without any outer prompt-count bound. The serving path turns each element into a separate engine input, creates one engine generator per element, merges all generators, and allocates a response slot per prompt. An authenticated API client can therefore turn one request into an attacker-chosen number of backend subrequests before any aggregate request-count budget is enforced.
Technical Details
CompletionRequest.prompt allows both list-shaped prompt inputs and scalar prompts:
python vllm/entrypoints/openai/completion/protocol.py prompt: ( list[Annotated[int, Field(ge=0)]] | list[list[Annotated[int, Field(ge=0)]]] | str | list[str] | None ) = None
The validator only requires some prompt-like input to be present:
python def validatepromptandpromptembeds(cls, data): prompt = data.get("prompt") promptembeds = data.get("promptembeds") ... if promptisempty and embedsisempty: raise VLLMValidationError(...)
The renderer then expands list-shaped prompts as a sequence. prompttoseq() wraps a scalar string or a single token-id list, but returns a list[str] or list[list[int]] unchanged:
python vllm/renderers/inputs/preprocess.py def prompttoseq(promptorprompts): if isinstance(promptorprompts, (dict, str, bytes)) or ( len(promptorprompts) > 0 and islistof(promptorprompts, int) ): return [promptorprompts]
return promptorprompts
OnlineRenderer.preprocesscompletion() appends that whole sequence, and the renderer processes every element:
python vllm/renderers/onlinerenderer.py prompts = listSingletonPrompt | bytes if promptinput is not None: prompts.extend(prompttoseq(promptinput)) ... parsedprompts = [ prompt if isinstance(prompt, bytes) else parsemodelprompt(modelconfig, prompt) for prompt in prompts ] return await renderer.rendercmplasync(parsedprompts, tokparams, ...)
Finally, completion serving creates one backend generator and one response slot per rendered prompt:
python vllm/entrypoints/openai/completion/serving.py generators: list[AsyncGenerator[RequestOutput, None]] = [] for i, engineinput in enumerate(engineinputs): ... generator = self.engineclient.generate(...) generators.append(generator)
resultgenerator = mergeasynciterators(generators) numprompts = len(engineinputs) ... finalresbatch: list[RequestOutput | None] = [None] numprompts
The violated invariant is that one HTTP request should have a bounded backend request count. Current code enforces per-prompt token and sampling limits, but not the number of prompts in the outer completion request.
PoV
A minimal oversized request keeps normal generation parameters small but supplies a large outer prompt list:
json { "model": "served-model", "prompt": ["x", "x", "x"], "maxtokens": 1, "n": 1 }
Scaling the prompt array to tens or hundreds of thousands of short entries makes the server allocate, preprocess, schedule, merge, and buffer one subrequest per entry. The same applies to token-id prompt lists:
json { "model": "served-model", "prompt": [[1], [1], [1]], "maxtokens": 1, "n": 1 }
The intended negative control is a scalar prompt:
json { "model": "served-model", "prompt": "x", "maxtokens": 1, "n": 1 }
The scalar string is wrapped as one prompt; the list form is not bounded and fans out by list length.
Impact
An authenticated API client can make one /v1/completions request consume CPU, memory, async task scheduling, engine request slots, and response buffering proportional to an attacker-chosen outer prompt list. This can starve or disrupt other tenants sharing the same vLLM server. The report does not claim unauthenticated access, confidentiality impact, integrity impact, code execution, or impact where /v1/completions is not reachable by untrusted or semi-trusted clients.
Suggested Fix
Reject oversized prompt lists before renderer preprocessing. Add an outer prompt-count limit to CompletionRequest.prompt when the prompt is list[str] or list[list[int]], and consider making the limit configurable in the same style as the batch-chat and sampling-list bounds. The check should run before OnlineRenderer.preprocesscompletion() expands the prompt sequence, so oversized requests do not allocate parsed prompt lists, async render/tokenization tasks, engine generators, or response result slots.
Regression coverage should include a scalar prompt, a bounded prompt list, an oversized list[str], and an oversized list[list[int]]. The oversized requests should fail with a controlled validation error before any backend generator is created.
Affected Package/Versions
Package ecosystem: pip
Package name: vllm
Affected range confirmed by source proof: >=0.19.0, <=0.24.0; current main at cbe9c40f998f13975b967773ac7e7920e115387f remains affected.
Patched versions: unknown.
Latest release checked: v0.24.0, published on 2026-06-29.
GitHub Advisory Metadata
Package ecosystem: pip
Package name: vllm
Vulnerable version range: >=0.19.0, <=0.24.0
Patched versions: unknown
Advisory History
Public issue and PR searches for CompletionRequest prompt list, "prompt" "list[str]" "completion", and "CompletionRequest" "maxlength" did not find an existing report or fix for this exact path.
The closest published advisory is GHSA-3mwp-wvh9-7528, "OOM Denial of Service via Unbounded n Parameter in OpenAI API Server", patched in 0.19.0. This report is distinct because it keeps n=1 and uses the /v1/completions prompt outer list to create one engine request per prompt. The fix invariant is an outer prompt-count and aggregate request budget, not only a generated-sequence-count cap.
The closest public PR is vllm-project/vllm#45390, which covers multiple DoS fixes including GHSA-83mh-6mwq-3hg9 for BatchChatCompletionRequest.messages. That PR adds an outer bound to batch chat conversations, but its diff does not touch vllm/entrypoints/openai/completion/protocol.py or vllm/entrypoints/openai/completion/serving.py.
Prior local/private report families checked included pooling/rerank batch fanout, derender token-id postprocessing, explicit truncationside tokenizer-limit bypass, Python disaggregated generate prompt-length bypass, and priority scheduling. Those reports differ by endpoint, attacker-controlled field, sink, and fix surface.
vLLM is an inference and serving engine for large language models (LLMs). From 0.3.0 until 0.22.0, a vulnerability in ASGI web servers and starlette's trust on those web servers enables an authentication bypass of the OpenAI API AuthenticationMiddleware. It allows to use the API without providing the configured VLLMAPIKEY or --api-key. This vulnerability is fixed in 0.22.0.
Summary A Server-Side Request Forgery (SSRF) vulnerability exists in the MediaConnector class within the vLLM project's multimodal feature set. The loadfromurl and loadfromurlasync methods obtain and process media from URLs provided by users, using different Python parsing libraries when restricting the target host. These two parsing libraries have different interpretations of backslashes, which allows the host name restriction to be bypassed. This allows an attacker to coerce the vLLM server into making arbitrary requests to internal network resources.
This vulnerability is particularly critical in containerized environments like llm-d, where a compromised vLLM pod could be used to scan the internal network, interact with other pods, and potentially cause Denial of Service or access sensitive data. For example, an attacker could make the vLLM pod send malicious requests to an internal llm-d management endpoint, leading to system instability by falsely reporting metrics like the KV cache state.
Details The core of the vulnerability lies in the MediaConnector.loadfromurl method and its asynchronous counterpart. These methods accept a URL string to fetch media content (images, audio, video).
def loadfromurl( self, url: str, mediaio: MediaIO[M], , fetchtimeout: int | None = None, ) -> M: # type: ignore[type-var] urlspec = urlparse(url) if urlspec.scheme.startswith("http"): self.asserturlinallowedmediadomains(urlspec) connection = self.connection data = connection.getbytes( url, timeout=fetchtimeout, allowredirects=envs.VLLMMEDIAURLALLOWREDIRECTS, ) return mediaio.loadbytes(data)
The URL validation uses the urlparse function from Python's urllib module, while the request is made using the request function from Python's requests module. The requests module's underlying URL parsing is implemented using the parseurl function from Python's urllib3. These two parsing functions follow different URL specifications; one is implemented according to the RFC 3986 specification, and the other is implemented according to the WHATWG Living Standard. There is a difference in how the two functions handle backslashes (\) in URLs, which allows the hostname restriction to be bypassed.
Fix
https://github.com/vllm-project/vllm/pull/32746
Summary
A frontend-legal multi-request speculative workload can make vLLM produce an out-of-vocabulary recovered token equal to vocabsize, convert that value to -1 when choosing the next live token for a request, and then feed that -1 back into the next drafter input ids. On Qwen3 GPTQ this reaches the worker-side drafting / attention path and crashes the engine with a GPU device-side assert.
The same issue is reachable through the public gRPC request surface by sending a specific overlapping Generate / Abort sequence.
Impact
- A remote client that can send public gRPC generation requests can crash the shared vLLM engine worker - The triggering request sequence aborts concurrent requests and prevents later requests from completing until the worker is restarted - In shared deployments, this is a service-wide denial of service for other clients, not just a failure isolated to the attacking requests - The failure is reproducible, so repeated request sequences can sustain the outage
Affected version
- Confirmed on vLLM 0.17.1 - Earlier and later versions have not been checked yet in this report
Repro model
- Official Hugging Face repo: - Qwen/Qwen3-0.6B-GPTQ-Int8 - Anyone wants to reproduce the bug with my PoC scripts should download Qwen3-0.6B-GPTQ-Int8 first
Trigger chain
1. A legal multi-request speculative workload keeps structured-output state, speculative decoding, overlap, and request cancellation active in the same live engine. 2. During rejection sampling, vLLM produces a recovered token equal to the model vocabsize boundary value. 3. That recovered token appears in position 0 of the sampled speculative row for a live request. The same row also contains trailing padding entries equal to -1, but those padding entries are not the key fault by themselves. 4. The next-token preparation step treats the position-0 recovered token as the real next token for that request and converts that out-of-vocabulary value to -1. 5. The drafter writes that converted -1 back into the live next-step input-id row for the request. 6. The drafting / embedding / attention path later consumes that live invalid token and the worker crashes on GPU.
Details
Simple example
The important distinction is:
- trailing -1 values in a speculative row can be ordinary padding - the bug appears when the first live token for a request becomes 151936 == vocabsize, and that live token is then converted into -1
In simplified form, the bad transition looks like this:
text sampled speculative row: [151936, -1, -1, -1, ...]
At this point, the trailing -1 values are only padding. The critical problem is that the first position holds 151936, which is out of vocabulary and is being treated as the request's real next token.
Then vLLM prepares the next-token buffer:
text nexttokenids: [-1, ...]
Finally, that converted -1 is written back into the live model input ids:
text inputidsafter: [-1, 0, 0, 0, ...]
The crash happens because the live next token became -1 and was later consumed by the drafting / embedding / attention path, not merely because the speculative row contained padded -1 entries.
Trigger path in code
1. The workload is frontend-legal. The requests use normal SamplingParams features such as structured outputs, stop, badwords, mintokens, and streaming overlap. No malformed token-id list is required at the request boundary. 2. In speculative decoding, the rejection sampler can generate recovered tokens when drafted tokens are rejected. python # vllm/v1/sample/rejectionsampler.py def samplerecoveredtokens(...): recoveredtokenids = torch.emptylike(drafttokenids) samplerecoveredtokenskernel(batchsize, maxspeclen) return recoveredtokenids On the verified Qwen3 run, the recovered-token trace shows recoveredtokenids[0] = 151936, which is exactly vocabsize for this checkpoint. 3. The speculative proposer then prepares the next-token row from the sampled speculative row. python # vllm/v1/specdecode/eagle.py def preparenexttokenidspadded(...): ... eaglepreparenexttokenpaddedkernelgrid return nexttokenids, validsampledtokenscount In the verified trace, this step receives a sampled row beginning with 151936, followed by -1 padding. The important point is that 151936 occupies the first live token position for the request. This step then produces nexttokenids[0] = -1, meaning the live next token for the request has been converted to -1. 4. The drafter then rotates the draft input ids and inserts those nexttokenids back into the live input-id buffer. python # vllm/v1/specdecode/eagle.py def setinputsfirstpass(...): ... self.inputids[tokenindicestosample] = nexttokenids In the verified trace, this produces inputidsafter[0] = -1. 5. The model-side embed path later consumes those input ids. python # vllm/modelexecutor/models/qwen2.py def embedinputids(self, inputids: torch.Tensor) -> torch.Tensor: return self.embedtokens(inputids) In the verified trace, this is the first point where the converted -1 becomes visible as a real model input. The bug is not merely that the sampled speculative row contained padding -1; the bug is that the live next token for the request became -1 and was written back into input ids. 6. After that point, the visible sink depends on timing and backend state. On the attached Qwen3 reproducer, the engine commonly dies later in the drafting / attention path with CUDA error: device-side assert triggered, for example under flashattnvarlenfunc(...).
Local script breakdown
reprog4recoveredminus1local.py is a standalone local reproducer.
- It reads the Qwen3 checkpoint path from VLLMPOCG4MODEL or the built-in /path/to/qwen3 placeholder - It creates EngineCore directly without any external helper dependency - It submits one fixed multi-request workload that preserves the same overlap and speculative-decoding state needed for the bug - It writes: - requestpayloads.json - reproconfig.json - timeline.json - responses.json - error.txt - recoveredchaintrace.jsonl - recoveredchaintrace.jsonl is the key attribution artifact. It records the recovered-token chain directly from the standalone reproducer
gRPC script breakdown
reprog4recoveredminus1grpc.py is a standalone public gRPC reproducer.
- It reads the Qwen3 checkpoint path from VLLMPOCG4MODEL or the built-in /path/to/qwen3 placeholder - It starts a temporary vllm.entrypoints.grpcserver process - It sends only public Generate and Abort RPCs - It submits one fixed overlapping request sequence that preserves the same speculative-decoding state needed for the bug - After the crash window, it sends one more public Generate probe request to confirm that later gRPC requests also fail after the worker dies - It writes: - requestpayloads.json - timeline.json - servercommand.json - responses.json - postcrashprobe.json - server.stdout.log - server.stderr.log
Observed result
Local repro typically ends with:
- a recovered-token trace showing: - samplerecoveredtokensreturn -> recoveredtokenids[0] = 151936 - preparenexttokenidspadded -> nexttokenids[0] = -1 - setinputsfirstpass -> inputidsafter[0] = -1 - embedinputidsoutofrange -> inputids[0] = -1 - CUDA error: device-side assert triggered - a fatal engine-side failure
gRPC repro typically ends with:
- the triggering gRPC requests failing with INTERNAL: EngineCore encountered an issue. See stack trace (above) for the root cause. - server logs showing the worker dies with CUDA error: device-side assert triggered - a later public probe request also failing after the worker is dead
This demonstrates that the issue is reachable through the public gRPC request surface, not only through a local reproducer.
Log snippets
Local recovered-chain trace
text samplerecoveredtokensreturn: recoveredtokenids = [151936, ...] vocabsize = 151936
preparenexttokenidspadded: sampledtokenidshead = [[151936, -1, -1, ...], ...] nexttokenids = [-1, ...]
setinputsfirstpass: inputidsafter = [-1, 0, 0, 0, ...]
embedinputidsoutofrange: inputids = [-1, 0, 0, 0, ...]
gRPC server log
text torch.AcceleratorError: CUDA error: device-side assert triggered ... vllm.v1.engine.exceptions.EngineDeadError: EngineCore encountered an issue. See stack trace (above) for the root cause. ... Error in Generate for request postcrashprobe vllm.v1.engine.exceptions.EngineDeadError: EngineCore encountered an issue. See stack trace (above) for the root cause.
Root cause
This is a speculative-decoding state-handling bug, not an invalid frontend token-id input bug.
The root cause is that a recovered speculative token can become equal to vocabsize, then be selected as the live next token for a request, then be converted to -1, and that converted -1 is still written back into live drafter input ids and later consumed by the drafting / embedding / attention path.
For the Qwen3 checkpoint used here:
- 151936 == vocabsize
This value should be described as the model vocabsize boundary value, not as a legal token id.
Attachments
The attached bundle for this report should contain:
- reprog4recoveredminus1local.py - reprog4recoveredminus1grpc.py
These two standalone scripts are sufficient to reproduce the issue and its public gRPC reachability.
Fix
A fix for this vulnerability has been merged in: https://github.com/vllm-project/vllm/pull/44744
vLLM versions >= 0.10.2 and < 0.13.0 are missing sparse tensor validation in multimodal embeddings processing. Because PyTorch disables sparse tensor invariant checks by default, an attacker can submit crafted embedding requests with malformed (negative or out-of-bounds) tensor indices, when the prompt-embeds feature is enabled, to trigger crashes or resource exhaustion (denial of service), with potential for out-of-bounds/write-what-where memory corruption. This continues CVE-2025-62164, whose prior fix only disabled the feature by default rather than addressing the root cause.
vLLM is an inference and serving engine for large language models (LLMs). From 0.1.0 to before 0.19.0, a Denial of Service vulnerability exists in the vLLM OpenAI-compatible API server. Due to the lack of an upper bound validation on the n parameter in the ChatCompletionRequest and CompletionRequest Pydantic models, an unauthenticated attacker can send a single HTTP request with an astronomically large n value. This completely blocks the Python asyncio event loop and causes immediate Out-Of-Memory crashes by allocating millions of request object copies in the heap before the request even reaches the scheduling queue. This vulnerability is fixed in 0.19.0.
vLLM is an inference and serving engine for large language models (LLMs). From 0.7.0 to before 0.19.0, the VideoMediaIO.loadbase64() method at vllm/multimodal/media/video.py splits video/jpeg data URLs by comma to extract individual JPEG frames, but does not enforce a frame count limit. The numframes parameter (default: 32), which is enforced by the loadbytes() code path, is completely bypassed in the video/jpeg base64 path. An attacker can send a single API request containing thousands of comma-separated base64-encoded JPEG frames, causing the server to decode all frames into memory and crash with OOM. This vulnerability is fixed in 0.19.0.
vLLM is an inference and serving engine for large language models (LLMs). Starting in version 0.10.1 and prior to version 0.18.0, two model implementation files hardcode trustremotecode=True when loading sub-components, bypassing the user's explicit --trust-remote-code=False security opt-out. This enables remote code execution via malicious model repositories even when the user has explicitly disabled remote code trust. Version 0.18.0 patches the issue.
vLLM is an inference and serving engine for large language models (LLMs). The SSRF protection fix for CVE-2026-24779 add in 0.15.1 can be bypassed in the loadfromurlasync method due to inconsistent URL parsing behavior between the validation layer and the actual HTTP client. The SSRF fix uses urllib3.util.parseurl() to validate and extract the hostname from user-provided URLs. However, loadfromurlasync uses aiohttp for making the actual HTTP requests, and aiohttp internally uses the yarl library for URL parsing. This vulnerability in 0.17.0.
vLLM is an inference and serving engine for large language models (LLMs). From 0.8.3 to before 0.14.1, when an invalid image is sent to vLLM's multimodal endpoint, PIL throws an error. vLLM returns this error to the client, leaking a heap address. With this leak, we reduce ASLR from 4 billion guesses to ~8 guesses. This vulnerability can be chained a heap overflow with JPEG2000 decoder in OpenCV/FFmpeg to achieve remote code execution. This vulnerability is fixed in 0.14.1.
vLLM is an inference and serving engine for large language models (LLMs). Prior to version 0.14.1, a Server-Side Request Forgery (SSRF) vulnerability exists in the MediaConnector class within the vLLM project's multimodal feature set. The loadfromurl and loadfromurlasync methods obtain and process media from URLs provided by users, using different Python parsing libraries when restricting the target host. These two parsing libraries have different interpretations of backslashes, which allows the host name restriction to be bypassed. This allows an attacker to coerce the vLLM server into making arbitrary requests to internal network resources. This vulnerability is particularly critical in containerized environments like llm-d, where a compromised vLLM pod could be used to scan the internal network, interact with other pods, and potentially cause denial of service or access sensitive data. For example, an attacker could make the vLLM pod send malicious requests to an internal llm-d management endpoint, leading to system instability by falsely reporting metrics like the KV cache state. Version 0.14.1 contains a patch for the issue.
Summary Users can crash the vLLM engine serving multimodal models that use the Idefics3 vision model implementation by sending a specially crafted 1x1 pixel image. This causes a tensor dimension mismatch that results in an unhandled runtime error, leading to complete server termination.
Details The vulnerability is triggered when the image processor encounters a 1x1 pixel image with shape (1, 1, 3) in HWC (Height, Width, Channel) format. Due to the ambiguous dimensions, the processor incorrectly assumes the image is in CHW (Channel, Height, Width) format with shape (3, H, W). This misinterpretation causes an incorrect calculation of the number of image patches, resulting in a fatal tensor split operation failure.
Crash location: vllm/modelexecutor/models/idefics3.py line 672: python def processimageinput(self, imageinput: ImageInputs) -> torch.Tensor | list[torch.Tensor]: # ... numpatches = imageinput["numpatches"] return [e.flatten(0, 1) for e in imagefeatures.split(numpatches.tolist())]
The split() call fails because the computed numpatches value (17) does not match the actual tensor dimension (9): RuntimeError: splitwithsizes expects splitsizes to sum exactly to 9 (input tensor's size at dimension 0), but got splitsizes=[17]
This unhandled exception terminates the EngineCore process, crashing the server.
Affected Models Any model using the Idefics3 architecture. The vulnerability was tested with HuggingFaceTB/SmolVLM-Instruct.
Impact Denial of service by crashing the engine
Mitigation Validating the input: python def validateimagedimensions(self, imageshape): h, w = imageshape[:2] if len(imageshape) == 3 else imageshape if h < MINIMAGESIZE or w < MINIMAGESIZE: raise ValueError(f"Image dimensions too small: {h}x{w}")
Managing the exception: python try: return [e.flatten(0, 1) for e in imagefeatures.split(numpatches.tolist())] except RuntimeError as e: logger.error(f"Image processing failed: {e}") raise InvalidImageError("Failed to process image features") from e
Fixes
https://github.com/vllm-project/vllm/pull/29881
vLLM is an inference and serving engine for large language models (LLMs). Starting in version 0.10.1 and prior to version 0.14.0, vLLM loads Hugging Face automap dynamic modules during model resolution without gating on trustremotecode, allowing attacker-controlled Python code in a model repo/path to execute at server startup. An attacker who can influence the model repo/path (local directory or remote Hugging Face repo) can achieve arbitrary code execution on the vLLM host during model load. This happens before any request handling and does not require API access. Version 0.14.0 fixes the issue.
vllm-project vllm version 0.6.0 contains a vulnerability in the AsyncEngineRPCServer() RPC server entrypoints. The core functionality runserverloop() calls the function makehandlercoro(), which directly uses cloudpickle.loads() on received messages without any sanitization. This can result in remote code execution by deserializing malicious pickle data.