本文内容仅供技术学习与交流使用,严禁用于任何非法用途。请遵守《中华人民共和国网络安全法》等相关法律法规,因违规使用产生的一切后果,由使用者自行承担,与作者无关。

引言

以前看沙箱隔离,关注的基本都是容器、网络和文件系统,总觉得只要不同账号的环境互相隔离,数据就很难在它们之间传递。直到最近看到 Check Point Research 披露的 ChatGPT 沙箱跨账号数据泄露问题,才发现事情没这么简单。

两个互不相通的容器,竟然能利用内部 Artifactory 的对象属性交换数据,甚至进一步影响其他会话。原本只是用来缓存依赖的服务,没想到还能成为数据传递的通道。刚好最近对这种比较隐蔽的攻击方式挺感兴趣,就想顺着这个案例深入研究一下,看看问题究竟出在哪里。

一、隔离环境下的跨账号对象状态共享

1.1 问题来源

2026 年 9 月 8 日,Check Point Research 公开了 ChatGPT 代码执行沙箱中的一处跨账号数据泄露问题。研究者 Alexey Bukhteyev 发现,不同账号虽然运行在相互隔离的容器中,却能够通过内部 Artifactory 共享对象属性。

公开验证的过程很简单:账号 A 在缓存制品的 Item Properties 中写入 chatgpt_test_ts 时间戳,账号 B 随后查询相同的 repo + path,读到了 A 写入的值。

这意味着数据并非从 A 的容器直接发给 B,而是先留在双方都能访问的缓存对象上。对象属性跨账号可见,才是这次现象的关键。

相关研究:Check Point Research — The Shared Clipboard Inside the Sandbox

1.2 基于共享服务的间接状态传递

把这个过程拆开看,关键并不是两个容器建立了连接,而是它们各自通过允许的网络出口访问同一个 Artifactory。A 修改缓存对象的属性,B 再查询该对象的属性;整个过程中,软件包正文没有变化。下图展示了这条间接传递路径。

图中虚线表示沙箱 A 与 B 不能直接通信。A 的 PUT 和 B 的 GET 分别到达内部 Artifactory,最终作用于同一对象的 Item Properties。B 读到的 Canary 不是来自 A 的网络连接,而是来自共同服务保存的属性值。

所以,容器之间无法直连并不等于数据一定不能跨账号传递。如果共同服务允许两个身份读写同一份可变状态,它本身就可能成为间接通道。

问题因此落到服务端:两个不能直接通信的运行时,为什么能够定位到同一份可修改的对象属性?

二、网络隔离与对象状态边界

要解释这种现象,先看沙箱允许访问什么。图中的 A、B 仍处于独立的网络边界内,但它们都能访问平台提供的内部制品服务;风险由此转向另一个问题:访问同一个 repoKey + itemPath 时,服务端如何区分不同账号的属性状态?

这张图把网络和对象两层边界放在一起:共享依赖缓存是正常功能,但如果缓存对象还允许通过 PUT properties 改写属性,就需要额外检查这些属性是否对其他账号可见。

2.1 隔离沙箱与共享服务可达性

在网络视图中,账号 A 与账号 B 的代码执行容器不存在直接可达路径,容器也不能任意访问公网。这一层限制的目标是阻止传统的数据外传和横向通信:

Sandbox A  ──X──>  Sandbox B
Sandbox A  ──X──>  Public Internet
Sandbox B  ──X──>  Public Internet

如果安全评估停在这里,系统看起来已经满足隔离要求。但网络策略仍然需要留下业务必需的例外,例如安装 Python 或 npm 依赖。允许出口因此指向内部软件包服务。

两个沙箱分别访问同一个内部 Artifactory:

Sandbox A ───────┐
                 ├──> Internal Artifactory
Sandbox B ───────┘

从网络层看,A 与 B 仍然互相隔离。问题出在两边都可以向 Artifactory 发送请求:如果后者没有按账号隔离可修改的对象属性,这个共同入口就足以承载信息。

多个租户共享公共依赖的二进制内容本身没有问题,缓存本来就是为了复用。真正需要区分的是:对象正文可以共享,不代表对象旁边的可变状态也应该共享。

以同一个缓存对象为例,正常依赖下载读取的是制品正文,而 Storage API 可以读取或修改附着在对象上的 Item Properties。下面的时序图将这两类操作按先后顺序放在一起,观察它们分别作用于哪部分数据。

图中①~③表示依赖下载:包管理器发出 GET package,由内部服务返回制品正文。④~⑥表示属性状态变化:一侧修改 Item Properties,另一侧查询相同对象的属性。这里的 Item Properties 表示逻辑状态,不是沙箱能直接连接的独立网络节点;实际读写仍需通过 Artifactory 提供的接口。

把网络出口、对象路径和权限边界放在同一个视图中,可以进一步看到:网络放行只是请求到达共享服务的前提;真正决定能否修改属性的,是请求所调用的接口及其授权。

这也引出了第二个问题:沙箱本来只需要下载依赖,为什么同一出口还能触达属性管理接口?

2.2 允许出口与 API 权限边界

网络出口通常先按主机和端口放行,具体 HTTP 接口则由 Artifactory 自己处理。对于同一个缓存对象,常见请求可以分成三类:

GET  /artifactory/<repo>/<path>                 下载制品
GET  /artifactory/api/storage/<repo>/<path>     查询对象信息
PUT  /artifactory/api/storage/<repo>/<path>     修改对象属性

白名单表达的是“能否到达”,不是“能调用什么”。如果下载接口和属性管理接口共用同一出口,允许拉包的网络规则本身就无法阻止属性修改。

从图中可以看到,正常下载和属性写入共用 Artifactory 入口,区别在于请求方法、API 路径以及服务端权限检查。网络白名单只能解决“请求能不能到达”,不能代替“请求有没有权利修改属性”。

从请求处理顺序看,网络白名单只决定流量能否到达 Artifactory;认证、对象定位和属性权限检查则发生在服务端:

网络允许
  → HTTP 认证
  → 解析 repoKey + itemPath
  → 定位缓存对象
  → 判断 Read / Annotate
  → 读取或修改 properties

