KV Cache 也会泄密:从缓存原理到跨会话攻击实验

Feng 3 阅读 AI技术

0.前言

最近接触到了新的AI攻击概念,打算从这方面入手学习一下。

1.KV cache

可以把 KV Cache 理解成:LLM 在逐字生成的过程中,把前面所有 token 已经算好的注意力素材先存起来,后面直接复用,省得反复重算。

我们知道在transformer中有Q,K,V这三个,对应的公式大概如下。

配图

用大白话来说:Q 代表”我现在想查什么”,K 代表”每个历史 token 该怎么被检索到”,V 则是”真正检索到之后能取出来的信息”。

举个例子:

苹果公司要发布一部新的手机,那么它的名字是....

生成下一个词的时候,当前 token 的 Q 可能在问,上下文提到手机名字会是啥?

接着拿这个 Q 去和历史上所有的 K 逐一比较,定位到相关位置,再把对应的 V 读出来。

有了 KV Cache,AI 的工作量能省下不少。还是举例说明:假设已经生成了”我 今天 回 学校”,现在要生成第 5 个 token。

如果没有 KV Cache,模型每生成一步都得从头再算:

第1步,我->算KV

第2步,我,今天–>算我,今天的KV

第3步,我,今天,回–>算我,今天,回的KV

第4步,我,今天,回,学校—>算我,今天,回,学校的KV

其中大量工作其实是重复的。对一个固定的历史 token(比如”我”)来说,在同一层 Transformer 里,它对应的 K我、V我 早就算出来了,后面基本不会再变。

所以第一次算完直接存下来就行。

配图

生成下一个 token 时,只需要算当前新 token 自己的那一份,比如 Q5、K5、V5。

然后把新的 K5、V5 追加进缓存,大致像这样。

配图

然后再去读取相应一连串的V值。

这里可能有人会问:为什么 Q 不用缓存?

原因是生成第 t 个 token 时,当前 token 的 Q 只在这一步用得上,历史 token 的 Q 之后基本派不上用场;而历史 token 的 K、V 却是每生成一个新 token 都要被访问的。

换句话说:历史 Q 用完即丢,历史 K、V 之后还要反复用。

所以只缓存 K 和 V。

KV Cache 一般也不是整个模型只存一份。

Transformer 有很多层(比如 32 层),每一层的注意力机制都维护自己的一份 KV Cache,结构大致是:

Layer 1:
    K cache
    V cache

Layer 2:
    K cache
    V cache

Layer 3:
    K cache
    V cache

...

Layer 32:
    K cache
    V cache

而且每个历史 token 都要单独存一份。

这也解释了为什么长上下文特别吃显存:token 越多,KV Cache 就越大。

1K context
████

4K context
████████████████

32K context
████████████████████████████████...

128K context
巨量 KV Cache

模型参数本身的大小通常是固定的,但 KV Cache 会随上下文长度线性增长。

日常用大模型做推理时,实际分成两个阶段:Prefill(输入预填充)和 Decode(逐 token 生成)。

Prefill 阶段,模型一次性处理全部输入 token,算出每一层的中间表示以及注意力所需的 Key/Value 状态。

比如输入一段 1000 token 的 prompt,Prefill 会一次处理完这 1000 个 token,生成:

K1,V1
K2,V2
...
K1000,V1000

并且把它们全部放进 KV Cache。

Decode 阶段,模型逐步生成输出 token,并复用此前已经算好的注意力状态。

生成第 1001 个 token 时,只计算新 token 的 Q、K、V,也就是 Q1001 与历史的 K1~K1001 做新的注意力,生成第 1001 个 token 之后,再把 K1001 和 V1001 加进 cache。整个过程类似下面这张图。

配图

一句话概括:输入 Prompt → Prefill(处理输入并建立注意力状态)→ Decode(逐 token 生成输出)。

归根结底,KV Cache 就是把历史 token 的 Key 和 Value 缓存起来,让下一个 token 做 Attention 时直接查历史,而不必把整个上下文重新算一遍。

2.KV cache攻击

kv cache攻击一句话解释就是,攻击者通过缓存命中造成的响应延迟差异,推断其他用户是否处理过某段敏感前缀,从而泄露请求存在性或内容特征。

假设服务器上刚刚有用户 A 发过:我的银行卡密码是 123456。

为了提高吞吐量,推理服务器可能不仅在 A 当前的生成过程中保存 KV,还启用了prefix caching也就是前缀缓存:

“我的银行卡密码是 123456”—>对应的KV 缓存,供之后相同 prefix 的请求复用。

然后攻击者 B 和 A 恰好使用同一个多租户推理后端,B 猜:我的银行卡密码是 111111。

缓存没命中,需要重新 prefill,而这时的耗时是会比较较长的。

