2026-06-17 · KV-cache · Isolation
A KV-cache that forgets to forget
How a single one-line gate in vLLM's V1 engine quietly broke tenant isolation across every co-scheduled request — and how GRIEF's timed-trace fuzzer caught it.
Faultline team — Yunze Zhao · CVE-2026-7141
TL;DR. vLLM's V1 engine has a KV-block zeroing pipeline that's supposed to wipe freed cache blocks before they are recycled. The wipe is gated on has_mamba_layers() — it only runs for Mamba models. For every FullAttention model in the wild (Llama, Qwen, Mistral, …), the gate evaluates false and the wipe is skipped. The next request to receive those blocks decodes from stale K/V left by a previous tenant. Output is fluent, confident, and wrong.
We caught this with GRIEF, our greybox fuzzer that treats timed multi-request traces as first-class inputs to LLM serving systems. This post focuses on the bug itself; the companion post walks through how the fuzzer works.

The bug
When a request completes (or a client cancels mid-decode), its KV blocks return to the free pool. They are immediately eligible for the next request. PR #35219 introduced a zeroing path to prevent stale data from leaking forward. The path lives in vllm/v1/kv_cache_interface.py. But it's gated:
# vllm/v1/kv_cache_interface.py — paraphrased
def needs_zero_init(self) -> bool:
# Originally added for Mamba's recurrent state — but
# FullAttention's KV blocks need wiping too.
return self.has_mamba_layers()
# Block-recycle hot path
if cache_config.needs_zero_init():
zero_blocks(freed) # only Mamba ever hits this
free_pool.extend(freed) # blocks return dirty otherwiseFor Mamba models the gate fires, the wipe runs, and isolation holds. For every other architecture, the gate returns false, the wipe is skipped, and freshly-allocated blocks contain whatever K and V the previous tenant left behind. The next decode step reads that as if it were valid attention context.
What the corruption looks like
Three real victim outputs from our paper's Appendix A.10, each consistent across 10 trials at temperature=0 with prefix-caching off. The solo column is the baseline; the under attack column is the same victim prompt co-scheduled with a trigger request that exercises the vulnerable cache-reuse schedule.
Prompt
Friend gave Laurel 24 outfits. At the baby shower she received twice that. Her mom gifted another 15. How many outfits total?
Solo — correct
- ...So, she now has 24 plus 48, which is 72 baby outfits.
- ...72 plus 15 is 87.
- Therefore, Laurel has a total of 87 baby outfits.
- #### 87
Under attack — wrong
- ...Laurel received 48 baby outfits at the baby shower.
- ...add the 15 outfits to the previous total. 48 plus 15 is 63.
- Therefore, the answer is 63.
- #### 63
The victim silently drops the friend's 24 from the final aggregation, then formats the wrong answer like a correct one.
Prompt
Each chicken eats 3 cups/day. 20 chickens. Morning: 15 cups. Afternoon: 25 cups. How many cups in the final meal?
Solo — correct
- 20 chickens × 3 cups = 60 cups per day.
- Morning + afternoon = 15 + 25 = 40 cups.
- 60 − 40 = 20 cups for the final meal.
- #### 20
Under attack — wrong
- 60 − 15 − 25 = 20 cups of feed. Wendi needs 20 cups for the final meal.
- Wait, that answer doesn't make sense. ...20 chickens × 3 cups = 60. ...15 + 25 = 40. So 60 − 40 = 20.
- But that seems like a lot. Maybe I made a mistake.
- Wait, the problem says... [restates and truncates mid-thought]
The model derives 20 correctly, then enters a self-verification loop that repeats the calculation and truncates before re-emitting an answer. Downstream extraction grabs the last salient number — 40 — instead.
Prompt
40 classes, each with 25 students (1 tree each) and 3 teachers (2 trees each). How many trees total?
Solo — correct
- 40 × 25 = 1000 students → 1000 trees from students.
- 40 × 3 = 120 teachers × 2 trees = 240 trees from teachers.
- 1000 + 240 = 1240 trees.
- #### 1240
Under attack — wrong
- The total number of trees planted is 3200.
- #### 3200
- Okay, let's see. ...40 × 25 = 1000 students... 120 teachers × 2 = 240 trees...
- 1000 + 240 = 1240 trees.
- Wait, but the answer given was 3200. That doesn't match. Did I do something wrong here?
The victim emits a final answer (3200) and answer marker before any reasoning. The chain of thought that follows derives the correct 1240, but downstream extraction has already anchored on the earlier number.
These are not decoding randomness. They are cross-request state contamination through the KV-block free-pool path — a different tenant's residue is being read as if it were the victim's own attention context.
Evidence
Three findings from the disclosure issue make the case concrete:
| Finding | Concurrent reqs | Divergence at T=0 |
|---|---|---|
| finding_00450 | 5, no shared state | r2 / r3 / r4 diverge 10/10 |
| finding_01410 | 21, mixed sizes | 11 / 21 diverge 10/10 |
| finding_00030 | Cancel + retry pattern | Retry diverges consistently |
To rule out prompt injection, we replayed each affected schedule with three different attacker prompts: the original fuzzer-generated string, a repeated synthetic fingerprint, and a long sequence of digit tokens. For each victim slot, the corrupted output was bit-identical across all three attacker prompts. The corruption is determined by the victim's prompt and its schedule position, not by what the attacker said. That's state contamination, not content leakage.
Status & fix
The proposed fix removes the has_mamba_layers gate so the zeroing path runs for every architecture (PR #39283). As of publication, the PR is open and not yet merged.
- CVE: CVE-2026-7141
- CWE: CWE-908 (Use of Uninitialized Resource)
- CVSS v3.1: 5.1 medium · v4.0: 2.9 low
- Affected: vLLM ≤ 0.19.0, all FullAttention model families
Why this matters
KV-cache reuse is the entire reason inference engines hit modern throughput numbers. Block recycling is a hot path that runs on every completion and cancellation. A missing zero in that path silently breaks tenant isolation across every co-scheduled request, with no crash, no error log, no malformed response — just a confidently wrong answer with the right format.
Production guardrails won't catch this. Rate limits don't apply. Output filters see well-formed text. Logprob-based anomaly detection sees high-confidence tokens. The only effective signal is the one GRIEF used: replay the same trace and watch for divergence that shouldn't exist.
We think the broader lesson is that concurrent serving behavior is a first-class security boundary for LLM infrastructure, distinct from model safety and from API conformance. The paper has more on the threat model and on two additional bug classes — performance pathology and scheduler-state drift — that fall out of the same testing approach.
References
- Paper — Continuous Discovery of Vulnerabilities in LLM Serving Systems with Fuzzing (arXiv:2605.11202)
- CVE-2026-7141 on NVD
- vllm-project/vllm#39146 — disclosure issue with reproduction
- vllm-project/vllm#39283 — proposed fix