白名单决定请求能否到达,API 授权决定请求能做什么。公开案例中使用的是合法运行时凭据;关键在于这组凭据也通过了属性写入授权。

Item Properties 附着在制品对象上,对象由仓库和路径共同定位:

ObjectID = repoKey + itemPath

公共依赖路径通常由包名和版本决定,例如 pypi-cache/packages/requests/2.32.3/requests-2.32.3.tar.gz。两个容器只要都能访问这条路径,就不需要知道对方 IP,也不需要开放容器端口。

因此,跨账号状态传递至少涉及两个独立条件:双方能定位到同一个对象,以及服务端允许相应的属性读写操作。

2.3 对象状态的租户命名空间

现在再看账号隔离应该落在哪里。理想情况下,即使两个账号访问同一个缓存包,它们看到的可变属性也应具有各自的租户作用域。可以抽象为:

StateKey = tenant_id + repoKey + itemPath

而公开案例中,A 写入后 B 能读回同一属性。仅从可观察行为看,这份属性状态相当于没有对 A、B 形成有效的账号隔离:

StateKey = repoKey + itemPath

这是隔离模型,不是对 Artifactory 后台存储键的断言。公开证据只表明:A 写入的值出现在 B 对同一对象的查询结果中;披露未说明其内部如何组织租户和属性存储。

图中同一个缓存路径同时指向制品内容和伴生属性:正常下载对应正文读取,额外的 PUT properties 对应属性修改。跨账号可见性发生在后者,而不是制品正文被替换。

到这里可以确定,问题不在容器间网络有没有打通,而在于共享对象的状态可见范围,以及运行时是否拿到了不必要的修改权限。下面继续追具体接口。

三、Item Properties 接口与权限边界

对象路径解决了“写到哪里”的问题,接下来需要回答“如何写进去”。Artifactory 的 /api/storage 提供属性读写能力,JFrog 的 Read 与 Annotate 则负责区分读取和修改权限。

3.1 /api/storage 属性读写机制

沙箱通过 Artifactory 安装 Python、npm 依赖,本来只需要读取软件包及必要元数据。但 Item Properties 是另一套能力:它可以为已经存在的制品附加自定义键值,且不必修改包文件。

JFrog 官方文档将 Item Properties 描述为可以附着在文件、目录或仓库上的可定制字符串字段。属性可用于搜索、分类和状态管理;本地仓库和远程仓库的本地缓存支持增加或删除属性。JFrog Properties

写入属性调用的是 Item Management API 中的 PUT 接口:

PUT /artifactory/api/storage/{repoKey}/{itemPath}
    ?properties=key=value&recursive=0

官方文档显示,设置成功返回 204 No Content,缺少相应权限时返回 403。读取则通过同一路径的 Storage Item Information 接口完成:

GET /artifactory/api/storage/{repoKey}/{itemPath}?properties

查询结果可以包含对象的 properties 字段。Set Item Properties、Get Storage Item Information。下面依次对照官方文档中的写入方法、返回状态和读取接口。

从文档中的 PUT 请求可以看到,路径定位已有对象,properties 参数指定要写入的键值;这一操作不会上传新的制品正文。接口能否执行,还要看服务端返回的状态码。

官方响应说明中,成功更新对应 204;认证失败和权限不足则对应不同的失败状态。后面的本地对照将分别观察 204、401 和 403。确定了写入方式后,再看读取属性使用的接口。

Storage Item Information 的 GET 接口可以查询同一缓存对象的信息,而不需要重新下载软件包。继续看这一接口的查询参数,可以区分获取包信息与获取属性状态的请求。

这里的 properties 查询参数用于读取对象属性。这样,读写两侧就有了共同坐标 repoKey + itemPath;两种操作的区别则体现在 HTTP 方法和授权条件上。

PUT 不上传新的包文件,属性直接放在查询参数中,并作用于已经存在的对象。因此测试必须先准备缓存对象,再对同一路径执行更新;目标不存在或没有写权限时,不能得到 204 No Content。

由于公开案例中的原始环境无法直接重放,我按照上述接口语义搭建了本地对照服务,分别测试属性写入成功、修改权限被拒绝和身份认证失败。这里验证的是 HTTP 接口与权限检查的关系,并非原始 ChatGPT 环境的实测流量。

实验先准备一个固定的缓存对象,再从 A 侧发送属性 PUT。第一组报文要看的就是请求路径、properties 参数和返回状态。

可以看到,A 向目标对象写入 probe=should-deny,本地服务返回 204 No Content。这只能说明当前身份的属性更新被接受;若想确认权限边界,还需要做拒绝对照。

接下来保持对象路径不变,单独改变授权条件:先撤销属性修改所需的 Annotate 能力,再使用错误 Token 发起读取请求。这样可以判断请求究竟停在权限检查还是身份认证阶段。

在第一组拒绝报文中,PUT 仍指向原来的对象,但返回 403 Forbidden,说明这次操作被属性修改权限挡住,而不是网络出口不通。

再换成错误凭据,GET 返回 401 Unauthorized。两次失败分别对应权限不足和身份无效,不能混为一种隔离机制。

除了权限,属性还存在长度限制:JFrog 文档给出的属性键上限为 255 个字符,单个属性值上限为 2400 个字符。因此它适合承载短标记,较长内容必须考虑编码及分片;第五章再分析这些约束。

3.2 Read 与 Annotate 权限差异

上面的 401 和 403 之所以不同,是因为 Artifactory 对身份认证与具体操作分别做了检查。对 Item Properties 来说,尤其要区分以下几类权限:

权限

主要作用

Read

下载制品并读取元数据

Annotate

为制品和目录增加或修改属性

Deploy/Cache

部署制品或将上游制品写入远程仓库缓存

Delete/Overwrite

删除或覆盖制品

下载制品需要 Read,但修改 Item Properties 需要 Annotate。JFrog 官方权限页面将两者分开列出,下面的截图对应这一差异。JFrog Permissions


截图中 Read 与 Annotate 是独立的能力。Check Point 披露的情况则是:提供给容器使用的 reader 凭据同时通过了属性读写检查。reader 只是凭据名称,不代表 JFrog 默认会给读取者授予修改权限;风险取决于这套环境实际配置了什么权限。

