返回所有文章
发布于 · 作者 Renaud Deraison

那个 commit 是一条分支

Air 在 9 月 17 日披露了 Plugin4Shell:Claude Code、Codex、Gemini CLI 和 Copilot 的插件市场,都把每个插件钉在一个四十字符的 commit 哈希上,而智能体会把那个哈希检出,却从不确认自己拿到的就是它。攻击者只要把一条分支起成被钉住的哈希那个名字,就能赢得检出,剩下的由后台更新程序完成,没有提示,也没有点击。Anthropic 和 OpenAI 已修补。Google 表示不会修,Microsoft 至今没有动作。在 Bromure Agentic Coding 工作区里,赢得检出的代码落进的是一台只有诱饵凭证的 VM,外面还有由宿主机强制执行的推送策略和出口规则。

每一份供应链指南都告诉你,把依赖钉在哈希上,因为版本号只是一张别人搬得动的标签, 而哈希就是内容本身。四个编码智能体听从了这个建议。没有一个去确认,拿到的哈希是不 是它要的那一个。

一个插件通过了评审。你读过它,或者你的平台团队读过它,于是它钉在某个 commit 上进了 核准清单:四十个十六进制字符,只能描述唯一一棵树,因为改动树就会改动字符。这正是它 的吸引力。任何握有合适权限的人都能挪动一个标签或重新发布一个版本,但 commit 哈希 本应是一项事实。

9 月 17 日,Air 的三位研究员(Or Nevo、Dor Granat 和 Niv Hoffman)发表了 Plugin4Shell。他们的结论是:钉选 的价值不会高于解析它的那段程序,而四个主要编码智能体的解析,并不检查自己的成果。 The Register 当天就做了报道。 Claude Code、OpenAI 的 Codex、Google 的 Gemini CLI 和 Microsoft 的 Copilot 全都 受影响。

git 偏爱 ref

commit 哈希是内容,同时也是一个名字。分支和标签也是,而 git 用同一套查找把它们 全都解析出来。你交给 git 一个既是分支名又是对象 id 的字符串,它就得挑一个;它挑 分支。研究员用一句话讲完:“当一个名字同时是有效的 ref 和对象 id 时,git 偏爱 ref。”

git 确实会警告。它会为那个引用打印一条歧义提示,打印进一个你并没有在看的终端, 因为执行检出的是一支后台更新程序。

git 的分支名接受四十个十六进制字符。控制插件仓库的攻击者,建立一条与市场钉选同名的 分支,并把它设为默认分支。克隆时,这条分支会跟着同名的 commit 一起下来。接着智能体 执行它的检出:

git clone <plugin repo> ./
git checkout aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

检出落在分支上。Gemini CLI 走另一条路到同一个地方:它用 --ref 取得正确的 commit, 然后执行 git checkout FETCH_HEAD,而只要仓库里有一条叫 FETCH_HEAD 的分支, 这一行就会解析到那条分支。

一个名字,两种可能的含义市场的钉选aaaaaaaa…aaaa已评审、已记录、未改动仓库里的对象 idaaaaaaaa…aaaa通过评审的那份代码同样四十字符的分支refs/heads/aaaaaaaa…aaaa攻击者推上去的东西git checkout aaaa…aaaa解析到分支警告打出来了,没人在看智能体从不追问自己实际检出了什么。一条命令就能做到:git rev-parse HEAD。
一个字符串,两种含义。commit aaa…aaa 和一条叫 aaa…aaa 的分支,两者都存在于克隆下来的仓库里。git checkout 必须挑一个,而它挑了 ref。市场记录的钉选仍然一模一样,磁盘上的那棵树却属于别人。

五个步骤,最后一个没有点击

研究员把攻击拆成五个动作,而前三个都是一个运作良好的市场刻意在做的事。

埋下。 发布一个良性插件。它在 commit aaa…aaa 通过评审,因为它确实没有问题。

采用。 开发者安装它,钉在已评审的 commit 上。这一步,你做的正是指南要你做的事。

版本更新。 市场把钉选换到较新的 commit bbb…bbb,同样良性,同样已评审。

抽地毯。 攻击者建立一条叫 bbb…bbb 的分支,设为默认,并让它指向恶意代码。他 什么都没有重新发布,市场的记录也没有移动。你会去核对的那四十个字符,就是你先前 核对过的那四十个字符。

从自动更新到远程代码执行。 Claude Code 和 Codex 默认在后台更新已安装的插件。 下一次排定的更新就会检出那条分支。Air 把这一步形容为“没有提示,没有点击”。

