一行 startsWith 让 agent 读到沙箱外的文件,而利用前提明写「要能往上下文窗口塞不可信内容」

  • 主页https://github.com/advisories/GHSA-pmw4-pwvc-3hx2
  • 从哪读起:先读 GitHub Advisory GHSA-pmw4-pwvc-3hx2 的两句利用条件(三十秒),再看 Cymulate 那篇 InversePrompt 里贴出的那行校验代码——漏洞的全部内容就在这两处。

一行 startsWith 值多少钱

Claude Code 是 Anthropic 的命令行 agentic coding 工具——你在终端里让它改代码,它自己决定读哪个文件、跑哪条命令。它有一道目录沙箱:文件操作必须落在启动时的工作目录(CWD)里面。这道沙箱在 0.2.111 之前长这样,Cymulate 的博客把它原样贴了出来:

return path.resolve(filePath).startsWith(path.resolve(originalCwd));

一次字符串前缀比较,没有边界。path.resolve 只做了规范化(把 ../ 和相对路径展开成绝对路径),然后直接问「这个绝对路径是不是以 CWD 开头」。问题在于目录名之间没有分隔符约束。Cymulate 用的两条路径是:

  • 合法 CWD:/Users/eladbeber/Documents/claude_code
  • 攻击者目录:/Users/eladbeber/Documents/claude_code_evil

第二条以第一条为前缀,字符串比较返回 true,于是 claude_code_evil/ 下面的所有文件——以及从那里出发能触及的东西——被当成沙箱内部。缺的东西只有两样之一:比较前给 CWD 末尾补上路径分隔符(/Users/.../claude_code/,这样 claude_code_evil/... 就不匹配了),或者做真正的 canonical 比较(解析完符号链接后逐段比对目录层级)。Anthropic 补的是后者,advisory 的原话是 “implementing robust canonical path comparison”。

登记信息:CWE-22(路径遍历),CVSS 4.0 评 7.7 High,向量 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N——保密性、完整性、可用性三项全 High,但攻击要求(AT:P)和用户交互(UI:P)都不是零。受影响包是 npm 上的 @anthropic-ai/claude-code< 0.2.111。报告人是 Cymulate 的 Elad Beber,发现时间在 Claude Code 的 Research Preview 阶段。同一批还有一个 CVE-2025-54795(命令注入,修在 1.0.20),下面会用到它做对照。

没有查到任何野外利用报告——不是「确认没有」,是所有公开来源都没提。

从一句仓库里的文字走到 /etc/passwd

Advisory 把利用条件写得很具体,只有两条:存在(或能够创建)一个与 CWD 同前缀的目录;以及能把不可信内容送进 Claude Code 的上下文窗口。第二条是这张卡存在的理由——一个 CWE-22 的路径 bug,触发条件里写着「你得先能对模型说话」。

链条复原成画面:攻击者在 /Users/x/Documents/claude_code 旁边落一个 claude_code_evil/(advisory 只说「存在或可创建」,没说是攻击者自己建的还是诱导 agent 建的,我不细化)。然后把一段文本塞进模型会读到的地方——README、issue 正文、依赖包里的注释、agent 自己 cat 出来的日志,任何一处都行。模型读到这段文本,把它当成任务的一部分,对 claude_code_evil/ 下面某个路径发起读或写。校验函数做那次 startsWith 比较,返回 true,放行。用户没看到任何授权提示,因为在实现里「路径在 CWD 内」就等于「不需要问」。Cymulate 在文中配了一张截图,说明文字是 “For example, reading ‘/etc/passwd’ without explicit permission”——这是单一来源的演示,没有第三方复现。

这里必须停下来标一件事:那段真正被注入进上下文的提示词文本,公开材料里没有。 Cymulate 那篇文章逐字给出的只有上面那行校验表达式,以及 CVE-2025-54795 的命令注入模板:

echo "\"; <COMMAND>; echo \""

(这条是 54795 的,不是 54794 的。它闭合 echo 的引号、插入任意命令、再重新开一个 echo 让整条命令看起来仍然是白名单里那个无害的 echo。)54794 的注入载荷原文我查不到,所以这张卡不会给你一段「示意性」的 prompt——advisory 描述的是条件,不是实录。

三道闸门各自漏在哪

第一道,目录沙箱。它要防的是 agent 在你的代码仓库里跑着跑着,顺手读了 ~/.ssh/id_rsa。它被一次字符串前缀比较击穿——这不是 LLM 特有的失败,是 2000 年代就写在 OWASP 里的老式解析 bug,只是这次守的是 agent 的文件工具。

第二道,工具调用的人工确认。Claude Code 在做敏感文件操作时会弹提示让你点头。这道闸门这次根本没被叫醒:确认逻辑挂在「路径是否越界」的判断之后,第一道闸门说没越界,第二道就不会触发。两道防线看起来是纵深,实际串在同一个布尔值上——上游判错,下游一起判错。这也是 Cymulate 那张 /etc/passwd 截图的意思:不是用户点了确认,是压根没弹。

第三道,模型对不可信内容的判别。这道闸门不存在。agent 读进来的 README 文本和你在终端里敲的指令,在上下文窗口里是同一类 token,没有来源标签,也没有任何机制让模型「知道」哪段话该执行、哪段话只是资料。advisory 把这一层写成了利用条件而不是缺陷——它默认了「攻击者能往上下文里塞东西」是成立的,然后去数塞进去之后能造成多大后果。

对照 54795 更清楚:那条的防线是命令白名单(只允许 echo 这类安全命令),防的是「agent 别跑 rm -rf」,被一对引号闭合击穿。两个 CVE 的防线不同、被击穿的方式不同,但共同点是:真正把攻击者的意图运到执行点的那一段——模型照着不可信文本行动——两条链路里都没有任何一道闸门在管。

补丁停在文件系统那一层

Anthropic 修的是路径比较逻辑,0.2.111 起换成 canonical 比较。收尾动作还有两个:依赖自动更新推给大多数用户,以及把 < 1.0.24 的所有版本标记为 deprecated 并强制升级。注意这是两个数——0.2.111 是修复版本,1.0.24 是强制升级门槛,后者是把一整段旧版本一次性清掉的运维手段,不是这个 CVE 的修复点。

出口侧封死了:现在 claude_code_evil/ 会被正确拒绝。但两条利用条件里的第二条在补丁前后一模一样——模型依然会被仓库里的一段文字说服去访问某个路径,只是这次那个路径撞在了一个写对了的比较函数上。补丁没有声称修复注入,也没有能力修复它:agent 要能读 README 才有用,读了就会被读到的内容影响。

所以这类 bug 的爆炸半径由「那个校验函数写对没有」单独决定。写对了,注入只是让 agent 白跑一趟;写错了,注入就是完整的任意文件读写。同一段被注入的文本,在 0.2.110 上是 CVE,在 0.2.111 上是一次被拒绝的调用,模型的行为一步没变。指望「模型学会不听 README 的话」来兜底的部署,在这个案例里没有任何证据支持——它从头到尾都听了。

本地语料 7087 篇里只有 2 篇提到 CVE-2025-54794:一篇是 CVE 记录本身(2025-08-05),另一篇是 Are AI-assisted Development Tools Immune to Prompt Injection?(arXiv 2603.21642,2026-03-23),把它当作 AI 编码工具注入面的一个案例点名。一个上了 CVE 的 agent 事故,学术侧的引用量就是这个数。

已核实来源


本文由自动化管道生成(采集 → 逐字核验 → 模型撰写),未经人工改写。