vLLM through 0.29.0 contains a denial of service vulnerability in the NIXL connector's prefix caching implementation that fails to properly validate block counts across multi-prompt completion requests in prefill/decode disaggregated deployments. Attackers can trigger an assertion failure in NixlBaseConnectorWorker.applyprefixcaching by submitting completion requests with multiple prompts of varying lengths, causing the decode worker to terminate and become unavailable until restarted.
vLLM through 0.29.0 contains a memory corruption vulnerability in the Triton bincountkernel where prompt token IDs index the penalty prompt-presence bitset without bounds checking against vocabulary size. Attackers can submit multimodal audio requests with tokens equal to vocabulary size, causing out-of-bounds writes that corrupt concurrent requests' sampler state and alter repetition penalty behavior.
vLLM versions before 0.28.0 fail to validate the lower bound of token IDs in the /v1/embeddings and /pooling endpoints, allowing unauthenticated attackers to crash the engine by submitting negative token IDs. A single request with a negative token ID triggers a CUDA device-side assertion that poisons the GPU context, causing all subsequent requests to fail until the process restarts.
vLLM through 0.29.0 fails to properly clean up decode-side metadata for rejected inference requests in prefill/decode disaggregated deployments. Remote attackers can submit requests with maxtokens=0 to exhaust decode-worker memory without bound until the worker restarts.
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
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.
vLLM versions >=0.10.2 and <0.28.0 do not apply any audio decode-size or duration limit when extracting audio from video input for NanoNemotronVL models. In nanonemotronvl.py, extractaudiofromvideos calls loadaudiopyav(BytesIO(videobytes)) without the maxdurations or maxdecodebytes parameters, so neither VLLMMAXAUDIODECODEDURATIONS nor VLLMMAXAUDIODECODEBYTES is enforced (unlike the direct audio upload path in AudioMediaIO). When a NanoNemotronVL model is served with useaudioinvideo=True, an attacker who supplies a small, highly compressed video as multimodal input can force the server to allocate gigabytes of memory during audio decoding, resulting in a denial of service. Fixed in vLLM 0.28.0.
vLLM before 0.28.0 contains a remote code execution vulnerability in the LlavaOnevision2 processor loader that ignores the trustremotecode parameter when loading remote processor classes. Attackers can craft a malicious model with arbitrary code in processingllavaonevision2.py that executes with vLLM process authority even when trustremotecode is set to False.
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.
Summary
The fix for GHSA-rwxx-mrjm-wc2m ("ReDoS via structuredoutputs.regex compiled without timeout") wrapped the regex compile in the xgrammar and outlines backends with compileregexwithtimeout (and, for outlines, validateregexisbuildable). The lm-format-enforcer backend was left unguarded: it compiles the attacker-supplied regex with no timeout and no buildability check. A single request with a catastrophic regex hangs the structured-output compile step and stalls the engine worker (denial of service).
Affected code (HEAD d6d39c1)
vllm/v1/structuredoutput/backendlmformatenforcer.py: - line 110: characterlevelparser = lmformatenforcer.RegexParser(grammarspec) — builds an interegular FSM from the attacker regex synchronously, no timeout. - line 155: validatestructuredoutputrequestlmformatenforcer returns immediately on if soparams.regex: — no validation.
Sibling backends that WERE patched by GHSA-rwxx: - backendxgrammar.py:92 → compileregexwithtimeout(...). - backendoutlines.py:65 → compileregexwithtimeout(...) (plus validateregexisbuildable).
lm-format-enforcer uses the same interegular DFA-construction primitive the advisory cites for the outlines backend.
Reproduction (runtime-verified against the sink)
The sink lmformatenforcer.RegexParser(<regex>) was exercised directly (this is exactly what the backend calls):
baseline '[0-9]{3}' -> 0.0002 s attacker '(a{1,300}){300}' -> DID NOT COMPLETE in 20 s (one core pegged at 100% in interegular FSM construction)
End-to-end: start vllm serve <model> --structured-outputs-config '{"backend":"lm-format-enforcer"}', then POST /v1/completions with {"structuredoutputs":{"regex":"(a{1,300}){300}"}, ...}. The request never returns; because grammar compile runs in the engine's structured-output path, concurrent requests stall = worker-level DoS. The identical request against the outlines backend is bounded by compileregexwithtimeout and returns a clean error.
Impact
Unauthenticated denial of service (vLLM ships with no authentication by default). One request pegs a CPU core and blocks the structured-output engine path.
Reachability precondition: the operator must have selected backend=lm-format-enforcer via --structured-outputs-config (the default is auto → xgrammar). This is the same opt-in tier as the outlines backend that GHSA-rwxx already covered.
Suggested remediation
Route the lm-format-enforcer regex compile (backendlmformatenforcer.py:110) through the same compileregexwithtimeout guard already applied to the xgrammar and outlines backends, and reject un-buildable / oversized patterns in validatestructuredoutputrequestlmformatenforcer.
Summary Short summary of the problem. Make the impact and severity as clear as possible. For example: An unsafe deserialization vulnerability allows any unauthenticated user to execute arbitrary code on the server.
Sending a pure prompt embeds payload in a /v1/completions request with a model using M-RoPE causes the EngineCore to fail an assertion and fatally crash, shutting down the entire server application.
Any remote user who is authorized to make a /v1/completions endpoint can trivially make such a request and induce a crash.
Details Give all details on the vulnerability. Pointing to the incriminated source code is very helpful for the maintainer.
In commit 56669c1, a simple assert intended to be a type-narrowing assert was added to the initmropepositions method in GPUModelRunner (the offending line on main at the time of writing: https://github.com/vllm-project/vllm/blob/2d481f8a946ee0521872af0f098674a8ee01ce4a/vllm/v1/worker/gpumodelrunner.py#L1588-L1607).
python assert reqstate.prompttokenids is not None, ( "M-RoPE requires prompttokenids to be available." )
This type narrowing assert is to prevent mypy errors later in the function because None is not a valid type for mropemodel.getmropeinputpositions. Unfortunately, this assertion is not always true. /v1/completions requests that specify prompt=None and promptembeds=<not none> will indeed create a CachedRequestState where prompttokenids is None. This triggers the assertion, which in turn crashes the EngineCore and the Server application.
(EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] File "/usr/local/lib/python3.12/dist-packages/vllm/v1/worker/gpumodelrunner.py", line 3997, in executemodel (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] deferredstatecorrectionsfn = self.updatestates(scheduleroutput) (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] File "/usr/local/lib/python3.12/dist-packages/vllm/v1/worker/gpumodelrunner.py", line 1239, in updatestates (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] self.initmropepositions(reqstate) (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] File "/usr/local/lib/python3.12/dist-packages/vllm/v1/worker/gpumodelrunner.py", line 1582, in initmropepositions (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] assert reqstate.prompttokenids is not None, ( (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] AssertionError: M-RoPE requires prompttokenids to be available. (APIServer pid=1) ERROR 06-11 00:48:03 [asyncllm.py:704] AsyncLLM outputhandler failed. (APIServer pid=1) ERROR 06-11 00:48:03 [asyncllm.py:704] Traceback (most recent call last): (APIServer pid=1) ERROR 06-11 00:48:03 [asyncllm.py:704] File "/usr/local/lib/python3.12/dist-packages/vllm/v1/engine/asyncllm.py", line 660, in outputhandler (APIServer pid=1) ERROR 06-11 00:48:03 [asyncllm.py:704] outputs = await enginecore.getoutputasync() (APIServer pid=1) ERROR 06-11 00:48:03 [asyncllm.py:704] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ (APIServer pid=1) ERROR 06-11 00:48:03 [asyncllm.py:704] File "/usr/local/lib/python3.12/dist-packages/vllm/v1/engine/coreclient.py", line 1030, in getoutputasync (APIServer pid=1) ERROR 06-11 00:48:03 [asyncllm.py:704] raise self.formatexception(outputs) from None (APIServer pid=1) ERROR 06-11 00:48:03 [asyncllm.py:704] vllm.v1.engine.exceptions.EngineDeadError: EngineCore encountered an issue. See stack trace (above) for the root cause.
All requests using the /v1/chat/completions endpoints will have text/prompttokenids parts (corresponding to the chat template), and promptembeds parts are handled as mmfeatures. This method (rightly) filters out those promptembeds content parts as they are treated as text positions.
A sufficient solution to type narrowing here without raising a fatal assertion is to instead replace the assertion with a using dummy token ids:
python def initmropepositions(self, reqstate: CachedRequestState): model = self.getmodel() assert supportsmrope(model), "M-RoPE support is not implemented." mropemodel = cast(SupportsMRoPE, model)
# Filter out promptembeds modality (text-only position info) mropefeatures = [ f for f in reqstate.mmfeatures if f.modality != "promptembeds" ] # Handle both tokenids and embeddings-only inputs if reqstate.prompttokenids is not None: inputtokens = reqstate.prompttokenids elif reqstate.promptembeds is not None: # For text-only embeddings, dummy token IDs are safe since # getmropeinputpositions only uses len(inputtokens) when mmfeatures is empty seqlen = reqstate.promptembeds.shape[0] inputtokens = list(range(seqlen)) # Verify no mmfeatures remain (should be true after promptembeds filter) assert len(mropefeatures) == 0, ( "M-RoPE with promptembeds-only input should have no multimodal features" ) else: raise ValueError( "M-RoPE requires either prompttokenids or promptembeds." )
reqstate.mropepositions, reqstate.mropepositiondelta = ( mropemodel.getmropeinputpositions( inputtokens, mropefeatures, ) )
Technically, in isolation, this method still crashes in the case where reqstate.prompttokenids is None and reqstate.mmfeatures, so the solution above still leaves that potential vector open. As far as can be determined, however, such a reqstate is impossible in the first place in online mode, because it would require a /v1/completions request with promptembeds AND multimodal features, but the /v1/completions request schema does not expose multimodal inputs in any discernible way. Today, those are the only two endpoints with promptembeds support.
When in offline mode, it is technically possible to directly create an EngineCoreRequest that has promptembeds and not prompttokenids and mmfeatures, and pass that to LLM.generate. That would trigger this same assertion, and no validation would prevent that combination. It is strongly suspected, though, that this combination would be undefined in any model that support M-RoPE, because it would not be possible to determine which token positions correspond to mmfeatures. The proposed solution above would end up not setting reqstate.mropepositions and reqstate.mropepositiondelta in this scenario, which could result in undefined behavior.
promptembeds is far more familiar here than M-RoPE, and it is understood that each model that supports it is responsible for defining its own getmropeinputpositions which have varying implementations. There is insufficient knowledge to be prescriptive in how the two features should interact in the offline case, other than possibly raising a validation error earlier on preventing that combination (which would emulate the current assertion behavior). Regardless, in offline mode, the chances of a remote user being able to exploit this are slim-to-nil compared to the online case which is incredibly straightforward.
Impact What kind of vulnerability is it? Who is impacted?
- Denial of Service caused by an incorrect assertion inside of the GPUModelRunner which causes a fatal EngineCore exception - Any configuration with --enable-prompt-embeds and M-RoPE-supported model is vulnerable - The attack is extremely easy from the remote attacker's perspective (copying the official promptembeds online mode docs examples almost-verbatim, accounting for model-name and connection details, of course, will induce a guaranteed shutdown)
Summary
The structuredoutputs.regex API parameter passes a user-supplied regex string directly to grammar compiler backends with no compilation timeout. In the xgrammar backend, the string reaches compileregex() with no guard. In the outlines backend, validateregexisbuildable() blocks structural issues (lookarounds, backreferences) but provides zero protection against exponential DFA state-space explosion. Patterns like (a+)+b pass all checks and hang the inference worker.
Root Cause
backendxgrammar.py:91 — no timeout: python ctx = self.compiler.compileregex(grammarspec)
backendoutlines.py:299–330 — structural checks only, no complexity analysis: python def validateregexisbuildable(regex: str) -> None: sreparse.parse(regex) # AST parse only — does not detect exponential patterns checkunsupported(...) # blocks lookarounds/backrefs, not nested quantifiers
backendoutlines.py:64 — no timeout: python oc.Index(regexstring, vocabulary.inner)
Impact
Denial of service — one request with an adversarial regex pattern hangs an inference worker indefinitely.
Remediation
Wrap compileregex() and oc.Index() calls in a thread with a deadline (e.g., 5 seconds). Add complexity analysis to validateregexisbuildable() to detect nested quantifier patterns before compilation.
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
Summary
Current-head vLLM documents VLLMMAXAUDIOCLIPFILESIZEMB as the maximum audio file size accepted by the speech-to-text APIs. The default is 25 MB. vllm/envs.py also describes files larger than this value as rejected.
The /v1/audio/transcriptions and /v1/audio/translations routes call await request.file.read() before vLLM checks that limit. In FastAPI and Starlette, UploadFile.read() returns bytes from the uploaded file object; when called without a size argument, the route materializes the remaining file contents. vLLM then performs the compressed file-size check later in preprocessspeechtotext() against the already-created bytes object.
As a result, the documented compressed audio file-size limit does not bound the memory allocated by vLLM endpoint code before validation. An oversized multipart upload can cause vLLM to allocate memory proportional to the uploaded file size before rejecting the request as too large.
This is distinct from GHSA-6pr9-rp53-2pmc, which covered decoded PCM expansion after compressed input was accepted. This report covers compressed upload materialization before compressed-size validation.
Technical Details
The upload routes perform an unbounded read before vLLM checks the documented compressed audio file-size limit:
- vllm/entrypoints/speechtotext/transcription/apirouter.py: audiodata = await request.file.read() - vllm/entrypoints/speechtotext/translation/apirouter.py: audiodata = await request.file.read() - vllm/entrypoints/speechtotext/base/serving.py later checks: if len(audiodata) / 10242 > self.maxaudiofilesizemb
There is no route-level check of request.file.size, Content-Length, a bounded read(maxbytes + 1), or a streaming copy that stops at the configured limit before the full file is materialized.
This does not appear to be intended behavior. vLLM's security guide treats request-controlled resource use as a security boundary: for example, requests that exceed VLLMMAXNSEQUENCES are rejected before reaching the engine. The speech-to-text upload limit is documented in the same spirit as an API enforced limit, but the first vLLM check happens after the over-limit upload has already been copied into a bytes object.
Impact
Attack requirements:
- the deployment exposes /v1/audio/transcriptions or /v1/audio/translations; - a speech-to-text capable model/task is configured; and - the caller can submit requests to the endpoint, including any API key the deployment requires.
An API caller who meets those requirements can send an oversized audio file. vLLM reads the full uploaded file into memory before applying the configured compressed audio file-size limit. This can create memory pressure or, depending on process/container limits and concurrency, terminate the process before the request is rejected.
Suggested severity: Moderate, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H (6.5), CWE-770/CWE-400.
The impact is availability-only. This is not a claim of code execution, data access, cross-tenant data exposure, parser-level multipart memory exhaustion, or persistence after process restart. Deployment body-size limits at a reverse proxy or ASGI layer can mitigate the issue before vLLM sees the request, but vLLM's own documented file-size limit does not currently provide that memory boundary.
Suggested Fix
Enforce the compressed audio upload limit before the unbounded read:
- Check reliable upload size metadata before reading when available. - Read at most maxbytes + 1 bytes in chunks as a defense-in-depth guard against missing or unreliable metadata. - Share the helper between transcription and translation routes. - Add regression tests that prove an over-limit UploadFile is rejected without calling an unbounded read().
The important property is that over-limit compressed uploads are rejected before vLLM allocates the full uploaded file as bytes.
Resources
- vLLM security policy: https://github.com/vllm-project/vllm/security/policy - vLLM speech-to-text docs: https://docs.vllm.ai/en/latest/serving/onlineserving/speechtotext/ - vLLM security guide, request parameter resource limits: https://docs.vllm.ai/en/latest/usage/security/ - vLLM vulnerability management docs: https://docs.vllm.ai/en/latest/contributing/vulnerabilitymanagement/ - FastAPI file uploads: https://fastapi.tiangolo.com/reference/uploadfile/ - Starlette uploaded files: https://www.starlette.io/requests/ - Adjacent published audio advisory: https://github.com/vllm-project/vllm/security/advisories/GHSA-6pr9-rp53-2pmc - Request-parameter resource DoS precedent: https://github.com/vllm-project/vllm/security/advisories/GHSA-3mwp-wvh9-7528
Appendix: Affected Version
Validated against current head:
- commit: 1033ffac2eccf986fdd880f4dee64ca3b22c63c9 - described version: v0.22.1rc0-491-g1033ffac2e
Known affected range: current head. It has not been determined the introducing commit or release range.
Appendix: Proof Of Vulnerability
The attached proof is a non-destructive static probe. It does not upload a large file or contact a running vLLM server:
bash python3 attached-evidence/poc/audiouploadsizeprecheckprobe.py
Observed result:
json { "pov": "this report", "validated": true, "defaultlimitmb": 25, "documentedapilimit": true, "routes": { "transcriptionroute": { "unboundeduploadread": true, "earlysizeguardbeforeread": false, "chunkedboundedread": false }, "translationroute": { "unboundeduploadread": true, "earlysizeguardbeforeread": false, "chunkedboundedread": false } }, "latesizecheck": { "present": true } }
Expected behavior: vLLM rejects over-limit audio files before materializing the entire upload into memory in vLLM endpoint code.
Actual behavior: the route materializes the upload into memory first, and only then does vLLM reject the request as exceeding VLLMMAXAUDIOCLIPFILESIZEMB.
vLLM is an inference and serving engine for large language models (LLMs). Prior to 0.22.0, an assert-based security check in vLLM's activation function loading allows any unauthenticated attacker to achieve arbitrary code execution on the server by publishing a malicious HuggingFace model, when vLLM runs in Python optimized mode (python -O or PYTHONOPTIMIZE=1). This vulnerability is fixed in 0.22.0.
vLLM is an inference and serving engine for large language models (LLMs). Prior to 0.22.1, the vLLM Dockerfile is vulnerable to a dependency confusion attack through the flashinfer-jit-cache package. The package is installed from a custom index (flashinfer.ai/whl/) using --extra-index-url, but the package name was not registered on PyPI, and UVINDEXSTRATEGY="unsafe-best-match" is set globally. An attacker who registers flashinfer-jit-cache on PyPI with version 0.6.11.post2 can execute arbitrary code as root during the Docker build and backdoor every resulting container image, enabling exfiltration of all user prompts, API credentials, and model data from production vLLM deployments This vulnerability is fixed in 0.22.1.
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 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.
Summary vLLM's /v1/audio/transcriptions endpoint limits compressed upload size but not decoded PCM output. A 25MB OPUS file expands to ~14.9GB of float32 PCM at decode time. Tested on vLLM v0.19.0.
Details SpeechToTextProcessor rejects uploads over VLLMMAXAUDIOCLIPFILESIZEMB (default 25MB) based on compressed byte length, but the audio decoder in audio.py accumulates all decoded frames into memory with no size limit before returning:
python speechtotext.py L184-189 if len(audiodata) / 1024 2 > self.maxaudiofilesizemb: raise VLLMValidationError(...) y, sr = loadaudio(buf, sr=self.asrconfig.samplerate) # decoded size unchecked
audio.py L77-107 chunks: list[npt.NDArray] = [] for frame in container.decode(stream): chunks.append(frame.tondarray()) audio = np.concatenate(chunks, axis=-1).astype(np.float32) # single contiguous allocation
A 25MB OPUS file at 6kbps encodes ~8.7 hours of audio. Decoding produces ~5.7GB of float32 PCM (232x amplification), and np.concatenate then allocates a second contiguous array, bringing peak RSS to ~14.9GB from a single request. SpeechToTextConfig.maxaudioclips (default 30s) applies only after the full decode and does not prevent the allocation.
Impact An unauthenticated attacker can exhaust server memory with a small number of concurrent requests, each a valid upload within the documented size limit. Severity was assessed with reference to prior OOM vulnerability reports in vLLM.
Fix
A fix for this vulnerability was merged here: https://github.com/vllm-project/vllm/pull/44970
vLLM: incomplete CVE-2026-22778 fix leaks PIL repr addresses via the Anthropic API router
Researcher: Kai Aizen — SnailSploit (@SnailSploit), Adversarial & Offensive Security Research Severity: CVSS 3.1 5.3 (Medium) AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N Target: https://github.com/vllm-project/vllm
---
Summary
The fix for CVE-2026-22778 / GHSA-4r2x-xpjr-7cvv (PRs #31987 and #32319) introduced sanitizemessage and applied it at four FastAPI exception-handling sites in the OpenAI router. The sanitizer strips object-repr memory addresses (<io.BytesIO object at 0x7a95e299e750> → <io.BytesIO object>) before error messages reach the client, defeating the ASLR-bypass primitive that CVE-2026-22778 chained with a libopenjp2 heap overflow for RCE.
The fix is incomplete: response paths added to vLLM at or after the same time as the fix continue to echo str(exc) directly to clients without sanitizemessage. The original Stage 1 primitive — sending malformed image bytes so PIL raises UnidentifiedImageError whose message contains the BytesIO object repr — reaches all of them unmodified and leaks the heap address verbatim in the response body.
All five lines below are present in main HEAD (771e1e48b, 2026-05-26).
Affected sites
Current main HEAD (771e1e48b, 2026-05-26):
| # | File | Line | Code | |---|---|---|---| | 1 | vllm/entrypoints/anthropic/apirouter.py | 78 | message=str(e), (inside POST /v1/messages exception handler) | | 2 | vllm/entrypoints/anthropic/apirouter.py | 124 | message=str(e), (inside POST /v1/messages/counttokens) | | 3 | vllm/entrypoints/anthropic/serving.py | 808 | error=AnthropicError(type="internalerror", message=str(e)), (SSE streaming converter) | | 4 | vllm/entrypoints/speechtotext/realtime/connection.py | 75 | await self.senderror(str(e), "processingerror") (WebSocket event loop) | | 5 | vllm/entrypoints/speechtotext/realtime/connection.py | 265 | await self.senderror(str(e), "processingerror") (WebSocket generation loop) |
Why the global exception handler does not save these paths
apiserver.py registers a catch-all app.exceptionhandler(Exception)(exceptionhandler) at line 262, and that handler calls createerrorresponse(exc) which DOES apply sanitizemessage. However, FastAPI exception handlers fire only on unhandled exceptions that propagate out of a route function.
All affected HTTP paths catch Exception inside the route coroutine and construct the response themselves:
python vllm/entrypoints/anthropic/apirouter.py:71-81 (POST /v1/messages) try: generator = await handler.createmessages(request, rawrequest) except Exception as e: logger.exception("Error in createmessages: %s", e) return JSONResponse( statuscode=HTTPStatus.INTERNALSERVERERROR.value, content=AnthropicErrorResponse( error=AnthropicError( type="internalerror", message=str(e), # <-- unsanitized ) ).modeldump(), )
Because the exception is caught and a JSONResponse is returned in-route, every registered FastAPI exception handler — including the sanitizing global one — is bypassed. The WebSocket path bypasses it for a different reason: WebSocket frames don't traverse FastAPI's HTTP exception handler chain at all.
Reachability — the same primitive as the parent CVE
The Anthropic Messages API accepts image content parts in the request body (type: "image" with base64 source.data or type: "imageurl"). Image bytes are passed to the same multimodal loader used by the OpenAI router. Malformed bytes cause PIL.Image.open to raise:
UnidentifiedImageError: cannot identify image file <io.BytesIO object at 0x7a95e299e750>
The exception propagates up through handler.createmessages into the except Exception as e: at apirouter.py:75. str(e) returns the exception message verbatim, including the address. The address ends up in the error.message field of the JSON response body returned to the attacker. ASLR entropy on the affected process drops from ~4 billion to ~8 candidates, identically to CVE-2026-22778 Stage 1.
The same primitive is reachable on POST /v1/messages/counttokens (route #2), inside the SSE streaming converter when an exception is raised mid-stream (route #3), and over the realtime speech-to-text WebSocket when audio decoder or generation paths raise an exception containing any object repr (routes #4, #5).
Chronology — these are scope misses, not legacy code
- 2026-01-09: PR #31987 (aa125ecf0) introduces sanitizemessage and applies it to OpenAI router HTTP exception handlers. - 2026-01-15 (six days later): PR #32369 (4c1c501a7) adds vllm/entrypoints/anthropic/apirouter.py containing line 78's message=str(e). The fix was not applied to the new router. - 2026-03-02 (~two months later): PR #35588 (9a87b0578) adds the Anthropic counttokens endpoint, replicating the same message=str(e) pattern at line 124. - 2026-05-12 (~four months later): PR #42370 (d37e25ffb) consolidates speech-to-text entrypoints and the realtime WebSocket uses senderror(str(e), ...) for both error paths. - 2026-05-26: current main HEAD, all five lines still present.
Remediation
1. Apply sanitizemessage symmetrically to the five sites
python vllm/entrypoints/anthropic/apirouter.py — add at top: from vllm.entrypoints.utils import sanitizemessage
Line 78 (POST /v1/messages) and Line 124 (counttokens): message=sanitizemessage(str(e)),
python vllm/entrypoints/anthropic/serving.py — add at top: from vllm.entrypoints.utils import sanitizemessage
Line 808: error=AnthropicError(type="internalerror", message=sanitizemessage(str(e))),
python vllm/entrypoints/speechtotext/realtime/connection.py — add at top: from vllm.entrypoints.utils import sanitizemessage
Lines 75 and 265: await self.senderror(sanitizemessage(str(e)), "processingerror")
2. Tighten the regex (defense in depth)
The current regex r" at 0x[0-9a-f]+>" is narrow — it only matches the exact CPython builtin object-repr suffix in lowercase hex with a trailing >. Future Python versions, C extensions, or custom repr methods could produce non-matching formats that re-enable the leak:
python vllm/entrypoints/utils.py def sanitizemessage(message: str) -> str: # Strip any standalone hex address; downstream observers don't need them. return re.sub(r"\b0x[0-9a-fA-F]{6,}\b", "0x?", message)
3. Future-proofing: consider a response middleware
Both the route-local exception handling pattern (Anthropic router) and the WebSocket path bypass FastAPI's exception handler chain. A response-level middleware that always invokes sanitizemessage on outgoing error bodies would prevent this class of regression entirely.
Affected versions
- All vLLM versions containing vllm/entrypoints/anthropic/apirouter.py (introduced 2026-01-15 in PR #32369). - All vLLM versions containing vllm/entrypoints/speechtotext/realtime/connection.py (introduced 2026-05-12 in PR #42370). - Confirmed present in main HEAD 771e1e48b (2026-05-26).
Steps to reproduce
1. Clone the target: git clone --depth 1 https://github.com/vllm-project/vllm 2. Run the proof of concept (PoC.py) against the cloned source. 3. Observe the result shown under Verified result below.
Credit
Kai Aizen — SnailSploit (@SnailSploit). Adversarial & Offensive Security Research.
Fix
A fix for this vulnerability was added here: https://github.com/vllm-project/vllm/pull/45119
Summary
Integer truncation of tensor dimensions in vLLM's GGUF dequantize kernels (csrc/quantization/gguf/ggufkernel.cu) causes partial tensor processing. The output tensor is allocated at full size via torch::empty (uninitialized memory), but the dequantize CUDA kernel processes only a truncated number of elements. The unfilled portion of the output tensor retains whatever was previously in GPU memory. In multi-tenant inference deployments, this residual GPU memory may contain tensor data from other users' inference requests, constituting information disclosure.
Root Cause
The tocudaggmlt function pointer type at ggml-common.h:1067 declares its element count parameter as int (32-bit):
cpp using tocudaggmlt = void ()(const void restrict x, dstt restrict y, int k, // 32-bit cudaStreamt stream);
All dequantize kernel functions (dequantizeblockcuda, dequantizerowq2Kcuda, etc. in dequantize.cuh) inherit this int k parameter and use it as the kernel launch grid size:
cpp static void dequantizeblockcuda(..., const int k, cudaStreamt stream) { const int numblocks = (k + 2CUDADEQUANTIZEBLOCKSIZE - 1) / (2CUDADEQUANTIZEBLOCKSIZE); dequantizeblock<<<numblocks, CUDADEQUANTIZEBLOCKSIZE, 0, stream>>>(vx, y, k); }
In ggmldequantize() at ggufkernel.cu:85, the caller passes m n (an int64t product) to this int k parameter:
cpp at::Tensor DW = torch::empty({m, n}, options); // line 80: full-size, UNINITIALIZED // ... tocuda((void)W.dataptr(), (scalart)DW.dataptr(), m n, stream); // line 85: mn truncated to int
When m n > INTMAX, the truncated k is smaller than the actual tensor size. The kernel processes k elements. The remaining (m n) - k elements in DW are never written and contain stale GPU memory.
This is a single root cause -- the int type on the k parameter in tocudaggmlt -- with a single fix: change int k to int64t k. All dequantize functions inherit this type through the same typedef.
Affected Functions
All in csrc/quantization/gguf/ggufkernel.cu:
| Function | Line | Allocation | Info Disclosure? | |----------|------|-----------|-----------------| | ggmldequantize | 74 | torch::empty({m, n}) at line 80 | Yes -- mn truncated to int k at line 85 | | ggmlmulmatveca8 | 91 | torch::empty({vecs, row}) at line 99 | Yes -- int col = X.sizes()[1] at line 94 | | ggmlmulmata8 | 207 | torch::empty({batch, row}) at line 215 | Yes -- int col = X.sizes()[1] at line 210 | | ggmlmoea8 | 279 | torch::empty({tokenstopk, row}) at line 289 | Yes -- int col = X.sizes()[1] at line 285 |
All four functions allocate output tensors with torch::empty (uninitialized) and then run CUDA kernels that use truncated dimension values as loop bounds. The unfilled portion of each output tensor retains stale GPU memory.
ggmlmoea8vec (line 382) uses torch::zeros instead of torch::empty, so it is not affected by the info disclosure variant.
Impact: Information Disclosure in Multi-Tenant Serving
vLLM is designed for multi-tenant inference serving. GPU memory is reused across requests from different users. When the dequantize kernel partially fills an output tensor:
1. The output tensor DW is allocated with torch::empty -- the buffer contains whatever was previously in that GPU memory region 2. The dequantize kernel fills only a truncated portion of the buffer 3. The unfilled portion retains residual data from prior GPU operations, which may include tensor data from other users' inference requests 4. The contaminated tensor proceeds through the model computation 5. No error or warning is generated -- the partial fill is silent
This is a confidentiality violation. In shared inference deployments (the primary vLLM use case), one user's inference data can leak into another user's model computation through residual GPU memory.
Attacker Control
The attacker crafts a GGUF model file with weight tensor dimensions whose product exceeds INTMAX (e.g., a matrix with shape [65536, 65536] gives m n = 4,294,967,296). The model is hosted on HuggingFace or any model hub. The victim loads the model with vLLM for inference serving. The truncation happens automatically during model weight dequantization.
Fix
A fix for this vulnerability was added here: https://github.com/vllm-project/vllm/pull/44971
Summary
All temperature validation gates use comparison operators (<, >), which silently evaluate to False for NaN and for positive Infinity in Python's IEEE 754 float semantics. Both values pass every guard and propagate to GPU sampling kernels, where they produce undefined behavior or CUDA errors that can crash the inference worker. Note: -Infinity is correctly caught.
Root Cause
samplingparams.py:384: python if 0 < self.temperature < MAXTEMP: # NaN → False; +Inf → False
samplingparams.py:462: python if self.temperature < 0.0: # NaN → False; +Inf → False raise VLLMValidationError(...)
No math.isnan() or math.isinf() check exists anywhere in samplingparams.py.
Python semantics (verified): float('nan') < 0.0 → False, float('inf') < 0.0 → False.
Impact
Crash of inference worker on GPU kernel execution with NaN/Inf softmax input, degrading service for all concurrent users.
Remediation
Add math.isfinite(self.temperature) check in verifyargs(). Reject non-finite float values with a 400 error.
Fix
A fix for this vulnerability was merged here: https://github.com/vllm-project/vllm/pull/45116
Summary
A vulnerability in ASGI web servers and starlette's trust on those web servers enables an authentication bypass of the OpenAI API AuthenticationMiddleware, which was discovered during @x41sec's source code audit. It allows to use the API without providing the configured VLLMAPIKEY or --api-key.
Details
In https://github.com/vllm-project/vllm/blob/v0.14.0/vllm/entrypoints/openai/apiserver.py#L689-L692 the urlpath is taken from the URL, which is reconstructed by starlette based on the request scope.
py from starlette.datastructures import URL, Headers, MutableHeaders, State
...
urlpath = URL(scope=scope).path.removeprefix(rootpath) headers = Headers(scope=scope) if urlpath.startswith("/v1") and not self.verifytoken(headers): response = JSONResponse(content={"error": "Unauthorized"}, statuscode=401) return response(scope, receive, send) return self.app(scope, receive, send)
The request scope includes the request's Host: header and reconstructs the URL as shown below:
py f"{scheme}://{hostheader}{path}"
Neither starlette nor any of the ASGI servers (including uvicorn, which vllm uses) properly filter the Host: header for invalid characters. This allows an attacker to include special URL characters such as / or ? in the Host: header and thereby control the reconstructed URL and it's .path attribute.
FastAPI/starlette's routing uses the HTTP path and does not depend on the parsed url.path attribute, allowing attackers to reach an endpoint via a certain path while providing a different value in the .path.
Impact - Instances of vllm that use an API Key for the OpenAI API and expose the API to attackers. - Instances behind an RFC-conforming web server (such as nginx) are not affected.
Summary
An assert-based security check in vLLM's activation function loading allows any unauthenticated attacker to achieve arbitrary code execution on the server by publishing a malicious HuggingFace model, when vLLM runs in Python optimized mode (python -O or PYTHONOPTIMIZE=1).
Details
vLLM uses an assert statement at vllm/modelexecutor/layers/pooler/activations.py:48 as its sole security control to restrict which activation functions can be loaded from a HuggingFace model's config.json:
python vllm/modelexecutor/layers/pooler/activations.py:35-53 functionname: str | None = None if ( hasattr(config, "sentencetransformers") and "activationfn" in config.sentencetransformers ): functionname = config.sentencetransformers["activationfn"] elif ( hasattr(config, "sbertcedefaultactivationfunction") and config.sbertcedefaultactivationfunction is not None ): functionname = config.sbertcedefaultactivationfunction
if functionname is not None: assert functionname.startswith("torch.nn.modules."), ( "Loading of activation functions is restricted to " "torch.nn.modules for security reasons" ) fn = resolveobjbyqualname(functionname)()
Python's assert statements are stripped at compile time when running in optimized mode (python -O or PYTHONOPTIMIZE=1). When the assert is absent, the attacker-controlled functionname from the model's config.json is passed directly to resolveobjbyqualname() — an unrestricted import gadget:
python def resolveobjbyqualname(qualname: str) -> Any: modulename, objname = qualname.rsplit(".", 1) module = importlib.importmodule(modulename) return getattr(module, objname)
This is the same vulnerability class as CVE-2017-1000433 (pysaml2 assert-based auth bypass), flagged by Bandit B101 and Ruff S101, and the reason Django proactively replaced all assert-based security checks (ticket #32508).
Attacker-controlled input sources: - config.sentencetransformers["activationfn"] (line 40) - config.sbertcedefaultactivationfunction (line 45)
Affected call sites — getactfn() is called via resolveclassifieractfn() from: - vllm/modelexecutor/layers/pooler/seqwise/poolers.py:122 — SequencePooler - vllm/modelexecutor/layers/pooler/tokwise/poolers.py:130 — TokenPooler
Broader systemic risk: resolveobjbyqualname is called from ~20 locations across the codebase with no validation of its own. Any future caller feeding user-controlled input to it without validation creates the same vulnerability class.
Suggested fix: Replace the assert with an explicit conditional raise:
python if not functionname.startswith("torch.nn.modules."): raise ValueError( "Loading of activation functions is restricted to " "torch.nn.modules for security reasons" )
Impact
Arbitrary code execution. A malicious model author publishes a HuggingFace model with a crafted config.json. When a victim loads this model with vLLM running under python -O or PYTHONOPTIMIZE=1, arbitrary code executes during model initialization with the privileges of the vLLM process.
The attack requires: 1. Victim loads a malicious model from HuggingFace (user interaction) 2. vLLM runs under python -O or PYTHONOPTIMIZE=1 (documented in production use) 3. Model uses a cross-encoder architecture (e.g. BERT or RoBERTa with sequence classification)
Coordinated disclosure note: This vulnerability was also reported via huntr.com on April 2, 2026 (https://huntr.com/bounties/dcb05b04-e625-41e7-adbc-bbae0cc2d64c). A GitHub Security Advisory was also filed because it is vLLM's stated preferred disclosure channel per SECURITY.md.
Fix
A fix for this was introduced in this commit: https://github.com/vllm-project/vllm/commit/b3c7ffcab82c2439726f8cb213800f6f38c023d3
vLLM versions 0.8.0 and later are vulnerable to an Out-of-Memory (OOM) Denial of Service (DoS) attack due to unbounded frame count processing in the VideoMediaIO.loadbase64() method. When processing video/jpeg data URLs, the method splits the base64 data string on commas to extract individual JPEG frames without enforcing a frame count limit. An attacker can exploit this by crafting a single API request containing thousands of comma-separated base64-encoded JPEG frames in a data URL, causing the server to decode all frames into memory and crash due to excessive memory consumption. This vulnerability is reachable via the OpenAI-compatible chat completions API and does not require authentication.
vLLM versions 0.8.0 and later are vulnerable to an Out-of-Memory (OOM) Denial of Service (DoS) attack due to unbounded frame count processing in the VideoMediaIO.loadbase64() method. When processing video/jpeg data URLs, the method splits the base64 data string on commas to extract individual JPEG frames without enforcing a frame count limit. An attacker can exploit this by crafting a single API request containing thousands of comma-separated base64-encoded JPEG frames in a data URL, causing the server to decode all frames into memory and crash due to excessive memory consumption. This vulnerability is reachable via the OpenAI-compatible chat completions API and does not require authentication.
Summary
vLLM's revision pinning controls do not consistently apply to all artifacts loaded for a model. A deployment that supplies --revision or --code-revision can still load dynamic code, GGUF files, image processors, retrieval side weights, or same-repository subfolder weights/config from an unpinned/default revision.
This is a supply-chain integrity issue for pinned vLLM deployments. Operators can believe they are serving a reviewed model revision while vLLM resolves behavior-affecting nested or sibling artifacts outside that reviewed revision.
Details
The expected invariant is:
When a vLLM operator supplies a model or code revision pin, every code, config, processor, weight file, side weight, and same-repository subfolder artifact loaded as part of that model should resolve under that pin unless vLLM exposes and enforces a separate explicit pin for that artifact.
Current main was verified affected at commit 3795d7acf431980e62e738493f437ae2a51549da.
Affected source boundaries:
- vllm/modelexecutor/models/registry.py:1045-1051 and :1058-1064 - tryresolvetransformers() passes revision=modelconfig.revision and trustremotecode=modelconfig.trustremotecode, but omits coderevision=modelconfig.coderevision for external automap dynamic module imports. - vllm/modelexecutor/modelloader/ggufloader.py:58-60 - The direct-file GGUF form repo/file.gguf calls hfhubdownload(repoid=repoid, filename=filename) without passing revision. - vllm/modelexecutor/models/roberta.py:203-209 - BGE-M3 secondary sparse and ColBERT side weights are declared with revision=None. - vllm/modelexecutor/models/kimik25.py:111-114 - Kimi-K2.5 calls cachedgetimageprocessor() without passing modelconfig.revision. - vllm/modelexecutor/models/kimiaudio.py:92-95 - Kimi-Audio loads Whisper config from the whisper-large-v3 subfolder without a revision argument. - vllm/modelexecutor/models/kimiaudio.py:425-430 - Kimi-Audio declares same-repository whisper-large-v3 secondary weights with revision=None. - vllm/modelexecutor/modelloader/defaultloader.py:287-301 - The default loader preserves modelconfig.revision for the primary source, then consumes model-supplied secondary sources as declared.
The strongest example is Kimi-Audio: the primary moonshotai/Kimi-Audio-7B-Instruct weights preserve the configured model revision, but the same-repository whisper-large-v3 audio tower config/weights do not. A pinned Kimi-Audio deployment can therefore load the Whisper subfolder outside the audited revision.
This report does not claim a trustremotecode=False bypass, unauthenticated RCE, or real artifact compromise. The issue is improper propagation of explicit artifact pins across supported loader paths.
Impact
Affected users are operators who pin vLLM model deployments to a reviewed Hugging Face revision for safety review, provenance, rollback, or reproducibility. The impact is that the pin does not reliably describe the full set of artifacts vLLM serves. Even when the operator selects an audited revision, vLLM can resolve behavior-affecting secondary artifacts from the repository default branch or another mutable ref.
Depending on the model path, the unpinned artifact can be dynamic model code, a GGUF file, an image processor, retrieval side weights, or the same-repository Kimi-Audio Whisper subfolder weights/config.
This breaks the operational guarantee of a pinned deployment: "serve the exact artifact set I reviewed." A later change to an unpinned secondary artifact can alter model behavior without changing the operator's configured revision, making review, rollback, incident response, and audit records unreliable.
Occurrences
- vllm/modelexecutor/models/kimik25.py L111-L114 — Kimi-K2.5 loads its image processor with cachedgetimageprocessor() but does not pass self.ctx.modelconfig.revision. The processor can therefore resolve from the default repository revision even when the model deployment is pinned. - vllm/modelexecutor/models/kimiaudio.py L425-L430 — Kimi-Audio declares same-repository whisper-large-v3 secondary weights with revision=None. A pinned Kimi-Audio deployment can therefore load the Whisper audio tower weights from an unpinned/default revision. - vllm/modelexecutor/models/kimiaudio.py L92-L95 — Kimi-Audio loads Whisper config from the same repository's whisper-large-v3 subfolder without passing the top-level model revision. The config for this behavior-affecting subcomponent can be resolved outside the audited model revision. - vllm/modelexecutor/models/registry.py L1058-L1064 — The later dynamic model-class resolution repeats the same pin-decay pattern: it forwards revision and trustremotecode, but omits coderevision. This means an operator-provided code pin is not enforced at the dynamic module loader boundary. - vllm/modelexecutor/modelloader/ggufloader.py L58-L60 — The direct GGUF form repo/file.gguf calls hfhubdownload(repoid=repoid, filename=filename) without passing modelconfig.revision. A deployment that pins the model revision can therefore resolve this GGUF file from the repository default revision. - vllm/modelexecutor/models/registry.py L1045-L1051 — trygetclassfromdynamicmodule() is called for external automap config/model classes with revision=modelconfig.revision, but without forwarding modelconfig.coderevision. When --code-revision is set, this dynamic module resolution can still fall back to the default code revision instead of the audited code revision. - vllm/modelexecutor/models/roberta.py L203-L209 — BgeM3EmbeddingModel creates same-repository secondary sparse/ColBERT weight sources with revision=None. The primary model revision is not propagated to these side weights, so they can be downloaded outside the operator-selected model revision.
Fixes
This was fixed in: https://github.com/vllm-project/vllm/pull/42616
Originally filed via huntr: https://huntr.com/bounties/3f1e24c0-87d2-4f6c-a705-820f380879ac.
The vLLM maintainer (Russell Bryant) redirected the report to the private GHSA channel. Offline proof bundle (vllmartifactpindecaybundleverify.py + bundle-verification-20260430T143506Z.json) is available upon request.
Summary
The extracthiddenstates speculative decoding proposer in vLLM returns a tensor with an incorrect shape after the first decode step, causing a RuntimeError that crashes the EngineCore process. The crash is triggered when any request in the batch uses sampling penalty parameters (repetitionpenalty, frequencypenalty, or presencepenalty).
A single request with a penalty parameter (e.g., "repetitionpenalty": 1.1) is sufficient to crash the server. The crash is deterministic and immediate — no concurrency, race condition, or special workload is required.
Details
In vLLM v0.17.0, the extracthiddenstates proposer's propose() method returned sampledtokenids.unsqueeze(-1), producing a tensor of shape (batchsize, 1).
In PR #37013 (first released in v0.18.0), the KV connector interface was refactored out of propose(). The return type changed from tuple[Tensor, KVConnectorOutput | None] to Tensor, and the .unsqueeze(-1) call was removed along with the KV connector output:
python Before (v0.17.0): return sampledtokenids.unsqueeze(-1), kvconnectoroutput # shape (batchsize, 1)
After (v0.18.0+): return sampledtokenids # shape (batchsize, 2) after first decode step
The refactor missed that sampledtokenids changed semantics between the first and subsequent decode steps. After the first decode step, the rejection sampler allocates its output as (batchsize, maxspeclen + 1). With numspeculativetokens=1, this produces shape (batchsize, 2) instead of the expected (batchsize, 1), causing a broadcast shape mismatch during penalty application.
Impact
Any vLLM deployment between v0.18.0 and v0.19.1 (inclusive) configured with extracthiddenstates speculative decoding is affected. A single API request containing any penalty parameter immediately and permanently crashes the EngineCore process, resulting in complete loss of service availability.
Patches
Fixed in PR #38610, first included in vLLM v0.20.0. The fix slices the return value to sampledtokenids[:, :1], ensuring the correct (batchsize, 1) shape regardless of the rejection sampler's output dimensions.
Workarounds
- Upgrade to vLLM v0.20.0 or later. - If upgrading is not possible, avoid using extracthiddenstates as the speculative decoding method on affected versions. - Alternatively, reject or strip penalty parameters (repetitionpenalty, frequencypenalty, presencepenalty) from incoming requests at an API gateway before they reach vLLM.
Summary This report explains a Token Injection vulnerability in vLLM’s multimodal processing. Unauthenticated, text-only prompts that spell special tokens are interpreted as control. Image and video placeholder sequences supplied without matching data cause vLLM to index into empty grids during input-position computation, raising an unhandled IndexError and terminating the worker or degrading availability. Multimodal paths that rely on imagegridthw/videogridthw are affected. Severity: High (remote DoS). Reproduced on vLLM 0.10.0 with Qwen2.5-VL.
Details - Affected component: multimodal input position computation. - File/functions (paths are indicative): - vllm/modelexecutor/layers/rotaryembedding.py - getinputpositionstensor(...) - vlgetinputpositionstensor(...) - Failure mechanism: - The code counts detected vision tokens and then indexes videogridthw/imagegridthw accordingly. - When user input carries placeholder tokens but no actual multimodal payload, these grids are empty. The code does not bounds-check before indexing.
Representative snippet (context): python vllm/modelexecutor/layers/rotaryembedding.py @classmethod def vlgetinputpositionstensor( cls, inputtokens, hfconfig, imagegridthw, videogridthw, ..., ): # detect video tokens videonums = (visiontokens == videotokenid).sum() # later in processing t, h, w = ( videogridthw[videoindex][0], # IndexError if no video data videogridthw[videoindex][1], videogridthw[videoindex][2], )
Abbreviated call path: OpenAI API request → vllm.v1.engine.core: step/executemodel → vllm.v1.worker.gpumodelrunner: updatestates/executemodel → vllm.modelexecutor.layers.rotaryembedding: getinputpositionstensor → vlgetinputpositionstensor → IndexError: list index out of range
PoC Environment - vLLM: 0.10.0 - Model: Qwen/Qwen2.5-VL-3B-Instruct - Launch server: bash python -m vllm.entrypoints.openai.apiserver \ --model Qwen/Qwen2.5-VL-3B-Instruct \ --port 8000
Request (text-only, no image/video data) bash cat > request.json <<'JSON' { "model": "Qwen/Qwen2.5-VL-3B-Instruct", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "what's in picture <|visionstart|><|imagepad|><|visionend|>" } ] } ] } JSON
curl -s http://127.0.0.1:8000/v1/chat/completions \ -H 'Content-Type: application/json' \ --data @request.json
Observed result - HTTP 500; logs show IndexError: list index out of range from vlgetinputpositionstensor(...). - In some deployments, the worker exits and capacity remains reduced until manual restart.
Impact - Type: Token Injection leading to Remote Denial of Service (unauthenticated). A single request can trigger the fault. - Scope: Any vLLM deployment that serves VLMs and accepts raw user text via OpenAI-compatible endpoints (self-hosted or proxied/managed fronts). - Effect: Request → unhandled exception in position computation → worker termination / service unavailability.
Fixes
Changes associated with https://github.com/vllm-project/vllm/issues/32656
Credits Pengyu Ding (Infra Security, Ant Group) Ziteng Xu (Infra Security, Ant Group)
A vulnerability was found in vLLM up to 0.19.0. The affected element is the function hasmambalayers of the file vllm/v1/kvcacheinterface.py of the component KV Block Handler. Performing a manipulation results in uninitialized resource. It is possible to initiate the attack remotely. The attack is considered to have high complexity. The exploitability is described as difficult. The exploit has been made public and could be used. The existence of this vulnerability is still disputed at present. The proposed patch did not fix the issue. A 3rd party explains: "The divergence could be explained by a benign and expected vLLM behavior where vLLM server could group concurrent requests together resulting in different input shapes based on varying request arrival time. The differences in grouped input shapes could call different kernels with could produce difference results due to rounding and differences in order of operations. There is an environment variable VLLMBATCHINVARIANT=1 for users that desire to have deterministic output with temperature 0.0."