Plugin4Shell,一步一步来谁在行动1一个良性插件发布,并在 commit aaa…aaa 通过评审此时代码确实没有任何问题市场2开发者安装它,钉在已评审的 commit 上这是被推荐的做法,而且做对了3市场把钉选换到较新的 commit,bbb…bbb同样良性,同样已评审市场4一条叫 bbb…bbb 的分支被建立并设为默认没有任何东西被重新发布;记录的钉选没有移动攻击者5后台更新程序检出那条分支代码在开发者的机器上执行 — 没有提示,没有点击智能体
步骤一到三是市场照设计运作,而步骤二是开发者遵循标准建议。攻击是步骤四和五,而这两步都不需要开发者批准任何事。

还有第二条门路,可以跳过埋下这一步:接管一位正当插件作者的仓库,把恶意版本推给每 一个已经装了该插件的智能体。分支的把戏一模一样,而且没有评审需要通过。

Air 把结果描述为“在员工机器上的完整远程代码执行”,以该员工的权限执行。攻击者不需要 提权。代码以笔记本主人的身份执行,触及他们的文件与他们的生产环境访问权。

这已经是这几位研究员第三次数到同一个形状。他们先前关于恶意智能体技能的研究,描述过 一个触及超过 26,000 个智能体的技能,以及一场劫持行动,拿下了已在 134,000 个智能体 上使用的 925 个技能。那些行动需要你安装坏东西; 这一次需要你安装好东西,然后等着。

四家里有两家没有补丁

Air 在 6 月通报了四家厂商。

Anthropic 在 6 月 17 日修补了 Claude Code,版本 2.1.179。OpenAI 的修正随 Codex 0.146.0 发布,并在 8 月 12 日获得验证。Google 在 8 月 4 日回复,Gemini CLI 已经 弃用,不会修补。Google 让用户改用 Antigravity,所以一份留在磁盘上的 Gemini CLI 就一直留着漏洞。Microsoft 没有为 Copilot 发布任何东西。GitHub 表示自家平台会挡下 看起来像 commit SHA 的分支与标签名,这覆盖了托管在 GitHub 上的插件,对托管在别处 的插件仓库则毫无作用,Bitbucket 也一样。The Register 引用 Microsoft 自己的数字 指出,财富五百强企业中有将近 90% 在用 Copilot。

修正本身只有一行,而且必须跑在智能体内部:

test "$(git rev-parse HEAD)" = "<pinned-sha>" || abort

解析出落进工作区的那个 commit,除非相符否则拒绝继续。没有任何市场能代你做这件事, 因为在市场把话说完之后,是你的智能体在你的机器上解析那个钉选。

对今天读到这篇文章的大量开发者来说,“更新你的智能体”不是一个可用的答案。那就剩下 另一个问题:当那段代码赢得检出并开始执行,它会找到什么?

在一个 Bromure Agentic Coding 工作区里

Air 那句话,以员工的权限执行,说得很准。工作区存在的意义,就是让它不成立。

Bromure Agentic Coding 里,智能体跑在你 Mac 上一台硬件虚拟化 的 Linux VM 中,而这台 VM 从来不持有真正的凭证。会话启动时,应用会把保留结构的诱饵 写进智能体运行的环境:ANTHROPIC_API_KEYGH_TOKENLINEAR_API_KEY 等等作为 环境变量,并把相对应的假值写进 ~/.git-credentials~/.docker/config.json~/.kube/config~/.aws/config 以及智能体的 MCP 配置文件。每个诱饵都保留工具预期 的形状,所以工具会接受它:GitHub 诱饵是 ghp_ 加 36 个字符,Anthropic 的以 sk-ant-api03-brm- 开头,Kubernetes 的 bearer token 以 brm-k8s- 开头。Bromure 以 每次安装专属的盐,通过 HKDF-SHA256 从真实值派生出每一个诱饵,因此它跨会话稳定不变, 而对捡走它的人毫无价值。

真实的值加密存放在宿主机上。宿主机端的代理在请求离开 VM 之后,才在链路上把它们换 回去,而且只在目的地符合该凭证所签发的主机时才换。一个插件扫过家目录寻找配置文件, 这正是这类载荷第一件会做的事,它捡到的只是一组格式完好的诱饵。

检出之后:同样三个动作,做两次一台普通的开发者机器读取家目录sk-ant-api03-… ghp_… AKIA…真密钥、真令牌、真 kubeconfig成功推送到它够得着的仓库git push → git-receive-pack开发者自己的 git 凭证成功把收集到的东西送到某处POST https://attacker.example普通的对外 HTTPS,路上什么也没有成功一个 Bromure Agentic Coding 工作区读取家目录sk-ant-api03-brm-… ghp_… brm-k8s-…诱饵;真的在宿主机上诱饵推送到它够得着的仓库git-receive-pack = 写入挡下,或弹出宿主机对话框让你决定策略把收集到的东西送到某处451 — 一个字节都没转发出口规则,接着 VM 被暂停停住
同一次检出,同一个恶意插件,两台机器。在普通笔记本上,代码继承了开发者真正的凭证,开发者能到的地方它都能到。在工作区里,它读到的凭证是诱饵,它尝试的推送撞上宿主机端的写入策略,而它对外的呼叫撞上一条来宾改不了的出口规则。

