---
id: "GHSA-fpch-3xrf-7q96"
page: "https://infrastack.ai/live/GHSA-fpch-3xrf-7q96"
kind: "vulnerability"
aliases: ["GHSA-fpch-3xrf-7q96"]
title: "Thinking-budget sparse state aliases onto a co-batched request during a mixed `SWAP`, forcing the reasoning-end token into another request's output"
severity: "medium"
cvss: 5.3
epss: null
exploited: false
status: "open"
category: "ai-tooling"
products: [{"name": "vllm", "fixed": ["0.28.0"], "affected": ">= 0.21.0, < 0.28.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["ai-tooling", "cwe-488", "llm-serving", "python", "self-hosted", "vllm"]
not_affected_if: ["default-config", "model-runner-v2", "non-reasoning-models"]
published_at: "2026-11-09T17:30:49+00:00"
first_seen_at: "2026-11-09T22:53:22+00:00"
updated_at: "2026-11-09T22:56:25+00:00"
sources: [{"source": "ghsa-repo", "url": "https://github.com/vllm-project/vllm/security/advisories/GHSA-fpch-3xrf-7q96", "first_seen_at": "2026-11-09T22:53:22+00:00"}]
exploit_sources: []
---

# Thinking-budget sparse state aliases onto a co-batched request during a mixed `SWAP`, forcing the reasoning-end token into another request's output

Link for people: https://infrastack.ai/live/GHSA-fpch-3xrf-7q96 (this .md is for agents)

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.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:vllm affected >= 0.21.0, < 0.28.0 fixed 0.28.0

## How to check

- Run `pip show vllm` and check whether the version is at least `0.21.0` and below `0.28.0`.
- Check whether reasoning support is enabled with `reasoning_end_str` configured, and whether the server runs Model Runner V1.
- Check whether `thinking_token_budget` is accepted by the server's completion route.

## Fix

- `vllm` `0.28.0` (patched version per the advisory; fixed in pull request 49613)

## Timeline

- 2026-11-09T22:53:22+00:00 ghsa-repo https://github.com/vllm-project/vllm/security/advisories/GHSA-fpch-3xrf-7q96

## Documents

- [advisory] Maintainer's advisory: github.com https://github.com/vllm-project/vllm/security/advisories/GHSA-fpch-3xrf-7q96

## From the sources

## Weakness

CWE-488 (Exposure of Data Element to Wrong Session)

## Affected

- **Ecosystem / package:** pip / `vllm`
- **Affected versions:** vLLM v0.21.0 through v0.27.1 when using the V1 model runner.
- **Patched version:** v0.28.0.

## Summary

`thinking_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.

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.

## Affected code

Links pinned to the confirmed commit [`752a3a504485`](https://github.com/vllm-project/vllm/tree/752a3a504485790a2e8491cacbb35c137339ad34) (v0.25.1):

- [`vllm/v1/sample/thinking_budget_state.py` — `ThinkingBudgetStateHolder.sync_batch()` (L82-L115)](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/sample/thinking_budget_state.py#L82-L115). The buggy `SWAP` branch reads both endpoints with `get()` instead of popping them ([L101-L107](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/sample/thinking_budget_state.py#L101-L107)):

```python
# 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`.

- The forced-token sink iterates every sparse row whose state is in the reasoning-end phase ([`thinking_budget_state.py` — `_apply_forcing_to_logits()` force loop, L515-L556](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/sample/thinking_budget_state.py#L515-L556), writing at [L585](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/sample/thinking_budget_state.py#L585)); aliased state makes both rows receive the forced end token:

```python
# 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):
```

```python
# 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)](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/worker/gpu_input_batch.py#L567-L642) records the two-way `SWAP` move for row-indexed sampler state.
- [`vllm/v1/attention/backends/utils.py` reorder loop (L729-L738)](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/attention/backends/utils.py#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](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/attention/backends/utils.py#L734)).
- The correct shape already exists in the generic sparse-state helper: [`vllm/v1/sample/logits_processor/builtin.py` — `process_dict_updates()` SWAP branch (L315-L327)](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/sample/logits_processor/builtin.py#L315-L327), which pops both endpoints before reassigning.
- The parameter enters through [`vllm/entrypoints/openai/completion/protocol.py#L207`](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/entrypoints/openai/completion/protocol.py#L207) and is gated in [`vllm/v1/engine/input_processor.py#L102-L116`](https://github.com/vllm-project/vllm/blob/752a3a504485790a2e8491cacbb35c137339ad34/vllm/v1/engine/input_processor.py#L102-L116) (reasoning must be enabled; only the legacy runner is supported).

## Impact

**Preconditions:**

- reasoning support is enabled and `reasoning_end_str` is configured;
- the server runs Model Runner V1;
- the attention backend takes a reorder path that emits a `SWAP`;
- the attacker grooms the queue so a budgeted row and an unbudgeted victim row land on opposite sides of that 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.

## Credit

**Reported by:** Patch the Planet (Trail of Bits + OpenAI collaboration)

This vulnerability was discovered using GPT-5.5-Cyber as part of the Patch the Planet security initiative.

A fix for this was introduced in https://github.com/vllm-project/vllm/pull/49613.

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