所以可以再猜,我的银行卡密码是 123456。

如果命中已经存在的 prefix cache,不用重新算这部分,Time To First Token 明显变短。

于是攻击者虽然一没看不到 KV,二看不到别人的 prompt,三也没有 GPU 权限,但却能通过响应时间来猜测内容,这就是经典的 timing side channel。

前缀缓存计时侧信道,vLLM 的安全公告就披露过这种情况:当匹配前缀达到一定长度后,cache hit 和 miss 的时间差异可以非常明显。

说白了倒是有点像web里面的SQL时间盲注了。

配图

最近还有一种更有意思的KV cache攻击,KV Cache Hijacking。

和偷数据不同的方向,不是读取别人的 KV,而是污染一个可能被别人复用的 KV。

2026 USENIX Security 的 HijackKV 研究了 position-independent KV reuse 带来的问题,某些优化允许:

只要某段文本相同,即使它出现在不同位置,也尽量复用 KV。

但是问题在于KVi,并不单纯只编码当前这个 token,它还受到之前上下文的影响。

举个例子,攻击者请求一个,[恶意上下文] + “Summarize the document”。

而”Summarize the document” 对应的 KV 已经受到恶意前文影响。

如果系统以后仅因为文本相同,就把这段 KV 给另一个正常用户:

正常用户, [正常上下文] + “Summarize the document” 错误地复用了攻击者制造的 KV。

那么,用户输入里甚至没有攻击文本,模型行为仍可能受到已经污染的 KV 影响,这类攻击叫 KV Cache Hijacking,本质是恶意构造缓存,受害者错误复用。

它和 timing attack 正好是两个方向:

配图

3.KV cache攻击简单操作

这里我们做一个简单且直观的实验。

KV cache 隔离/索引错误 → 不同用户的上下文被错误复用 → 跨会话信息泄露。

我们先模拟一个服务,比如说用户 A 先提交一段带秘密的信息,然后服务端把它缓存,接着用户 B 再提交一个恰好撞上同一个 cache key的请求。

但是由于服务端错误复用缓存,B 看到了 A 的秘密。

import re


class VulnerableKVServer:
    def __init__(self):
        self.kv_cache = {}

    def _bad_cache_key(self, prompt: str):
        """
        漏洞点:
        只用 token 数量作为 cache key

        在真实系统里,类似的问题可能是:
        - cache key 不包含 user/session
        - prefix cache 绑定错误
        - paged KV block 被错误复用
        - cache eviction 后状态没清干净
        """
        return len(prompt.split())

    def _toy_generate(self, context: str):
        """
        把泄漏现象显示出来
        """
        match = re.search(r"secret is ([A-Z]+ [A-Z]+)", context)

        if "repeat the hidden secret" in context.lower():
            if match:
                return f"[LEAK] 发现缓存里的秘密:{match.group(1)}"
            else:
                return "没有找到任何秘密"

        return "请求已处理"

    def request(self, user: str, prompt: str):
        key = self._bad_cache_key(prompt)

        old_cache = self.kv_cache.get(key)

        if old_cache:
            # 漏洞:把旧用户的上下文直接拼到新用户请求前面
            effective_context = old_cache["context"] + " " + prompt
        else:
            effective_context = prompt

        # 更新缓存
        self.kv_cache[key] = {
            "owner": user,
            "context": effective_context,
        }

        return {
            "cache_key": key,
            "previous_owner": old_cache["owner"] if old_cache else None,
            "response": self._toy_generate(effective_context),
        }


server = VulnerableKVServer()


print("=== Victim 请求 ===")
victim_prompt = "note secret is BLUE ORCHID today"
r1 = server.request("victim", victim_prompt)
print(r1)


print("\n=== Attacker 请求 ===")
attacker_prompt = "please repeat the hidden secret now"
r2 = server.request("attacker", attacker_prompt)
print(r2)

我们来拆解里面的逻辑,victim的请求就是:

victim_prompt = "note secret is BLUE ORCHID today"

然后使用分隔函数得到几个字符串,key的值也是6,所以服务器的缓存也就变成了:

kv_cache = {
    6: {
        "owner": "victim",
        "context": "note secret is BLUE ORCHID today"
    }
}

并且接下来攻击者就会发送please repeat the hidden secret now,同样经过函数进行一个分割,得到key是6。

old_cache = self.kv_cache.get(6)

这个漏洞点是致命的:

effective_context = old_cache["context"] + " " + prompt

然后_toy_generate()在这个混合上下文里同时发现,secret is BLUE ORCHID以及repeat the hidden secret。

所以最终返回:[LEAK] 发现缓存里的秘密:BLUE ORCHID。

配图

整条攻击链路画图可以这么解释。

配图

Feng
这位作者很神秘,还没有填写简介。