Replies: 6 comments
|
按你给的 commit( 一、竞态窗口不是一次 syscall,是四次路径名解析。
const directory = dirname(absolutePath)
await mkdir(directory, { recursive: true }) // 解析 directory
const stagingDir = join(directory, stagingDirName)
await mkdir(stagingDir, { mode: 0o700 }) // 再次解析
handle = await open(tempPath, 'wx', 0o600) // 再次解析
await rename(tempPath, absolutePath) // 再次解析
附带效果: 二、这个 TOCTOU 在代码里是被显式接受的,而你的 PoC 推翻了接受它的前提。
也就是说这不是疏漏,是一个基于"只有路径字符串不可信、不可信代码由 你的 PoC 的分量正在这里:默认 composition 同时给了模型 三、一条我验证过但不成立的猜想,先排掉免得误导。 我一度以为收尾的 |
|
补充一个同一 这和正文的 post-check symlink race 应当放在同一个报告里理解:最终违反的仍然是“ 1. 最小 hardlink control先在 sandbox 建立前准备: 确认两条路径的 inode 相同。 在当前 Linux Landlock 直接写 outside path: 但写 workspace path: 同样的测试在当前 bwrap profile 下也成立: 直接写 outside path 返回 因此这里不是 runner 根本没有生效。两个 negative control 都正确拒绝 outside pathname;问题是 writable workspace pathname 和 readonly/outside pathname 指向同一个 inode。 2. pnpm 默认 workspace 可以自然形成这个前提我又用两个完全独立的 throwaway Node projects A / B,共享一个新的 pnpm 11.7.0 store,分别正常安装同一个 dependency,没有手工创建 hardlink,也没有使用自定义 在本次 ext4 环境中,安装后: 三者是同一个 也就是说,这不是只能靠一个人为构造的 3. Cross-project consequence接着把 A 作为唯一 writable workspace,B 作为 workspace 外另一个项目。 Landlock 和 bwrap 下都先做 negative control: 然后 sandbox 只修改: 写入一个 harmless JS marker payload。 结果: 随后在 sandbox 外按正常方式从 project B 执行: harmless marker 被创建,证明 A 的 4. 真实 model-facing
|
|
再补一个同一 这个点和 #190 的关系是:#190 讨论的是“bootstrap-only 1. 启动层会保护
|
|
补充上一条 project 这条不需要模型自己调用 Web control-plane RPC;正常 UI 的文件路径点击就会走 1. 项目
|
|
原帖的 post-check pathname race 已在本地完成修复并验证:在 POSIX(Linux/macOS)上,把 补丁(1 个提交,位于 fork 分支): 做法要点(与 Zenine 的"四次路径名解析"判断一致,不再依赖"检查后不再重新解析"的假设):
验证:
关于后续补充的三条向量——均不在本补丁范围内,建议分别跟踪:
(说明:上游仓库当前不接受外部 PR,补丁先落在 fork 分支;修复本身已在本地完整验证,随时可合入。) 补充说明:本补丁由 DeepSeek Harness(dsh)自身的 agent 环境完成——包括对本仓库的分析、漏洞确认与复现、修复方案设计与实现、以及测试验证,属于"用 dsh 修复 dsh"的 dogfooding 产出;经 seainml 复核后发布。 |
|
把 dirfd 锚定的作用域和这条报告目前的作用域对一下,免得被当成已经收口。 锚定关掉的是原帖那条 post-check pathname race,而那条恰恰是四条里唯一需要赢竞态的。后来追加的三条都不经过"路径名被重新解析"这个环节,因此锚定对它们不生效:
如果按"
三条都不需要并发,也不需要构造竞态窗口。 |
Uh oh!
There was an error while loading. Please reload this page.
先说结论:当前
workspace-write模式下,一个本身仍受 sandbox 限制的模型侧后台进程,可以通过 workspace 内的 symlink/rename race,诱导宿主进程中的ctx.fs在 workspace 之外覆盖文件。我在当前
master(47f943859bef60e4160492346772ded9b24f765a)上分别做了 provider-level 和完整 model-facing tool-level 复现。完整工具链测试中,参与 race 的进程始终运行在当前workspace-writeLandlock profile 下,它自己直接写 workspace 外文件会被拒绝;但与正常tool-fs写操作并发后,3/3 试验均成功覆盖了 workspace 外预先存在的 harmless victim 文件。因此这里产生的效果不是“sandbox child 自己突破 Landlock”,而是一个 confused-deputy 路径:
可能影响包括:
workspace-write本应对当前 Agent 不可写的文件;最后一点是后续影响判断;下面的 PoC 只证明 workspace 外文件覆盖,不把 RCE 当作已经动态复现的事实。测试只使用独立临时目录中的 victim 文件,没有修改真实用户配置、启动文件或业务数据。
1. 默认组合允许 Agent 同时使用受限 background bash 和
tool-fs当前基础 composition 默认把 sandbox policy 设为:
同时加载了 sandboxed bash/jobs 和 fs tool/fence:
对应配置在:
tool-bash的模型接口支持run_in_background,因此模型可以让一个仍受 sandbox policy 限制的进程持续运行,同时继续发起后续tool-fs调用:所以这个 race 不要求系统上额外存在另一个未受限制的恶意 host process。参与 race 的进程本身就是当前产品提供给 Agent 的正常受限执行能力。
攻击链可以概括为:
2.
fs-sandbox会重新 canonicalize,但授权结果最终仍然只是 pathname当前:
中,
writeText()会先通过checkedTarget():workspace-write下,checkedTarget()会重新 resolve 当前 display path,然后检查 fresh target 是否位于 writable roots:这个设计确实能修复一类 stale-target 问题:如果 ancestor symlink 在较早的 resolve 之后已经变化,写之前重新 resolve 可以发现 fresh path 已经落到 workspace 外。
但这里返回的
fresh.targetKey最终仍然是一个路径字符串,不是绑定到刚才通过 containment 检查的 filesystem object 的 fd / dirfd / handle。因此它保证的是:
而不是:
只要 pathname 的某个目录组件在检查之后、真实文件操作之前发生变化,后续 pathname lookup 仍然可以解析到另一棵目录树。
3. containment check 之后,
LocalFileSystem会再次按 pathname 做真实文件操作写入随后进入:
当前 atomic write 的关键步骤包括:
这里的 atomic write 可以保证 publication/replacement 的文件内容语义,但它没有把前面 containment check 所看到的目录 identity 固定下来。
因此会存在下面的时序:
所以这里不是绕过了
checkedTarget()的 canonicalization;问题发生在 canonicalization + containment 已经成功之后。4. 参与 race 的受限进程自己不能写 outside
PoC 中的 racer 只做一件事情:在 workspace 内使用 Linux
renameat2(..., RENAME_EXCHANGE)原子交换两个目录项。核心逻辑:
其中:
为了确认这些前提本身就是
workspace-writechild 能做到的,我另外做了 control:让当前 sandbox launcher 启动受限 shell,由它自己在 workspace 内创建外向 symlink 和交换目录。结果:
但是让同一个受限 child直接修改 outside victim:
因此攻击前提是:
真正完成越界写入的是之后被 race 误导的宿主
ctx.fs。5. Negative control:静态外向 symlink 会被正确拒绝
为了区分“普通 symlink containment 根本没有生效”和“post-check race”,我先做了静态 symlink negative control:
通过正常 model-facing
read读取 target 后,再调用:结果:
符合预期。
当 symlink 在
checkedTarget()执行时已经指向 outside,fresh canonical path 会落在 workspace 外,因此写入被拒绝。这说明复现依赖的是检查完成以后的 pathname identity 变化,而不是一个简单的“静态 symlink 未 canonicalize”问题。
6. Negative control:sandbox child 直接写 outside 也会失败
我还使用仓库当前 native sandbox launcher 做了 direct-write control。
受限 child 直接写 workspace 外文件:
写 workspace 内文件:
因此 positive result 不能解释为“sandbox 本身根本没有限制 outside file write”。
7. Provider-level 动态复现:5/5 成功写出 workspace
首先直接针对当前
SandboxedFileSystem做 provider-level race test。受限 racer 持续交换 workspace 内的
gate/alt,宿主侧重复对:发起正常 fs write。
五次独立试验结果:
最终:
每次成功后,workspace 外的 harmless victim 都被宿主 fs provider 写成了新的
ESCAPED-*内容。8. 完整 model-facing 复现:真实
tool-fs路径 3/3 成功为了确认这不是只有直接调用 provider API 才能出现的问题,我又使用真实工具调用链测试:
这里使用一个预先存在的 outside victim 文件,并先通过 model-facing
read观察它,以满足默认 observation policy 的 read-before-overwrite 要求。然后启动受限 racer,通过真实
ctx.tools.execute()重复调用write。三次独立试验结果:
最终:
所以当前完整 model-facing path 的结果是:
这一步证明漏洞能够穿过当前实际的
tool-fs + observation policy + workspace-write fs sandbox,而不只是内部 Service API 的理论 race。9. 为什么默认 observation policy 不能阻止它
当前 policy 允许读,而覆盖既有文件前需要先观察目标。
PoC 的顺序是:
实际测试中确实观察到大量失败尝试,包括:
这些机制会降低单次 race 的命中概率,但不会把 containment check 和最终 filesystem identity 绑定起来。
持续 race 后,3 个 model-facing trial 最终全部成功。
10. 这与“额外 adversarial host process 抢 realpath race”有区别
当前 fs sandbox 的设计本身将 in-process fs fence 与 kernel sandbox 分开:受限代码由 sandboxed shell 隔离,
ctx.fs则在可信宿主进程中根据 model-controlled path 做 policy fence。如果利用 residual resolve-to-syscall race 必须依赖系统上另一个已经拥有正常宿主权限的恶意进程去修改目录树,那么它更接近普通 adversarial-host-process TOCTOU。
但这个复现不需要这样的前提。
参与 race 的进程是:
它只修改自己本来就允许修改的 workspace namespace。
最后 outside write 是由更高权限的可信宿主
ctx.fs完成的:因此这里形成的是一个可由同一受限 Agent 主动触发的 confused-deputy boundary bypass,而不是要求另一个未受限 host attacker 配合。
11. 这个 race 位于之前 TOCTOU 修复之后的窗口
历史提交
2530bf8aa已经专门调整过fs-sandbox的 TOCTOU 方向。修复前大致是:
修复后变成:
这个修改能关闭 stale-target 问题。
但当前 race 发生在更晚的位置:
也就是说:
仍然不等于:
所以这里剩下的是一个 post-check pathname race。
12. 已验证的直接影响
当前代码已经动态证明的 primitive 是:
因此
workspace-write的效果会从:退化成:
可能涉及其他 checkout、应用配置、工具状态、脚本以及其他本来不属于当前 Agent workspace 的文件。
如果某个可写目标后续会进入 trusted interpretation / execution path,则该 overwrite primitive 还可能继续形成代码执行或持久化链;本文没有用真实敏感文件验证这一点,以避免破坏性测试。
13. 修复建议
我认为核心修复点是:不要让 containment authorization 和最终 mutation 只通过一个之后还能被重新解析的 pathname 关联。
仅在最终
rename()前再做一次realpath()只能继续缩小 race window,不能从原理上消除它。Linux 上可以考虑把 mutation 绑定到已经验证的目录 identity,例如使用 dirfd-relative primitive,并结合适合的 beneath / symlink-resolution 限制,让 staging-file creation 和最终 publication 都相对于已经授权的 directory identity 完成。
核心性质应当类似:
而不是:
另外建议加入一个真正并发的 regression test:
只构造静态 stale
targetKey的 testcase 无法覆盖这个 post-check window。总结
当前
fs-sandbox会在 mutation 前重新 canonicalize target 并检查workspace-writecontainment,但检查完成以后仍然把普通 pathname 交给宿主LocalFileSystem,后者在真正的 filesystem operation 时重新解析该 pathname。当前提交上的动态证据为:
同一个 Agent 可以通过正常 background bash/job 能力运行仍受
workspace-write约束的 racer。这个 racer 自己不能修改 outside,但可以仅通过 workspace 内目录项交换,让 trusted hostctx.fs在 containment check 后解析到另一个 filesystem location,最终产生workspace-write本应禁止的 workspace 外 mutation。因此建议将这里作为一个独立的 post-check pathname identity race / confused-deputy boundary bypass 处理。
All reactions