← Back to blog

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.

Diagram of three KV-cache state-corruption symptoms: confident value pollution, reasoning-chain disturbance, and answer-first reasoning confusion.
Three user-visible symptoms of a single KV-cache isolation bug. Reproduced from Zhao et al., 2026, Figure 1.

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 otherwise

For 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.

A — Confident value pollution10 trials · solo 87 · attack 63

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.

B — Reasoning-chain disturbance10 trials · solo 20 · attack 40 (extracted from truncation)

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.

C — Answer-first reasoning confusion10 trials · solo 1240 · attack 3200

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:

FindingConcurrent reqsDivergence at T=0
finding_004505, no shared stater2 / r3 / r4 diverge 10/10
finding_0141021, mixed sizes11 / 21 diverge 10/10
finding_00030Cancel + retry patternRetry 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.

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