回看刚才的对照就容易理解:错误 Token 返回 401,只能说明身份认证失败;撤销 Annotate 后 PUT 返回 403,才对应属性修改权限的限制。两者解决的是不同问题。

3.3 数据面与状态面的权限错配

从业务需求看,代码执行沙箱只需要解析依赖:

需要的能力:Resolve / Download Artifact

但实际凭据和允许接口形成了更大的能力集合:

实际能力:
Resolve Artifact
+ Read Item Metadata
+ Annotate Item Properties

两组能力最大的区别是操作对象不同:下载依赖使用的是包内容,属性修改作用于包旁边的可变状态。

  • 包内容属于数据平面,作用是向沙箱提供依赖;
  • Item Properties 属于控制或管理性质的伴生状态,可改变对象的外部描述;
  • 沙箱业务只依赖前者,却同时获得了后者;
  • 后者又作用于跨租户共享的对象标识。

所以即使缓存制品本身可信、内容没有变化,也不能据此排除属性信道。这里被改变的是共享元数据,而不是依赖包文件。

可以把两种请求放在一张图里对照。

沙箱执行 pip install 时,平台希望它只走一条很窄的路径:解析包名、命中缓存、取回 tarball。这个过程最多需要读取制品和少量包元数据,并不需要改变缓存对象旁边的属性。

下面把两条请求路径并排放在一起:左侧是安装依赖需要的 GET 和 Read,右侧是额外开放的 PUT /api/storage 和 Annotate。两条路径共用同一个运行时身份和同一个对象坐标,差别就在于后者可以改动对象旁边的状态。

左侧的 200 OK 是正常下载成功,右侧的 204 与 403 则分别表示属性写入被允许和被拒绝。图中最关键的并不是这些状态码本身,而是正常制品读取与属性写入共享了同一对象路径,却受不同权限控制。

/api/storage 用同一组 repoKey + itemPath定位包旁边的属性表。运行时因此能在不改包正文的情况下改变对象状态。这条请求没有绕过认证,也没有覆盖制品正文;公开案例中的 A、B 仍通过内部服务和合法凭据完成读写。

到这里,Artifactory 这条路径的关键已经比较清楚:同一对象坐标提供了数据交汇点,额外的 Annotate 权限让对象属性可以被修改。接下来换两种常见服务做对照,看看这是不是只会出现在制品仓库中的特殊现象。

四、共享状态原语的跨组件验证

Artifactory 的特殊之处是属性附着在共享缓存对象上。那么如果把 Item Properties 换成普通文件或对象标签,两个互不直连的客户端还能不能交换状态?为此,我分别使用 Jupyter Contents 和 MinIO Object Tagging 做了两组本地对照。

实验让两侧使用同一服务、同一身份及同一对象,只保留网络隔离。这样验证的是共享状态能否传递数据,而不是证明 Jupyter 或 MinIO 存在默认的跨租户越权问题。

4.1 实验拓扑与隔离基线

我把 attacker 和 victim 放到两个 Docker internal 网络中,并让共享服务同时加入两条网络。下面这张是实验拓扑,重点是两个客户端没有彼此可达的网络路径。

从图中可以看到,A、B 虽然连不上彼此,却都可以请求中间的服务。后面的实验均在这个前提下进行,避免将共享状态传递误判成网络打通。

拓扑只保留三个动作:

  1. attacker 无法直接访问 victim;
  2. 两个客户端都能访问共享服务;
  3. 一侧写入的状态可以被另一侧读取。

这样可以把状态变化归因到共享服务,而不是容器网络被意外打通。

4.2 Jupyter Contents 的共享文件状态

Jupyter Server 的 Contents API 使用 path 表示虚拟文件系统中的实体。官方文档说明,默认 FileContentsManager 使用服务器本地文件系统作为存储,API 路径相对于 Notebook Server 的根目录解释;文件模型可以包含文本或 Base64 内容。Jupyter Server Contents API

先分析 Jupyter 的对象是如何定位的。Contents API 通过 path 指向文件,GET 和 PUT 虽然都要经过认证及授权,但它们最终操作的仍是这个路径。下面从入口处理函数追到实际文件存储位置。

Jupyter Server f8169d7 的 handlers.py 117-145 行就是 GET 入口:handlers.py#L117-L145。

在 ContentsHandler.get 中,请求的 path 被交给 contents_manager.get(...),说明读取的具体文件由 Contents Manager 决定。

PUT 入口紧接在同一个文件里,位置是 handlers.py 147-179 行:handlers.py#L147-L179。

ContentsHandler.put 也沿用这个 path,根据文件是否存在分别走保存或上传处理。只要读写双方使用同一实例和根目录,相同的 API 路径就可能对应相同文件。

不过,相同路径不等于一定可以共享。这里还需要确认两个条件:请求是否被允许操作 contents 资源,以及 API 路径最终映射到哪里的文件。

@authorized 最终调用 authorizer.is_authorized(...),Contents Handler 默认使用 contents 资源;FileContentsManager 再用配置的 root_dir 把 API path 映射到文件系统。认证和资源授权都存在,但租户归属不是由 URL 中的 path 自动产生的,需要由 authorizer、根目录或外层部署补上。

授权逻辑看 decorator.py 19-78 行,同样固定在 Jupyter Server f8169d7 这一版:decorator.py#L19-L78。

root_dir 的定义在同一提交的 jupyter_server/services/contents/filemanager.py L72-L93,对应地址:filemanager.py#L72-L93。

从授权装饰器看,访问 contents 资源前确实存在 is_authorized(...) 检查;但 URL 里的文件路径本身不会自动产生独立的租户作用域。

再追到 FileContentsManager,对应 filemanager.py 464-560 行:filemanager.py#L464-L560。

再看 FileContentsManager 的路径解析:API 中的 path 结合服务端配置的 root_dir,定位到实际文件。这样,同一实例下使用相同身份和根目录的两个客户端就会访问同一份文件状态。

源码分析已经说明了读写路径如何交汇,接下来用两个隔离客户端验证:一侧写入任务,另一侧读取,再把结果写回。