推送必须在宿主机上获得批准

一个被入侵的插件在开发者机器上能做的最有价值的事,就是推送。 一台笔记本变成一百个仓库,正是这样发生的。

Guardrails 会把智能体发往 GitHub 的每一次调用分类。走 HTTPS 的 git push 到达代理 时是 git-receive-pack,算作写入。只读模式拒绝它。写入前先询问模式会在你的 Mac 上弹出一个对话框,标题是 Allow write on "<scope>" from workspace "<name>"?, 一字不改地显示该操作,而不是它的摘要。git fetch 算作读取,不打扰你就通过。

这个分类跑在宿主机的代理里,在 VM 之外。来宾内部的代码既关不掉它也绕不过去,因为它 不是跑在做决定的那台机器上。

对外的呼叫撞上一条来宾改不了的规则

每个工作区都有一道出口防火墙:一份有序的、pf 风格的允许与拒绝规则清单,涵盖主机、 IP 范围、协议、端口,而对网页流量还涵盖单个的 HTTP 动词。 allow web api.example.com GET,POST 让智能体只能查询、不能变更,就在链路上生效, 无论 VM 里哪个工具发出请求都一样。

有两层在执行它。虚拟网络交换机依目的 IP 和从 DNS 侧录到的主机名,对每一条流套用 规则,除了 HTTPS 也涵盖纯 TCP 与 UDP。代理再依 TLS 服务器名与 HTTP 方法套用一次。 Bromure 不需要来宾配合,就把 VM 的 80 和 443 端口流量导进代理,所以里面没有任何东西 能豁免于检查。你的修改会立刻送达进行中的会话,这在你读到像这样一则披露的当天特别 重要。

底下还有失陷检测器,默认开启,没有东西要配置。代理会在单次扫描中,检查每个对外请求 里有没有为该工作区铸造的诱饵令牌。一个诱饵被寄往它从未被铸造去对应的主机,就是外泄 的特征,代理会以 HTTP 451 回应,一个字节也不转发。它当场暂停 VM,并发出告警,让你 选择关闭工作区、导出磁盘与家目录以供调查,或是自担风险继续。Bromure 会把该工作区 标记为已失陷,下一次启动会清除 VM 磁盘与家目录,同时保留你的令牌、SSH 密钥与工作区 设置。走掉的只有诱饵,所以没有什么需要轮换。

零点击意味着没有东西可供察觉

步骤五最锐利的性质是它的安静。后台的插件更新不会产生提示,也不会产生工具调用,所以 智能体自己的界面,没能给开发者任何可以抓住的东西。Air 的论点是,这个攻击跑在系统中 你没有理由去盯着的那一块。

工作区把盯着这件事搬到别处。安全引擎把每一个决定写成你 Mac 上 Security Timeline 窗口里的一行:防火墙判决(evil.example:443 tcp — blocked)、Guardrails 的执行 (DELETE api.github.com/repos/… — blocked)、credential brokering,包括红色的 blocked — exfiltration attempt, VM paused 那一行,外加供应链判决与提示注入命中, 都有颜色标示、可以筛选。一个会话还可以录下已执行命令与已连到主机的加密轨迹。

一个没告诉你就自己更新的插件,终究还是得发出请求,而每一个请求都要越过一道边界, 那道边界在 hypervisor 的另一侧留着自己的记录。

先确认你的智能体版本

Claude Code 2.1.179 与 Codex 0.146.0 含有这项修正。如果你用 Gemini CLI,不会有 修正。如果你用 Copilot,目前还没有。

能关的地方就关掉插件自动更新

零点击这个性质来自后台更新程序。手动更新会把人重新放回步骤五的回路里。

在 CI 里自己验证检出

在钉选的检出之后执行 git rev-parse HEAD,拿来和钉选比对,就是厂商不得不补上的 那一行检查。凡是你用 SHA 克隆的地方,它都管用。

假设有人会赢得检出

钉选、评审、市场审核,在这里全都守住了,攻击还是打进来。接下来的结果,就取决于 赢得检出的那段代码够得着什么。

Plugin4Shell 用同样的条件抓住谨慎的开发者和粗心的开发者。装了当红热门那位,下场并不 比读了源码、钉住哈希、把钉选记下来的那位更糟。内容寻址本该保护第二位开发者,它却 没有做到,因为 git 是通过一种查找来解析这个地址,而那种查找也把同样的字符拿去指别的 东西。

继续钉选。然后假设:一个建立在查找或名称匹配之上的控制,总有一天会在你睡着的时候被 交上错误的答案。为那个早晨做设计:让智能体跑在一个没有任何值得拿走的密钥的地方, 并把它做过什么的记录,留在代码够不着的边界另一侧。