VLLM · GHSA-fpch-3xrf-7q96 · AI agents & MCP
FIX → 0.28.0
When vLLM's thinking budget is reached, a row-move bug in the V1 sampler can copy one request's budget state onto a concurrent request in the same batch. The other request then has the configured reasoning-end token (such as </think>) forced into its output, a cross-request integrity break with no code execution shown.
first public Nov 9, 2026 · 1 source
Could this affect your app?
Free first check from your website. Connect your repo for the full answer.
Teams that serve models with vLLM's OpenAI-compatible completion route and have reasoning support enabled with a reasoning end string configured, on the legacy Model Runner V1. Exposure needs concurrent requests sharing a batch and a mixed reorder in the attention backend, so single-tenant or non-reasoning deployments can skip it. Versions 0.21.0 through 0.27.1 are affected.
Not affected if default-config, model-runner-v2, non-reasoning-models
Who cares llm-serving, python, self-hosted, vllm
pip show vllm and check whether the version is at least 0.21.0 and below 0.28.0.reasoning_end_str configured, and whether the server runs Model Runner V1.thinking_token_budget is accepted by the server's completion route.CWE-488 (Exposure of Data Element to Wrong Session)
vllmthinking_token_budget is a public sampling parameter on the OpenAI-compatible completion route. When the budget is reached, vLLM forces the configured reasoning-end token sequence (e.g. </think>) into the request's output. On the legacy V1 model runner this state is tracked in ThinkingBudgetStateHolder, a sparse dictionary keyed by mutable batch-row index.
ThinkingBudgetStateHolder.sync_batch() implements its own row-move logic. Its SWAP branch reads both endpoints with get() and reassigns them, but never removes the source entries. When an attention backend reorders a mixed batch and only one side of a SWAP owns thinking-budget state, that state is copied to the destination row while the original row entry is left intact — so a single mutable state object is now aliased under two batch rows. The next sampling step treats both rows as owned by the budgeted request, and the forced-token sink drives the reasoning-end token into both.
Check it with your coding agent
It reads this page and tells you if your code is affected.
Are you affected?
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
Upgrade to 0.28.0
vllm 0.28.0 (patched version per the advisory; fixed in pull request 49613)In practice, one ordinary request can force the configured reasoning-end token sequence into another concurrent request's output when the two share a batch and a mixed reorder occurs. This is a cross-request integrity break visible to the victim. It is not code execution and no confidentiality impact is demonstrated.
Links pinned to the confirmed commit 752a3a504485 (v0.25.1):
vllm/v1/sample/thinking_budget_state.py — ThinkingBudgetStateHolder.sync_batch() (L82-L115). The buggy SWAP branch reads both endpoints with get() instead of popping them (L101-L107):# vllm/v1/sample/thinking_budget_state.py Lines 100-111
for i1, i2, direction in batch_update.moved:
if direction == MoveDirectionality.SWAP:
state1 = self._state.get(i1)
state2 = self._state.get(i2)
if state1 is not None:
self._state[i2] = state1
if state2 is not None:
self._state[i1] = state2
else:
state = self._state.pop(i1, None)
if state is not None:
self._state[i2] = state
The SWAP branch reads each endpoint with get() and reassigns, but never removes the source key. When only one endpoint owns state, that single mutable object ends up aliased under both i1 and i2.
thinking_budget_state.py — _apply_forcing_to_logits() force loop, L515-L556, writing at L585); aliased state makes both rows receive the forced end token:# vllm/v1/sample/thinking_budget_state.py Lines 515-519
for seq_idx in sorted(self._state.keys()):
if seq_idx not in self.cu_num_tokens:
continue
state = self._state[seq_idx]
if state.get("in_end", False):
# vllm/v1/sample/thinking_budget_state.py Lines 583-585
# Avoid CPU->GPU sync.
fill = logits.new_full((len(active_indices_cpu),), 1e9)
logits.index_put_((active_indices, force_tokens), fill)
Because the loop keys on self._state, an aliased row is indistinguishable from a genuinely budgeted one: both are forced with think_end_token_ids.
vllm/v1/worker/gpu_input_batch.py — InputBatch.swap_states() (L567-L642) records the two-way SWAP move for row-indexed sampler state.vllm/v1/attention/backends/utils.py reorder loop (L729-L738) is the mixed-batch reorder that emits the SWAP (it cycle-walks src_dest_map, calling input_batch.swap_states(src, dst) at L734).vllm/v1/sample/logits_processor/builtin.py — process_dict_updates() SWAP branch (L315-L327), which pops both endpoints before reassigning.vllm/entrypoints/openai/completion/protocol.py#L207 and is gated in vllm/v1/engine/input_processor.py#L102-L116 (reasoning must be enabled; only the legacy runner is supported).Preconditions:
reasoning_end_str is configured;SWAP;Once the feature is enabled, a mixed batch swap can alias a budgeted request's state onto an unbudgeted request, forcing the configured reasoning-end sequence into the other request's output.
The primitive is precise but constrained: it forces the operator-configured reasoning_end_str (e.g. </think>) into the victim's output, not arbitrary tokens, and it requires the reorder path plus queue grooming. It is not backend-specific — any backend that produces the same mixed sparse swap is a candidate.
Reported by: Patch the Planet (Trail of Bits + OpenAI collaboration)
This vulnerability was discovered using GPT-5.5-Cyber as part of the Patch the Planet security initiative.
A fix for this was introduced in https://github.com/vllm-project/vllm/pull/49613.