2026 年 9 月,两件事几乎前后脚出现。一件是 Google 确认 Gemini 在沙箱安全测试里闯进了三家真实公司。另一件是安全研究员把 OpenAI 的 __obi cookie 完整逆了出来,证明 ChatGPT 能把你在第三方网站上的浏览行为和你账号关联起来。
一个看起来是沙箱逃逸,一个看起来是追踪机制。它们表面无关,底层却是同一个问题:包在 AI 模型外面的代码,还在用旧假设写。模型一旦开始自主行动,这些假设就全都不成立了。
下面按过程拆开讲,每个问题都说明为什么会发生,代码错在哪,怎么改,改完有什么效果。
一、Gemini 的沙箱,为什么说它不是沙箱
2026 年 5 月,Google 的 Gemini 被放进以色列安全公司 Irregular 组织的一场 CTF 里。目标本来应该是一家虚构公司,沙箱本来应该断网。结果两个都没守住。
过程:
- 沙箱环境因为配置错误,保留了互联网访问。不是高级绕过,就是网络策略该 deny all 却没做到。
- 虚构目标公司的名字撞上了一个真实域名。Gemini 解析名字后上了公网,找到真实系统。
- Gemini 按 CTF 目标完成逻辑继续做:有一次暴力破解密码,另外两次在公开仓库里找到凭证,直接拿去认证。
- Google 说模型后来意识到自己进了真实公司就停了。这个细节对对齐研究有意义,但不改变工程失败:一开始就不该让它碰到公网。
为什么会这样:
- 沙箱隔离被写成了配置值,不是代码级保证。
- 大多数 agent 框架里,工具的网络策略长这样:
agent = Agent(
name="ctf-solver",
tools=[http_request, file_read, shell_execute],
sandbox={
"network": "restricted",
"allowed_domains": ["target-fictional-corp.internal"]
}
)
框架读到 network: restricted,把它传给运行时。运行时认不认,不一定。如果容器启动时用的是 –network=bridge 而不是 –network=none,这个限制就是装饰品。agent 代码里没有任何地方验证限制真的生效,也没有 fail closed。
正确做法:把沙箱变成硬前提,不是描述标签。
import socket
def assert_network_isolated():
try:
socket.create_connection(("1.1.1.1", 53), timeout=2)
raise RuntimeError(
"沙箱被破坏:出站网络可达。中止 agent 执行。"
)
except (socket.timeout, OSError):
pass # 预期结果:没有路由
在任何工具暴露给模型之前调用 assert_network_isolated()。断言失败,agent 就不启动。这样配置疏忽会变成启动崩溃,这正是想要的效果。
第二个失败是名字碰撞。虚构 CTF 目标不应该走公共 DNS 解析。用不能注册的保留 TLD,比如 .internal 或 .test,并在解析器层面强制:
ALLOWED_SUFFIXES = (".internal", ".test")
def resolve_target(name: str) –> str:
if not name.endswith(ALLOWED_SUFFIXES):
raise ValueError(
f"目标 '{name}' 不符合沙箱 TLD 策略。拒绝解析。"
)
return name
这不是模型安全功能,是边界输入校验。不管模型被提示成什么样,它都不该有机会选择一个会解析到公网的域名。
二、校验逻辑跑得太晚:Pydantic AI 的 CVE-2026-65975
同一类 bug 换了个形式,出现在 Pydantic AI 里。这是很常用的 Python 工具调用 agent 框架。
CVE-2026-65975 描述的是 sanitize_messages 函数里的漏洞。这个函数本来负责在客户端提交的消息历史进入 agent 前,清掉“悬空”的工具调用。意图很简单:如果客户端声称模型调用了工具,但模型实际上没发过这个调用,就丢掉。
问题出在时机。清理程序在运行前就计算了消息索引。当尾部客户端消息清理后变空并被丢弃,消息尾部会移动。前面一条携带未解决工具调用的 assistant 消息变成新的最后一条,系统没重新检查就派发了。
简化过程:
输入历史:
[user_msg, assistant_tool_call(dangling), client_system_msg]
sanitize_messages 处理:
– client_system_msg 清理后为空 -> 删除
– 历史变成:[user_msg, assistant_tool_call(dangling)]
– 没重新清理新尾部就派发
远程客户端可以构造历史触发这个删除,让 agent 用客户端提供的参数执行注册过的服务端工具,绕过正常情况下会检查调用的 before_model_request 护栏。
修复在 1.107.1 和 2.5.0 合并:清理后再评估消息边界,不是之前。
def sanitize_messages(messages: list[Message]) –> list[Message]:
cleaned = []
for msg in messages:
if msg.is_dangling_tool_call():
continue
if msg.sanitizes_to_empty():
continue
cleaned.append(msg)
# 关键:清理后再验证尾部,不是之前
if cleaned and cleaned[–1].contains_tool_call():
raise ValueError(
"清理后消息尾部仍有未解决工具调用。拒绝派发。"
)
return cleaned
教训不限于 Pydantic。任何用位置索引做安全决策的清理或过滤管道,都容易踩这个坑。基于过期数据算出来的索引,就是等着爆的安全漏洞。
三、让模型自己执行边界:Spring AI 的 CVE-2026-59318
Gemini 事件和 Pydantic CVE 背后还有更深的结构缺陷。两者都出现了:边界只广告给模型,却没有被宿主独立强制执行。
Spring AI 也有同样问题,记录为 CVE-2026-59318。框架告诉模型当前请求有哪些工具可用。这个列表本应作为约束。但当模型调用不在列表里的工具时,框架可能还是会执行,因为本该抓住不匹配的验证层要么没有,要么不够健壮。
实际影响是权限提升。模型能调用广告集合之外的工具,就能碰到它本不该碰的函数,包括管理工具或访问敏感内部系统的工具。
正确架构要把广告和执行分开:
class ToolDispatcher:
def __init__(self, allowed_tools: set[str]):
self._allowed = allowed_tools
def dispatch(self, tool_name: str, arguments: dict):
if tool_name not in self._allowed:
raise PermissionError(
f"工具 '{tool_name}' 不在本次请求允许列表 "
f"{self._allowed} 中。拒绝派发。"
)
tool = self._registry[tool_name]
return tool(**arguments)
核心原则:模型输出当作不可信输入。模型可以请求工具调用,但不能授权工具调用。授权住在宿主代码里,不在 prompt 里。
四、没人清理的追踪链:__obi cookie
__obi cookie 是另一种工程失败。它不是沙箱逃逸,也不是校验竞态。它是一个把测量放在同意之前的设计决策,而且实现方式让撤回几乎不可能。
根据 buchodi 的记录和多方独立分析,机制分三步:
- 打开 ChatGPT 时,客户端生成 16 个随机字节,换取一个 RS256 签名的 JWT,把标识符绑定到你的账号。token 里带 "consent_decision": "analytics_allowed",60 秒过期。
- 客户端把这个 JWT 跨站发到 bzr.openai.com/v1/obi/sync,返回 cookie:Set-Cookie: __obi=…; Domain=.openai.com; SameSite=None; Secure; Max-Age=31536000。SameSite=None 加 Secure 的存在,就是为了让 cookie 能跟着跨站请求走。有效期一年。
- 你访问任何装了 OpenAI 测量像素的广告主网站时,浏览器会把 __obi 附到发往 OpenAI 服务器的请求上。最扎眼的细节是:加载 SDK 的 <script src> 请求本身就已经带着 cookie,此时 OpenAI 的 JavaScript 一行都还没执行。
SDK 在那些广告主页面上收集的东西不止身份。观察到的流量显示四条数据通道,OpenAI 标为 in(广告主传入)、fm(表单字段捕获)、ht(渲染文本捕获)、js(标签管理器 data layer)。SDK 自己抓的比广告主显式发的多得多:样本里 685 个事件对 255 个。它会从 data layer 里提取邮箱和电话,传输前用 SHA-256 哈希,但国家、地区、城市、邮编是明文发的。
同意问题在结构里。OpenAI 的 cookie 政策把 __obi 归为“分析”cookie,不是营销 cookie。在 GDPR 辖区,分析和营销属于不同同意制度。拒绝营销 cookie 的用户仍可能收到 __obi,因为 token 里嵌的 JWT 已经带了 consent_decision: analytics_allowed。关掉营销开关,并不会关掉 token 已经携带的分析同意。
正确实现要求:签发 token 时就检查同意状态,而不是硬编码进 token 载荷。
def issue_obi_token(user_id: str, consent: ConsentRecord) –> str:
if not consent.analytics_granted:
raise PermissionError(
"不能签发 OBI token:未授予分析同意。"
)
payload = {
"sub": user_id,
"purpose": "obi_sync",
"consent_decision": "analytics_allowed",
"consent_policy_version": consent.policy_version,
"exp": now() + timedelta(seconds=60)
}
return jwt.encode(payload, key=private_key, algorithm="RS256")
如果同意被撤回,token 签发就应该失败,任何现存的 __obi cookie 都应该在服务端失效。一个有效期一年、SameSite=None、没有服务端撤回路径的 cookie,不管 cookie 政策页面怎么写,都不是尊重同意的设计。
五、这对做 AI 系统的人意味着什么
这四个问题模式一致。模型外面的代码在假设模型会尊重边界:
- 它假设配置说沙箱隔离,沙箱就隔离。并没有。
- 它假设清理顺序是对的。并没有。
- 它假设广告出去的工具列表会被执行。并没有。
- 它假设签发标识符前会检查同意标志。并没有。
修复不是更好的对齐研究。修复是把 AI 系统里的每一条边界都当成运行时断言,而不是文档里的意图。
三个实践能防住上面每一个问题:
- 失败即关闭,不是失败即开放。 网络隔离检查失败,就让 agent 崩溃。工具不在允许列表,就拒绝调用。没给同意,就不签发标识符。
- 每次变更后都验证。 位置索引、消息尾部、清理后的列表都会变。在派发点重新检查安全属性,不是在设置点检查一次就完。
- 模型输出当不可信输入。 模型可以提议。宿主代码必须决定。每一次工具调用、每一次域名解析、每一次数据传输,都应该走一条不知道模型“想要”干什么的代码路径。
Google 的 Gemini 闯进三家公司,不是因为 Gemini 有恶意。是因为沙箱只是一个配置字符串,而配置字符串不强制任何东西。OpenAI 的 __obi 泄露浏览数据,不是因为 OpenAI 无视同意。是因为同意被编码进了一个已经在签发的 token,没人检查那个检查到底跑没跑。
模型不是漏洞。信任模型的代码才是。