对照环境启动一台 Jupyter Server,两个隔离客户端使用同一个实验 Token。攻击者向固定路径写入任务:

PUT /api/contents/mbox_task?token=<lab-token>
Content-Type: application/json

{
  "type": "file",
  "format": "text",
  "content": "{\"op\":\"canary\"}"
}

受害者随后读取同一个路径:

GET /api/contents/mbox_task?token=<lab-token>

本轮报文中,向 mbox_task 发送 PUT 返回 200 OK,另一侧随后通过 GET 读取任务内容。受害侧还向 mbox_result 写入了回执。下面三张抓包分别展示任务写入、任务读取和回执写入;第三张只记录了写回操作,不能单独证明攻击侧已经完成结果读取。

第一张抓包显示,攻击侧向 /api/contents/mbox_task 发送 PUT,服务端返回 200 OK。接下来通过另一侧的读取请求,检查写入的内容是否可见。

第二张抓包对应受害侧的 GET /api/contents/mbox_task,响应中的 content 包含之前写入的测试任务。这里证明的是同一 Jupyter 路径上的任务读取;客户端不能直连的前提由前面的网络拓扑与隔离配置给出。

第三张抓包展示的是向 /api/contents/mbox_result 发送 PUT,请求体为 {"ack":true},服务端返回 200 OK。因此这张图可以确认共享路径收到了回执,但无法直接证明另一客户端已经读取该回执。结合前一张 GET,这组截图至少明确展示了共享任务可读、回执可写两步。

这里需要强调实验前提:双方使用的是同一 Jupyter Server、同一身份、同一根目录。实验展示了共享路径的通信能力,不是绕过 Jupyter 授权;在多用户环境下,独立实例、根目录及外层授权才是需要重点检查的隔离边界。

4.3 MinIO Object Tagging 的共享元数据

Jupyter 证明了共享文件能够传递状态,但它毕竟直接修改了文件内容。第二组我改用 MinIO Object Tagging:标签像 Artifactory 的 Item Properties 一样附着在对象上,不需要修改对象正文。MinIO mc tag 文档

MinIO 的源码从路由开始区分这两类操作:带 ?tagging 的请求进入标签 Handler,普通对象下载走另一条入口。路由来自 MinIO 7aac2a2 的 cmd/api-router.go 343-354 行:api-router.go#L343-L354。

源码中的 ?tagging 是这里的关键:对同一 bucket + object,GET、PUT、DELETE 标签请求由独立的 Handler 处理,不需要走普通对象下载流程。

读标签和写标签还对应不同授权动作,分别是 GetObjectTaggingAction 与 PutObjectTaggingAction。下面继续查看两个 Handler,确认它们如何定位对象并操作标签。

再看 Handler:GET 和 PUT 从同一个 bucket + object 坐标出发,但执行不同的状态操作。

GET 标签 Handler 在 cmd/object-handlers.go 3086-3116 行:object-handlers.go#L3086-L3116。

GET 标签 Handler 使用 bucket + object 定位目标,在权限检查后读取标签。对象正文读取与标签读取并不是同一项操作。

从 PUT Handler 的源码可以看到,权限检查通过后调用 objAPI.PutObjectTags(...) 写入标签。再往对象层追,标签的读取、修改和删除还有各自的接口定义。

PUT 标签 Handler 在同一个文件的 3140-3175 行:object-handlers.go#L3140-L3175。

对象层对应的接口声明位于 cmd/object-api-interface.go 310-317 行,ObjectOptions 中的标签字段位于同文件 L62-L76:object-api-interface.go#L310-L317。从这张源码图能看到,PutObjectTags、GetObjectTags 和 DeleteObjectTags 是独立的对象操作。

源码说明了接口具备标签读写能力,但没有证明任意用户都能访问它。下一步在实验中为两侧配置同一身份和同一对象,看能否完成状态传递。

实验预置一个模拟包缓存对象:

pypi-cache/packages/requests/2.32.3/requests-2.32.3.tar.gz

两个隔离客户端在实验中使用同一组 MinIO 凭据。攻击者设置无害时间戳标签:

mc tag set \
  local/pypi-cache/packages/requests/2.32.3/requests-2.32.3.tar.gz \
  "chatgpt_test_ts=1789105063"

受害者读取同一对象的标签:

mc tag list \
  local/pypi-cache/packages/requests/2.32.3/requests-2.32.3.tar.gz

这组对照中,受害侧读取到了 chatgpt_test_ts=1789105063,对象正文没有发生变化。为了看清标签与对象正文的区别,下面分别展示标签写入和读取的 HTTP 报文。

先看标签写入的报文,PUT ?tagging 发送到指定对象,样例中写入的是 channel_canary 标签:

这张报文返回 200 OK,表示对应的标签写入请求被接受。再看读取报文,GET ?tagging 返回的是同一对象上的 channel_canary 标签:

这两张抓包中的对象路径、标签名 channel_canary 和属性值 1789105063 一致:先由 PUT ?tagging 写入,再由 GET ?tagging 读回,能够对应同一组标签状态。前面的 mc tag set/list 使用的是另一组测试键 chatgpt_test_ts,与这里的 HTTP 抓包应分别理解。

这组结果依赖靶场预先配置的共享凭据和共享对象。真实部署应按租户限制 PutObjectTagging、GetObjectTagging 和对象前缀;实验范围只到标签能够承载状态,不能据此判断 MinIO 默认部署的租户隔离。

4.4 共享状态原语的共同条件

这两组本地对照,加上公开 Artifactory 案例,可以放在一起理解。服务实现虽然不同,观察的都是同一件事:客户端之间没有直连,却能在共同服务中找到双方可访问的状态。

在 Artifactory 中,共同坐标是 repo + itemPath,可变部分是 Item Properties;制品正文和属性状态挂在同一条缓存路径旁边。Jupyter 把 API 中的 path 映射到 root_dir + path,共享实例、Token 和根目录时,两个客户端操作的是同一份文件。MinIO 则用 bucket + objectKey 定位对象,再通过 ?tagging 读写对象标签。三者实现不同,状态都附着在一个双方能够稳定定位的对象上。

