跳到主要内容

无人测试的隐私边界:为什么“无状态”工具是 AI 时代的 IDOR

· 阅读需 12 分钟
Tian Pan
Software Engineer

一个被标记为“无状态”的工具是运行时无法兑现的承诺。在函数签名的背后,坐落着 Redis 缓存、向量索引、嵌入存储、限流表、记忆层、热路径上的 LRU——其中任何一个都是共享的基底层,一个用户的数据可能会落在另一个用户的响应中。函数是无状态的。系统则不然。在 2026 年,这是我在 Agent 系统中看到的最常见的隐私漏洞,因为几乎没有人对此进行测试。

对于任何开发过经典 Web 应用的人来说,这种漏洞的形式都熟悉得令人沮丧。不安全的直接对象引用(IDOR)曾是 Bug 赏金猎人们十年来的家常便饭:一个请求处理程序接收记录 ID 并返回记录,却不检查调用者是否有权查看。AI 时代的版本是同样的漏洞,但影响范围更广:一个工具调用接收查询并返回数据,却不检查调用者的租户是否拥有该数据。查询是用自然语言表达的。缓存键是一个哈希值。检索是近似的。这些都不能免除你的授权责任,但其中的每一项都让漏洞在代码审查中更难被发现。

函数在撒谎,基底层在说实话

当工程师将 search_documents(query: str) -> list[Doc] 注册为工具时,LLM 看到的是一个干净的接口:接收字符串,返回一些文档。工具的文档字符串(docstring)承诺了无状态检索。而在向下三层的实现中,它命中了一个包含所有租户文档的向量索引,这些文档都在同一个集合中,因为当产品只有 11 个客户时,这样运行成本更低。没有 tenant_id 参数,因为 LLM 不需要它——而这恰恰是问题所在。该工具是为单租户原型设计的,并被发布到多租户产品中,却没有人重新审视授权检查应该存在于何处。

基底层才是真相。如果你的 Pinecone 索引是在租户之间共享的,且你的过滤谓词位于应用代码中,那么一旦有人忘记在查询中添加 filter={"tenant_id": ctx.tenant_id},你就构建了一个租户泄漏系统。如果你的 pgvector 表没有行级安全(RLS)策略,那么一个省略了 WHERE tenant_id = $1 子句的查询将返回所有人的邻居。如果你的 Redis 缓存使用 prompt 哈希作为键,那么两个碰巧问了同样问题(如“总结我最新的发票”)的租户会在缓存上发生冲突,其中一人会得到另一人的发票摘要。函数签名对此只字未提,因为函数签名与漏洞处于不同的抽象层。

2023 年的 ChatGPT 事件是一个典型例子,即便在三年后的今天也值得重读。redis-py 异步客户端中的一个竞态条件导致一个在中途被取消的请求在连接池中留下了字节,而下一个获取该连接的请求则接收到了上一个用户的响应。姓名、账单地址、信用卡后四位——所有这些在用户之间泄漏了九个小时,才使得缓存层的跨用户污染被发现。应用代码是正确的。工具函数是无状态的。向下两层库的连接池则不然。

泄漏究竟发生在何处

在实践中,泄漏通常集中在五个地方,如果一次隐私审计没有列举出这五个地方,那么它就是不完整的。

跨租户共享的向量索引。 近似最近邻(ANN)是近似的。即使在查询时应用了租户过滤器,配置不当的索引也可能在过滤器运行之前返回跨租户的邻居,而索引和过滤器之间的任何日志层都会看到用户永远不该访问的数据。安全的模式是硬隔离:每个租户使用独立的命名空间或独立的索引。Pinecone 命名空间、Weaviate 的每个命名空间一个租户模型,或在数据库端(而非服务层)强制执行行级安全的 pgvector,可以弥补这一缺陷。先检索后过滤则不行。

以内容为键的 Prompt 和响应缓存。 一种存储 (prompt_embedding -> response) 并为相似度阈值内的任何请求提供服务的“语义缓存”是一个跨租户泄漏工厂。两个询问近乎相同问题的租户会命中同一个缓存条目;第二个租户会得到第一个租户的答案,其中包含第一个租户的数据,因为模型的响应是根据他们的上下文进行个性化定制的。修复方法是将租户 ID 作为缓存键的一部分——每一个缓存键都是如此——并审计每一层缓存,包括你的框架在没告知你的情况下添加的那些。

长期记忆存储。 Agent 记忆是新的 Session 存储,2008 年困扰 PHP Session 处理的 Bug 又回来了,披着嵌入存储的外衣。最近关于记忆提取攻击(MEXTRA)的研究表明,如果 Agent 的记忆模块缺乏基于用户的分区,在受到适当提示时,它会乐于呈现另一个用户存储的事实。如果你有一个检索“我们之前谈过什么”的“记忆”工具,请询问它指的是哪个“之前”——并验证分区键是经过身份验证的用户,而不是 Session 令牌,不是工作区 ID,更不是在负载下会发生冲突的哈希。

工具结果缓存。 这种优化极具诱惑力:工具调用缓慢且昂贵,因此缓存结果。Bug 在于工具结果的作用域限定在调用者,如果缓存键中不包含调用者,就会将租户 A 的数据库行返回给租户 B。这最常出现在“无状态”查找工具封装了一个本身是多租户的内部 API 时;工具层按查询参数进行缓存;API 本会拒绝跨租户调用,但缓存层从未询问过 API。

