fix(claude): 加固跨目录恢复的软链边界 - #604
Conversation
|
过程摘要(codex):
|
|
收尾更新:CI build 与 CodeQL(actions / javascript-typescript / python)已全部通过。验证完成后已按多 checkout 规范把全局 shim 与 daemon 切回最新 canonical master( |
deepcoldy
left a comment
There was a problem hiding this comment.
首审结论(Claude):✅ 建议合入,0 阻塞项
我把这个 PR 的两个漏洞在 master 上亲自复现、再用本 PR 的真实编译产物验证修复,而不是采信描述。以下都是实测结果。
白话解释:这个 PR 到底在修什么
Claude Code 把每个会话的 transcript(对话记录 <session-id>.jsonl)按 cwd 的哈希分桶存放。也就是说同一个会话,在 /repoA 里跑和在 /repoB 里跑,记录会落在两个不同的目录里。
botmux 的 /cd 命令刻意保持会话 id 不变,所以换目录后会出现「会话还是那个会话,但 Claude 去新桶里找不到记录」的情况。syncClaudeResumeTargetToCwd 就是干这个的:冷恢复前,把最新的 transcript 从旧桶复制到新 cwd 对应的桶,这样 --resume 才能接上上下文。
问题出在权限边界:
这个复制动作跑在沙盒外的 worker(高权限),而沙盒内的 CLI 有权写自己的
claudeDataDir。
也就是说,桶里的文件对高权限的 worker 来说全是攻击面。老代码用 statSync + copyFileSync,这两个 API 两端都跟随软链:
- source 是软链 → worker 会把
claudeDataDir外面的文件(比如密钥)读进来,写成 transcript(读穿) - target 是软链 → worker 会把 transcript 覆写到
claudeDataDir外面的文件上(写穿,任意文件覆盖)
修法可以理解成给整条链路加了一串闸门:先用 UUID 校验挡住把路径片段塞进 session id;再把所有 statSync 换成 lstat(不跟随软链)+ realpath 包含性检查;打开 source 时用 O_NOFOLLOW 并核对 inode,确保「扫描时看到的那个文件」和「真正读的那个文件」是同一个;最后不直接写 target,而是先在同目录写一个 0600 + O_EXCL 的私有临时文件,校验完再用 rename 原子替换。
rename 这一步是整个方案的承载点:即使 target 在最后一刻被换成软链,rename 替换的是软链本身,不会写穿到它指向的文件。
一、漏洞真实性:两个洞在 master 上均 CONFIRMED 复现
我逐字复刻 master 的实现单独跑:
HOLE1 (source 读穿): copied=true | target 内容 = "TOP-SECRET-HOST-CONTENT\n" => VULNERABLE
HOLE2 (target 写穿): copied=true | dataDir 外文件 = "ATTACKER-TRANSCRIPT-CONTENT\n" => VULNERABLE
这两个洞确实存在,不是理论推演。(背景:它们是我和 codex 复审已关闭的 #506 时挖出来的 master 独立问题,当时结论就是「#506 的 rescue/MOVE 对 canonical /cd 是死代码,但 master sync 自身的软链洞需要单独处理」。本 PR 只移植安全增量、不引入第二套 rescue/MOVE,方向和当时的收敛结论一致 👍)
二、修复有效性:用 PR 的 dist/ 产物验,两洞均 CLOSED
FIX HOLE1: {"copied":false} → target 未创建,secret 未被读出 => CLOSED
FIX HOLE2: THREW "unsafe Claude resume target (expected a regular file)"
→ 外部文件保持 "ORIGINAL-MUST-NOT-CHANGE",target 仍是软链 => CLOSED
承载论证单独验证:renameSync 到一个软链路径 → 外部文件内容不变,该路径变成普通文件。PR 描述里「rename 替换软链本身、不写穿」的说法成立。
三、功能回归:核心 /cd 语义完整保留
- 跨 cwd 复制 75000 字节 逐字节一致,无
.tmp残留,旧桶保留(原生历史不丢) - 端到端
/cd A→B→C→B:上下文正确迁移、newest-wins 选择正确、回切不做多余复制 - 数据根布局全绿:Seed 深层
.claude-runtime、Genius.genius、per-botBOT_HOME/claude、trailing slash、./段 - 符号链接祖先目录(
/home/x → /data00/home/x这类常见部署)仍正常工作 - 0 字节 source、FIFO 作 source(不 hang,被
isFile()挡住)、各 throw 路径无临时文件残留
性能(master 用内核级 copyFileSync,本 PR 换成 64KB 用户态循环,值得测):
| 大小 | 本 PR | master copyFileSync |
|---|---|---|
| 5MB | 4ms | 3ms |
| 50MB | 34ms | 30ms |
| 200MB | 118ms | 786ms |
非但不退化,大文件反而更快。
四、对抗探针
- UUID gate 打 14 个 payload(
../../、../../../etc/passwd、..%2f、null 字节、换行拼接、en-dash 同形字、超短/非 hex/前导斜杠)→ 全部 rejected - 目标桶目录预置为指向外部的软链 → THROW,外部目录零写入(
mkdirSync对已存在软链是 no-op,随后的lstat挡住) projects根软链 /dataDir自身软链 → fail closed(见下 P3)
五、测试基线核对 ⚠️ (PR 写 4 failed,我这边 10 failed —— 已查清不是回归)
我实测 pnpm test 是 10 failed / 10676 passed,和描述的 4 个对不上,所以专门定性了一下:
- 用精确 base commit
e13ec929的干净 worktree 跑同一批文件 → 同样 10 failed - 失败文件(
fs-policy-bwrap1、schedule-card-model1、scheduler2、v3-distillation-runner6)全部不在本 PR 触碰范围,属时区/root-mode 敏感的环境基线 codex-app-threads首轮 full run 失败,但隔离跑 11/11 绿、二次 full run 不复现 → 时间敏感 flake
结论:基线差异来自各人机器环境(时区/权限),非本 PR 回归。定向 4 文件 525/525 通过,与描述一致;pnpm build 通过;CI build + CodeQL 全 pass。
Findings
P3(唯一,非阻塞)— fail-closed 行为变更
dataDir 自身或 projects 根自身是软链时,行为从「静默跟随」变为 throw → 本次同步跳过 → 落回 probe,可能丢上下文起新会话。
但影响面很窄,且我确认是可接受的:
- botmux 全仓只在
core/plugins/install.ts建软链,从不把 Claude 数据根做成软链;本机~/.claude、~/.claude/projects均非软链 - 符号链接祖先目录不受影响(已实测),常见的 symlinked home 部署没问题
- 唯一调用点
src/worker.ts:6671已包在 try/catch 内(6670-6679),只 log WARN 并落回既有 probe/两级 fallback,不会崩 resume - PR 的「影响面」章节已如实写明这一点
非 finding,但记录备查 — hardlink 读穿
攻击者可以把外部文件 hardlink 进桶(lstat 认普通文件、realpath 就是自身,绕过包含性检查),内容会被复制进 transcript。我实测确实可行。但不作为本 PR 的阻塞项:
- master 上同样成立(
copyFileSync照样复制)→ 非本 PR 引入的回归 fs.protected_hardlinks=1(本机已确认开启)要求攻击者对目标本就有读写权,拿不到新东西- 需同一文件系统
可作为后续观察项,不必在本 PR 处理。
关于并发 append(我一度怀疑是 P2,实测降级为非问题)
新代码在 source 于扫描后变化时会 throw。我用真外部进程紧密循环 append → 10/10 全 throw,看着吓人。但按真实 Claude 写入节奏(200ms~1s 一行)重测:busy 5MB / idle 5MB / busy 200KB 各 10/10 全绿,零 throw。
更关键的是:该路径是 effectiveResume && !willReattachPersistent 的冷恢复——老 Claude 进程此时已经死了,source 是静态的。生产上 source 被并发写的前提基本不成立;即便真 throw,也只是 WARN + 落回 probe。→ 非阻塞。
补充事实(供复审参考)
- session id 唯一铸造点
src/services/session-store.ts:173sessionId: randomUUID()→ 全是 UUID - Claude-family 的
cliSessionId只来自resolveJsonlFromPid(claude-code.ts:485,237 行已 UUID 校验)和findOpenClaudeSessionIds(305/311 已校验)→ UUID gate 不会误伤 - 非 UUID 的
cliSessionId来源(Cursor chatId、Kiro、Grok/Traex)都不是 Claude-family,不走此路径 - 影响面仅 Claude-family(claude-code / Seed / Genius,即显式提供
claudeDataDir的适配器),其它 20+ CLI 不经过这里;仅冷恢复触发,持久 pane 重连 / 全新会话 / riff 远端会话不受影响 - merge-base == master tip,无 fork 旧基点陷阱
结论:修的是真问题,方案克制(只移植安全增量、保持 master COPY + 保留旧桶语义),实现严谨,测试覆盖到位。建议合入。
已 @ codex 做复审。在申晗确认前不合码。
✅ 已合入(merge commit
|
背景
syncClaudeResumeTargetToCwd在 Claude-family 冷恢复、且 cwd 已变化时,会从 sibling project 桶挑最新<session-id>.jsonl并复制到当前 cwd 桶。沙盒内 CLI 可以写自己的claudeDataDir,而同步函数运行在沙盒外 worker;原实现使用statSync + copyFileSync,两端都会跟随软链:claudeDataDir外文件读入 transcript(读穿);claudeDataDir外文件(写穿)。这是复审已关闭的 #506 时发现的 master 独立问题;#506 的 cwd 恢复主问题已由 master 现有同步逻辑解决,本 PR 只移植其安全增量,不引入第二套 rescue/move 逻辑。
漏洞链路图
flowchart LR subgraph Sandbox["沙盒内:Claude dataDir 可写"] S["旧 cwd 桶<br/>source 软链"] T["新 cwd 桶<br/>target 软链"] end H1["dataDir 外文件<br/>secret"] H2["dataDir 外文件<br/>config"] W["沙盒外 worker<br/>syncClaudeResumeTargetToCwd"] C["目标 transcript"] H1 -. "软链指向" .-> S S -->|"stat/copy 跟随:读穿"| W W -->|"把外部内容复制进桶"| C W -->|"copy 跟随:写穿"| T T -. "软链指向并覆写" .-> H2修复方案
具体包括:
lstat/类型检查,并做 realpath 包含性验证;O_NOFOLLOW打开 source,结合dev/ino/size/mtime/ctime校验扫描、打开、复制期间的对象一致性;0600 + O_EXCL私有临时 inode,完整校验后rename原子替换 target leaf;即使 target leaf 在末刻被换成软链,rename 也替换软链本身,不会写穿其指向;影响面
claude-code适配器的 transcript 同步 helper;影响所有显式提供claudeDataDir的 Claude-family 适配器(Claude Code、Seed/Genius 等)。其他 CLI 不走此路径。resume && !willReattachPersistent && claudeDataDir的冷恢复路径触发;持久 pane 重连、全新会话、riff 远端会话不受影响。O_NOFOLLOW;Windows 保留兼容分支,并仍有 lstat、realpath、inode 与原子替换校验。daemon 生产路径(Linux)已编译和测试。dataDir或projects根本身做成软链的配置会在冷同步时 fail closed,由后续既有 resume probe/fallback 决定是否恢复。实际验证
pnpm build:通过pnpm exec vitest run test/command-handler.test.ts test/cli-adapters.test.ts test/seed-adapter.test.ts test/claude-code-cwd.test.ts:4 files / 525 tests passedpnpm test:10651 passed / 5 skipped / 4 failed;4 个失败均在本 PR 未触及的 fs-policy root-mode 与 scheduler 时区断言。对精确origin/master@e13ec929的干净 worktree 复跑对应 3 个文件,得到相同 4 failures,确认为基线/环境失败。pnpm daemon:restart:成功,全部进程恢复 online。