从中可以提取出四个共同条件:双方可以访问同一服务、可以定位同一对象、一侧能够写入状态、另一侧能够读取状态。这些条件只说明存在传递数据的可能性,并不等于模型已经执行了某个任务。



图中分别用 repo + path、root_dir + path 和 bucket + objectKey 表示对象位置。虽然 API 不同,信息都是先写入共享服务,再由另一侧读出。只做一次读写是状态传递,想让它反复承载任务和结果,还需要进一步处理寻址、轮询及消息完整性问题。

五、共享状态信道的形成机制

前面的验证基本都是一次写入、一次读取。如果把它作为反复交换状态的通道,还会遇到三个实际问题:消息写在哪里、接收端什么时候读、读到的数据是不是完整且最新。下面从已有接口出发分析这些约束。

5.1 对象寻址与读写闭环

对这条路径来说,对端地址不是关键,固定的对象和属性键才是通信位置。Item Properties 的坐标可以写成:

ChannelAddress = repoKey + itemPath + propertyPrefix

自动缓存路径通常可由包名和版本推导。攻击者与接收端不必交换容器地址;只要各自能访问同一对象,属性键就能承担读写位置。

缓存共享本身不是问题,风险来自可预测对象和可变伴生状态的组合。若属性状态按租户分开,即使制品路径相同,双方也应看到不同视图。

单次写入可以只用一个属性键;如果需要把任务和结果分开,就必须约定不同的字段。例如:

task.*     A → B
result.*   B → A

下图只画最基本的单向过程:发送端写共享对象,接收端查询同一位置。双向回传可以沿相同方式使用另一组键。

关键仍然是共享对象,而不是两侧的 IP 地址。图中读写 Item Properties 的箭头表示逻辑操作;客户端发出的真实 HTTP 请求仍由 Artifactory 接口处理,并非直接连接属性存储。

需要注意,task.*、result.* 只是约定的属性名,Item Properties 并不会自动处理消费确认或消息顺序。

5.2 轮询、容量与分片机制

公开披露没有描述服务端事件通知,因此最保守的接收方式是轮询。若轮询周期为 Δt,并假设消息在两个轮询点之间均匀到达,则平均发现延迟约为:

E[DetectionDelay] ≈ Δt / 2
RequestRate ≈ 1 / Δt

缩短周期会增加请求量,拉长周期则增加发现任务的等待时间。实际发现延迟还会受调度和请求耗时影响;这里关注的是轮询带来的通信方式,不把它写成生产环境的性能指标。

时间戳、消息编号或状态字段可以帮助接收方区分新旧消息。公开材料没有出现 ETag 或 If-Match,这里不把它们列为案例条件。

JFrog 官方文档给出的单属性值上限是 2400 个字符。如果使用标准 Base64,长度近似为:

EncodedLength = 4 × ceil(RawBytes / 3)

忽略 URL 编码和字段开销,一个 2400 字符属性理论上最多承载约 1800 字节的原始二进制数据。单值放不下时,可以拆成多段;下面这张本地对照日志展示了 5000 字节数据经分片读回并重组后的结果。

日志中的重组长度为 5000 字节,说明本地服务在这组参数下能够恢复分片内容;这不是实际 Artifactory 吞吐量或可靠性的测量。

再看写入侧的 HTTP 报文,属性参数包含 blob_n=4 和 blob_0 至 blob_3。返回的 204 No Content 说明本地服务接受了这些属性;与前面的读取端日志结合,才能判断分片已被重新拼接。

一个最小分片结构可以包含:

message_id    标识本次消息
part_count    总分片数
part_0..n     Base64 数据
digest        完整性校验值(可选)
state         writing / ready / consumed

只有等分片全部写入、完整性检查通过后,发送端才应该将 state 标记为 ready。因此,单属性长度限制影响的是传输组织方式,而不是天然禁止多键分片。

5.3 可靠性与状态残留

Item Properties 能承载状态,不代表它适合做消息队列。它没有天然的认领、确认、消费偏移或过期语义;写入成功只说明某个键变了,完整性、去重和清理都要由上层自己补。

最容易出现的是覆盖。两个写入者同时读到旧值,再分别写回新值时,后到的内容会把前一个值盖掉;属性接口没有天然的 compare-and-swap 或版本条件。分片也有类似问题:接收方可能在 part_0 已经写入、part_1 还没有到达时读到半条消息。如果没有 part_count、摘要和最后提交的 READY 标记,读到的内容无法和一次完整写入区分。

消息读完以后还会留下两个状态问题。第一,旧属性如果没有删除或覆盖,下一次轮询可能把上一条任务当成新任务;第二,接收方即使写入 CONSUMED,两个消费者也可能在状态改变前同时读到 READY,从而重复处理。message_id、序号和消费标记可以帮助识别这些情况,但它们只是应用层约定,不能提供队列的原子认领。

如果在授权场景中确实需要这类状态交换,至少应按“先写分片—再写摘要和编号—最后标记 READY”组织数据;接收方核验后再标记 CONSUMED 并清理旧键。这只能降低半写入和重复读取问题,不能弥补跨账号属性授权缺陷。

5.4 跨租户状态信道成立条件

把租户 A、租户 B、共享服务 S 和共享对象 O 记为四个变量。一条可用的跨租户状态信道至少依赖以下条件:

Reach(A, S) ∧ Reach(B, S)
∧ StableKey(A, O) ∧ StableKey(B, O)
∧ Mutate(A, O)
∧ Observe(B, O)

映射到公开案例,repo + path 是可观察到的共同坐标,Annotate 对应属性修改,Storage Item Information 对应属性读取。跨账号读回相同值证明属性视图没有按账号有效隔离;公开资料没有披露其内部存储键或具体实现。

只要这些条件成立,状态值就可能跨账号可见;轮询、缓存一致性和对象生命周期则影响它是否稳定。至于这段值会不会被当成指令执行,不属于状态接口本身的能力。下面进一步看公开研究中,这条通道为何能与连接器调用产生关联。

六、共享状态向连接器能力的扩展