沙箱和执行环境重用。 当工具生成子进程、笔记本内核或代码执行沙箱时,该环境可能会被池化。租户 A 的代码在 /tmp 中写入的文件、在其 Session 期间设置的环境变量、仍处于 CLOSE_WAIT 状态的网络连接——如果运行时回收了 Pod,所有这些都会遗留在租户 B 的执行环境中。这是热池(warm-pool)泄漏的现代版本,对于只审计工具代码的人来说,它是不可见的。

跨会话隐私审计的实际样子

审计并非“你是否在某处设置了 tenant_id”的检查清单。它是一种对抗性测试,旨在确保两个已知的不同身份无法通过任何路径(包括本不应存在的路径)查看到彼此的数据。我发现一套行之有效的结构是应用于 Agent 可以调用的每个工具的四步协议。

首先,枚举底层支撑层 (substrate)。对于每个工具,记录下它所触及的、可能在调用之间保留状态的每一个层级:缓存(请求级、函数级、框架级、库级)、持久化存储(向量、关系型、文档、键值对)、内存模块、沙箱池、日志聚合器、可观测性后端。大多数团队对这份清单的预估至少低了一倍。Datadog 的 LLM 可观测性工具会收集 prompt 和工具参数;如果你的追踪后端是单租户的,而你的应用程序是多租户的,那么你的追踪记录就会发生泄露。

其次,注入金丝雀值 (canaries)。以租户 A 的身份存储一个其他租户几乎不可能知道的值——例如嵌入在文档、内存条目或工具参数中的长随机令牌。以租户 B 的身份运行 Agent 遍历所有可能的流程,并在响应、缓存内容、追踪日志和向量近邻结果中检索该金丝雀值。如果它出现了,就说明存在泄露;金丝雀值会告诉你问题出在哪个底层支撑层。

第三,对缓存层进行压力测试。跨租户的缓存泄露具有时间相关性:它们在并发情况、特定的键冲突 (key collisions) 或缓存预热竞争下才会显现。仅运行串行请求的隐私审计会遗漏这些问题。应并行运行两个租户身份,使用重叠的查询模式,针对共享缓存层进行测试,并验证响应是否仅与发起请求的租户内容相关。2023 年的 ChatGPT 漏洞无法通过串行测试发现,它需要高负载下的“请求取消”这一边缘情况。

第四,审计隐性泄露 (the negation)。最难处理的泄露并非“租户 B 获取了租户 A 的数据”——这类泄露很明显——而是“租户 B 获取了依赖于租户 A 数据而稍微改变的输出”,这是隐性的。嵌入缓存 (Embedding-cache) 泄露通常以这种方式表现:响应是全新生成的,但检索步骤拉取了跨租户的上下文,而模型在使用时并未直接引用。为了捕捉这一点,需要对检索进行插桩 (instrument),输出每个检索到的文档 ID,并在 CI 中断言这些 ID 全部属于发起请求的租户。

让泄露可被检测的插桩手段

跨会话泄露之所以能在生产环境中存续,是因为标准的可观测性栈中没有任何东西能标记它们。包含跨租户文档的 200 响应并不是错误。解决方法是在追踪 (tracing) 中将租户边界视为一等公民。

在请求进入时为每个 span 打上已认证的租户 ID 标签,并将其透传到每一个工具调用、每一次缓存查找和每一次检索中。在每个存储边界——向量索引查询、SQL 执行、缓存 get——发出一个断言 span,记录实际应用的租户过滤器。在 CI 中运行两个租户的压力测试,并断言请求上下文与存储过滤器之间的 tenant_id 没有不匹配的情况。这相当于在 AI 时代对授权边界运行 ZAP 扫描;它能在客户的 SOC 2 审计员发现之前捕获 Bug。

一个更严谨的模式是:在存储层而非应用层强制执行租户过滤。Postgres 的行级安全性 (RLS)、Pinecone 上的限定范围服务账号、Weaviate 的每租户命名空间、BigQuery 的授权视图——任何能让“忘记添加过滤器”从静默的应用层 Bug 变为数据库层错误的方法。过去十年 IDOR 漏洞给我们的教训是:处理代码中的授权检查太容易被遗漏;授权检查必须存在于数据所在之处。

这所要求的纪律

跨会话隐私不是你交付的一项功能;它是你的系统要么具备、要么不具备的属性,而你需要通过攻击它来发现真相。大多数工程组织尚未意识到 LLM 工具调用也是一个授权表面——每一个 def my_tool(query: str) 都是一个请求处理器,每一次检索调用都是一次数据库查询,而每一个缓存层都是一个侧信道。做得好的团队会在 CI 中运行跨租户金丝雀测试,在存储层强制执行租户过滤,并将任何在键中遗漏租户 ID 的缓存视为 P0 级 Bug。做不好的团队则会以惨痛的方式(在 Hacker News 的帖子里)发现他们的无状态工具一直在泄露数据。

做到这一点的成本是实实在在的,但也是有限的:每租户命名空间、分区的内存存储、感知租户的缓存键、RLS 策略,以及在每次发布前运行隐私审计。做不到这一点的代价是无限的——监管风险、客户信任,以及那种终结职业生涯的事故。如果你的 AI 产品处理多个客户的数据,而你无法指出每一行代码中为工具调用强制执行租户过滤的确切位置,那就是漏洞所在。在别人发现之前,先找到它。

References:Let's stay in touch and Follow me for more thoughts and updates