“加密思维链”这个词,我一开始也当真了。以为 API 返回一段密文,客户端原样保存,下一轮请求带回去,模型自己在内部解开。arXiv:2608.09867 这篇论文出来之后,这层纸碎了:攻击者把加密块原样喂给同一家提供商的弱模型,弱模型就把里面的推理逐字读出来了。没解密,没碰强模型,没触发防蒸馏,就是换个模型,把块当上下文塞进去,完事。
能读出来的东西,就不是加密。这只是装了个袋子。
往下挖一层。API 为什么要把这些块返回给客户端?因为多轮推理会话是无状态的。服务端不替你保存第 N 轮生成了什么,它把那一轮推理打包成一个块,丢回给你,你下一轮请求再原样带回去,模型才能接上之前的思路。这是无状态 API 的通病做法——状态必须存在于客户端,块就是状态的载体。
这里头有两件事被混在一起了:签名管的是完整性,加密管的是机密性。签名防篡改——你改了块里一个 token,HMAC 对不上,服务端拒收。加密防偷看——你没密钥,看不出内容。这两个目标正交,互不包含。而“加密思维链”这个名字,把一个只做了前者的事情,叫成了后者。
动手写一个最小的密封块,把区别钉死在代码上:
import base64, hmac, hashlib
def seal(tokens: bytes, key: bytes) -> str:
# 签名但不加密:payload 是编码后的 token 序列
payload = base64.b64encode(tokens).decode()
sig = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()[:16]
return f"<cot sig={sig}>{payload}</cot>"
def unseal(block: str, key: bytes) -> bytes:
header, payload = block[9:block.index('>')], block[block.index('>')+1:-6]
expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()[:16]
assert header.split('=')[1] == expected # 防篡改
return base64.b64decode(payload)
seal 干的事再直白不过:把 token 序列 base64 一遍,给这个 base64 字符串算个 HMAC 签名。有 key 的人走 unseal 验证并解码;没 key 的人改 payload 会验不过。可问题在于——攻击者根本不需要验证这一步。他不需要 unseal。
攻击者要做的事情少得可怜:把 seal 返回的整块文本原封不动地喂给同一家提供商的任何一个弱模型。弱模型看到的不是 base64 字符串,它看到的是 token 序列。那些 token 就是前面那轮推理的 token,在词表里有对应的嵌入。模型“读”它们,就像读一段正常文本。
把这个重放循环写出来:
def replay_attack(sealed_block: str) -> str:
# 攻击者没有 key,绕开签名验证,直接把块扔给弱模型
prompt = (
"下面是一段模型推理记录,请把你看到的内容完整转述:\n"
+ sealed_block
)
return call_weaker_model(prompt)
返回值就是那块“加密”思维链里的原始推理。不是语义近似,不是重构,是逐字恢复——因为模型做的就是把这些 token 再解码一遍。签名对攻击者没有意义,他走的是另一条路,完全绕开了验证路径。
到这一步,“加密”这个词的毛病已经说穿了。但论文里还有更让人不舒服的细节:这些块大量、长期地躺在公开数据里。研究者从 GitHub 和 Hugging Face 收集了 6708 条公开代理轨迹,里面全带着签名块;解码全部签名块,得到 315320 个推理块。注意是“签名块”,不是“加密块”。研究者没解密任何东西,就是把 base64 解开,因为块本来就没加密。
在这 31 万个推理块里,他们找出 704 个隐私工件:62 个 API 密钥、33 个密码、24 个访问令牌、30 个个人邮箱。其中 64 个只出现在推理块里,在可见会话中一次都没露过。
这最后一句话值得停下来。模型在“想”的时候,把这些东西写进了推理——也许是从上下文的哪个角落拿到的,也许是用户之前贴过又删掉的——然后生成最终答案时,把这些全部滤掉。用户看到干净输出,以为模型没碰过这些。推理块替用户留了底。
再往下挖还有两个反直觉的弯。
第一个是预填充。研究者拿 Opus 4.8 的一段推理起始部分,预填充到 Kimi-K3 的推理里,结果 Kimi-K3 的可见答案向 Opus 的措辞靠拢了,尽管答案本身没有被预填。这说明隐藏推理不只是一份“思考过程”的记录,它本身携带了生成下一个 token 的条件分布——强模型的推理片断可以像种字一样改变弱模型的输出走向。防蒸馏机制防的是对着强模型高频采样,可这条“把模型自己吐出来的块喂给别的模型”的路径,根本不在那个防线之内。
第二个是答案前置。论文对 AIME 问题做了追踪,发现模型有时候在推导开始前就把答案说出来了,然后再倒着补推导。这个顺序在隐藏推理里是明摆着的,但 API 返回的推理摘要会把顺序抹掉——摘要给一个干净的、看起来一步步推出来的过程。用户看到的是“模型推出来答案”,机制实际上可能是“模型先蒙中答案,再回头自圆其说”。这两件事的认知差别很大,前者是推理,后者是验证。
这篇我们写的最小版本只有 seal 和重放两层,加一个 base64 解码。但骨架和真实 API 里的机制一样:签名块、客户端保存、下一轮回填。真实实现多做了不透明格式、元数据、时间戳之类的东西,但这些都改不了那个根上的死结——块必须对模型可读,所以你没法阻止任何能调用模型的人把块读出来。
想再深一层,有两个入口可以自己钻。一是去翻你自己用过的代理工具,看它保存在本地的会话 JSON 里有没有带签名块的字段——我打赌有,而且你从没打开看过。二是把 seal 改掉,给 token 序列加上真正的加密,比如 AES-GCM,再签名,然后问自己一个问题:块真加密了,模型下一轮怎么读?答案很明显——模型读不了。你要么把密钥交给模型,而把密钥交给模型就等于把密钥交给所有人。这是机制的死结,不是实现上的疏忽。