共享属性最多负责把字符串传到另一侧,不能自己读取邮件。Check Point 公开案例之所以进一步出现 Gmail 数据读取,是因为受害会话中的执行流程把共享状态当成了任务来源,并使用了该会话已经连接的工具。

这里把两部分分开说明:真实 Gmail 相关行为引用公开研究;我在本地只模拟“读取任务—调用假连接器—写回结果”,检验状态传递与工具执行之间如何衔接。

6.1 任务投递与结果回写

按照公开案例,可以将链路拆成“投递任务—读取共享属性—决定调用连接器—读取数据—回写结果”。下面用时序图标出各个参与者和数据流向。


图中 Item Properties 和 Artifactory 代表同一内部服务中的对象属性操作,箭头省略了实际 HTTP 转发环节;Gmail 访问则由受害会话已有的连接器完成。跨账号属性可见性和连接器访问是两套不同的权限关系,不能把图中的逻辑连线当作绕过服务端的直连路径。

链路中存在两条独立的授权关系:

  1. 容器凭据允许访问 Artifactory 属性;
  2. 受害会话允许模型访问已连接应用。

风险恰恰出在两套权限被执行流程串联起来:攻击者不一定需要取得 Gmail Token,只要外来任务被受害会话采纳,就可能借用该会话已有的合法能力。这里还必须满足模型执行和平台授权等额外条件。

6.2 本地执行器的任务处理链复现

公开研究中,用户看到的正常回答与后台的连接器操作可能出现在同一轮交互中,可以将其区分为两条流程:

可见流:用户普通问题 → 正常回答

隐藏流:检查共享邮箱 → 执行攻击者任务 → 回写结果

恶意指令可以通过粘贴内容、共享对话或自定义 GPT 配置进入上下文。用户发送普通消息后,隐藏流检查共享状态;公开 PoC 中,界面只在动作完成后显示 “Talked to Gmail”。Check Point Research

为了单独验证后台任务处理流程,本地 sandbox_agent.py 使用 --hidden 明确开启实验分支,依次读取 list_mail、调用本地假连接器、回写结果。该参数只是我在靶场中的控制方式,不能等同于真实模型自动选择了这条路径。

实验使用 mbox_task_* 存任务、mbox_result_* 存结果,假连接器的数据源是 lab/mail.json。先看终端操作是否形成闭环,再对照 HTTP 报文确认每次共享属性读写发生在什么位置。

步骤1:攻击者投递测试任务。 攻击者容器发布 list_mail,内容只有操作名和 limit,不包含邮件数据。


步骤2:确认“读到任务”不等于“执行任务”。 受害者执行 inspect-task 后确实读到了共享属性,但日志同时显示 NO CONNECTOR CALL、NO RESULT WRITE。此时仅完成了数据读取,尚未进入工具执行。

步骤3:验证执行分支。 使用 victim-turn --hidden 显式处理同一条 list_mail 任务,终端出现 Talked to Mail 并返回合成邮件。与上一步相比,多出来的是实验执行器实际调用了假连接器;这不代表真实模型会自动执行。

步骤4:收取结果。 攻击者执行 collect,读取到 ok=true、op=list_mail 及合成邮件条目,说明执行器已把结果写回共享对象。

这一步验证的是回传路径:攻击者没有直连受害容器,仍能通过属性读取获得实验结果。

步骤5:定位一次失败情形。 将 MAIL_FILE 指向不存在的路径后,假连接器返回 FileNotFoundError。这张日志用来区分失败发生在哪里:任务已经传递到执行器,只是在读取本地数据源时失败。

因此,不能把这次异常当成共享状态通道被拦截的证据。

终端日志展示了任务处理流程,接下来用三张 HTTP 抓包核对共享属性:首先观察任务属性能否通过 GET 读到,再看结果如何写入、如何被另一侧查询。攻击者投递任务的操作已在前面的终端日志中展示;这里的抓包主要补充服务端读写结果。

这张 GET 响应包含 mbox_task_*,同时已经存在 mbox_result_*。它证明共享对象上能够查询到任务与结果属性,但并非一次“结果尚未写入”的初始状态截图。执行器是否调用假连接器,仍要依据前面的终端运行记录。

再看结果写回:第二张报文显示本地执行器将合成邮件结果写入 mbox_result_*。数据来自靶场中的 lab/mail.json,没有调用真实 Gmail API。

最后一张是攻击者读取 mbox_result_*。这一步需要对照前一张写回的属性值,确认结果经共享对象返回,而非容器间直连。

这三张报文分别覆盖任务与结果属性的查询、结果写入和写入后读回;任务投递、模拟执行还需要与前面的终端日志一起对照,才能说明本地流程的完整性。单看 HTTP 响应,不能证明真实模型或 Gmail 连接器被调用。

公开 PoC 选择 Gmail,是因为受害会话本来就已经连接了该应用。这里没有绕过 OAuth,也没有直接攻击 Gmail API;真正被借用的是受害会话已经合法拥有的能力。

链路可以写成:

跨租户状态信道提供任务来源
        +
受害会话提供合法连接器身份
        +
模型根据上下文决定执行任务
        =
攻击者间接借用受害会话能力

这接近“混淆代理”:执行主体的权限是真的,但任务来源可能不是会话用户。是否构成完整的 Confused Deputy,还取决于模型行为、工具策略和确认机制。

6.3 只读连接器的影响边界

“只读”并不等于没有安全影响。只要任务来源能被另一个租户间接控制,读取邮件、文档、仓库内容或聊天记录本身就可能构成数据泄露。

公开披露称,Gmail 低风险读取在当时的默认设置下可以不经单独确认完成,“Talked to Gmail” 是动作完成后的界面痕迹。实际影响范围由三件事决定:受害会话能看到哪些数据,已经连接了哪些应用,以及平台在调用前是否要求用户确认。

Gmail 只是公开 PoC 的落点。Drive、Teams、GitHub 等只有在受害会话已连接、权限允许且任务通过平台策略时才可能受到影响,不能写成已逐项验证的事实。

不过,从“能读取共享状态”到“能访问连接器数据”,还必须依次满足:

  1. 一段能够让受害会话检查共享状态的指令已经进入上下文;
  2. 受害者发起一次能够触发模型运行的交互;
  3. 模型和平台允许调用对应工具;
  4. 受害会话拥有目标数据的合法访问权限;
  5. 结果能够重新写回共享状态。

任何一个条件不成立,链路都可能停在字符串传输阶段。仅靠 Prompt 约束不够,基础设施还应阻断未经授权的跨账号状态读写和结果回传。

七、Tagging 受限后的验证实验

前面的 MinIO 对照依赖 Object Tagging。如果已经撤销 PutObjectTagging、GetObjectTagging,是不是就可以认为对象上没有其他可被利用的状态了?我继续检查了 S3 兼容接口中的 Multipart 上传机制,因为未完成上传同样会在服务端留下可查询的记录。

这一组改用两套实验凭据,并限制 Tagging 接口,但仍按测试需要授予 Multipart 操作权限。需要说明的是,两套凭据并不等于两个真实租户。本轮只验证另一套已获授权的凭据能否读取上传分片元数据,不将其表述为绕过 MinIO 权限检查。

7.1 确认 Tagging 拒绝,记录正式对象的基线

步骤1:用不同凭据验证标签权限。

A 使用 pypi-attacker,B 使用 pypi-victim,两者访问同一组 bucket + objectKey。A 请求 PUT ?tagging 写入测试值 probe=should-deny,B 请求 GET ?tagging 读取标签。

先看 A 写入标签的结果,以及 B 读取标签的结果,两次请求分别返回 403 AccessDenied。这一步先确认 Tagging 确实受到限制。

第一张报文中,A 的 PUT ?tagging 被拒绝,没有获得标签修改权限。

第二张报文中,B 的 GET ?tagging 同样被拒绝。由此只能得出标签操作受限,不能推导所有对象元数据都不可访问。

步骤2:记录正式对象的 ETag。

为了判断后面的操作有没有改动正式对象,B 先对相同对象执行 HEAD,记录返回的 ETag。下图就是上传分片前的对象基线;等 Multipart 实验结束,再拿它做一次对比。

7.2 创建 Multipart 上传,检查 ListParts 权限

步骤3:A 创建一条未完成的上传记录。

A 对同一个对象键发起 POST ?uploads,创建一条尚未完成的 Multipart 上传记录。下面的响应返回了 UploadId;后续写分片、列举分片都需要它。因此 Multipart 比普通 Tagging 多了一个前提:只知道对象路径,还不能确定具体的 UploadId。

步骤4:核对分片读取所需的权限。

创建上传只说明 A 可以写入上传状态。要让 B 读取分片,还得看 ListParts 的授权。MinIO 的 ListObjectPartsHandler 在返回分片列表前会检查 ListMultipartUploadPartsAction;这项权限与标签权限彼此独立。

源码参考:MinIO object-multipart-handlers.go — ListObjectPartsHandler。

本次测试中,A 将服务端返回的 UploadId 直接交给 B。B 后续使用自己的凭据与已知 UploadId 查询分片,所以本轮验证的是已知上传编号、且策略允许读取时的可见性,不是 B 能独立发现上传编号。这里的编号由测试人员提供,不能将其省略为一般情况下无需前置条件的跨账号读取。

7.3 利用分片长度承载测试字符串

步骤5:A 写入九个未完成分片。

这次用无害字符串 list_mail 做测试,不把指令写进对象标签或正式文件,而是把每个字符的 ASCII 数值对应到一个分片的大小。上传的分片内容用 X 填充,接收端实际读取的是 ListParts 返回的 Size。

list_mail → 108, 105, 115, 116, 95, 109, 97, 105, 108

以第一个字符 l 为例,ASCII 值为 108,所以第一片的正文长度设为 108 字节,partNumber=1。下图展示这个 UploadPart 返回 200 OK;其余八片按相同规则写入。

步骤6:B 读取分片元数据并还原字符串。

接下来 B 使用独立凭据访问同一对象,并携带 A 提供的 UploadId 调用 ListParts。下图 200 OK 的响应显示了 ListParts 返回的分片元数据,其中能够直接看到前四项 PartNumber 和 Size,对应 108、105、115、116。本轮实验按同一规则处理九个分片长度,依次转换成 ASCII 后得到 list_mail。

这里的关键不是 ListParts 被绕过,而是它在当前策略下明确允许 B 读取这条上传记录。先前的 403 仅表示 Tagging 被拒绝,两套权限没有自动联动。

还要注意 S3 Multipart 的大小限制:常规完整上传要求非最后一片至少 5 MiB。这里的九片只有约百字节,本次仅验证未完成上传的 ListParts 状态,不应称为可以正常 CompleteMultipartUpload 的文件上传流程,也不能据此推导实际生产信道吞吐量。

7.4 对照正式对象,划清实验结论

步骤7:重复 HEAD,检查正式制品是否改变。

实验全程没有执行 CompleteMultipartUpload。现在 B 再向正式对象发起 HEAD,用 ETag 与步骤2的基线对比。下面这张响应显示 ETag 没有变化,说明本轮未观察到正式制品被覆盖。

这里的 HEAD 不携带 UploadId,检查的是正式对象;分片上传记录是否存在,需要单独根据 ListParts 判断。下面把对象基线、分片状态和标签权限放在一起对照。

再把 Tagging、Multipart 和对象本身三组检查放在一起,可以更清楚地区分每一步证明了什么:

检查项目

本轮记录

能说明什么

A PUT ?tagging

403 AccessDenied

A 不能按当前策略修改对象标签

B GET ?tagging

403 AccessDenied

B 不能按当前策略读取对象标签

A CreateMultipartUpload

返回 UploadId

A 可创建未完成上传

A UploadPart

九个分片写入成功

Size 可承载测试字符

B ListParts

200,还原 list_mail

已知 UploadId 且拥有权限时,B 可读到上传元数据

B HEAD

ETag 与基线一致

本轮未观察到正式对象被替换

这组实验说明,禁止 Tagging 并不等于关闭所有与对象相关的可变状态。未完成 Multipart 上传另有 UploadId 和分片枚举权限;在本次特定授权下,B 能通过已知 UploadId 读取 A 写入的分片长度,而正式对象没有变化。

但本轮没有验证 B 能自行发现 UploadId,也没有验证真实租户之间的越权、禁止 ListParts 后仍可读取分片,或真实 Agent 会自动执行还原出的字符串。这些行为不能从一个 200 OK 推导出来。

从防护角度看,应检查的不只是 PutObjectTagging、GetObjectTagging,还有 PutObject、ListMultipartUploadParts、ListBucketMultipartUploads 等对象上传生命周期相关权限。对于只负责下载缓存的运行时,最小权限应当允许正常读取,但不允许创建和枚举不属于自己的上传状态。

八、防护设计

从 Artifactory 的 Item Properties,到 MinIO 的 Tagging,再到未完成的 Multipart 上传记录,问题都指向同一件事:服务可能同时提供对象正文和多种可变状态。防护不能只封禁某一个接口,而应分别检查操作权限和状态归属。

8.1 收紧接口权限

对只负责安装依赖的沙箱,正常业务需要的是解析包、读取缓存、下载制品。Artifactory 的 Read 与 Annotate 应分开授权;第三章的本地对照显示,关闭 Annotate 后,属性 PUT 返回 403。这支持最小权限的设计方向,但本地 Mock 不能代替真实 Artifactory 的配置验证。

MinIO/S3 侧也应单独审查 Tagging 与 Multipart 权限。仅需要下载对象的身份,不应因为能够 GET、HEAD,就默认具备创建 Multipart 上传、UploadPart、ListParts、列举未完成上传或完成上传的权限。

对于真正需要上传的业务,不能简单一律封禁 Multipart;更合适的办法是按主体、桶、前缀、上传归属和操作范围授予最小权限。

8.2 校验状态归属

可以用以下记号表达隔离目标:

ArtifactKey    = repoKey + itemPath
StateKey       = tenant_id + repoKey + itemPath + state_namespace
TaskStateKey   = tenant_id + session_id + task_id
UploadOwner    = UploadId -> tenant_id + session_id + bucket + objectKey

这些是建议的授权模型,不是对 Artifactory、MinIO 真实内部存储键的描述。对象正文为了缓存效率可以共享;属性、标签、未完成上传及任务结果,则应按业务要求建立独立的授权与归属边界。

尤其是 UploadId:它负责定位一条上传记录,但知道这个编号不应自动获得跨用户读取能力。服务端还需根据经过认证的主体核验查询分片的权限和归属。客户端自报的 tenant_id、session_id 或任务内容,不能替代身份认证。

8.3 限制连接器调用

第六章的本地实验展示了共享状态到假连接器的执行流程,但 victim-turn --hidden 是实验程序显式打开的分支,不是模型被攻击者诱导后自行决定执行的证据。因此,执行器不能仅凭“读到了任务”就调用连接器。

实际 Agent 应从当前受信会话或服务端授权上下文确认任务来源,再判断是否允许对应工具操作。即使共享对象属性、标签或上传元数据被错误暴露,也不应仅凭其中一段字符串借用用户已有的邮件、文件或仓库访问权限。

九、结语

这次研究下来,发现自己之前对沙箱隔离的理解还是有些片面。以前主要关注容器之间能不能通信、网络出口有没有限制,却很少去考虑那些允许访问的内部服务会不会成为另一条数据传递的路径。

后面做 MinIO 实验时也挺意外,明明已经禁止了 Tagging,Multipart 上传记录却依然可以用来传递信息。当然,这次实验有特定的权限配置,不能直接认为 MinIO 存在漏洞。

以后再碰到类似的场景,还是得多看看这些不起眼的接口和权限配置。有时候问题不在于防护没做好,而是一些原本正常的功能组合在一起,产生了意想不到的效果。如有理解不准确的地方,也欢迎师傅们交流指正。

参考资料

  1. Check Point Research, The Shared Clipboard Inside the Sandbox: Cross-Account Data Leakage in ChatGPT:https://research.checkpoint.com/2026/the-shared-clipboard-inside-the-sandbox-cross-account-data-leakage-in-chatgpt/
  2. JFrog, Set Item Properties:https://docs.jfrog.com/artifactory/reference/setitemproperties
  3. JFrog, Get Storage Item Information:https://docs.jfrog.com/artifactory/reference/getstorageitem
  4. JFrog, JFrog Properties:https://docs.jfrog.com/artifactory/docs/jfrog-properties
  5. JFrog, Permissions:https://docs.jfrog.com/administration/docs/permissions
  6. Jupyter Server, Contents API:https://jupyter-server.readthedocs.io/en/latest/developers/contents.html
  7. MinIO, mc tag:https://docs.min.io/aistor/reference/cli/mc-tag/
  8. Jupyter Server, services/contents/handlers.py:https://github.com/jupyter-server/jupyter_server/blob/f8169d721a0e0abb9ca27266c83db5a3a09e29d7/jupyter_server/services/contents/handlers.py
  9. Jupyter Server, auth/decorator.py:https://github.com/jupyter-server/jupyter_server/blob/f8169d721a0e0abb9ca27266c83db5a3a09e29d7/jupyter_server/auth/decorator.py
  10. Jupyter Server, services/contents/filemanager.py:https://github.com/jupyter-server/jupyter_server/blob/f8169d721a0e0abb9ca27266c83db5a3a09e29d7/jupyter_server/services/contents/filemanager.py
  11. MinIO, cmd/api-router.go:https://github.com/minio/minio/blob/7aac2a2c5b7c882e68c1ce017d8256be2feea27f/cmd/api-router.go
  12. MinIO, cmd/object-handlers.go:https://github.com/minio/minio/blob/7aac2a2c5b7c882e68c1ce017d8256be2feea27f/cmd/object-handlers.go
  13. MinIO, cmd/object-api-interface.go:https://github.com/minio/minio/blob/7aac2a2c5b7c882e68c1ce017d8256be2feea27f/cmd/object-api-interface.go
  14. AWS, ListParts API:https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListParts.html
  15. AWS, Multipart Upload Permissions:https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
  16. AWS, Multipart Upload Limits:https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html