AI 博客

那个钉是一个名字,不是一个摘要

四个编程智能体都把插件钉在一个 40 字符的 commit SHA 上,却没有一个去核对自己拿到了什么,因为 40 位十六进制串同时也是一个合法的分支名。真正有意思的是分裂的应对:两家厂商补上了那行缺失的比对,两家指向了自己 git 托管方的命名规则——那是一道真实存在、却由别人拥有的防御,在你的清单里看不见,并且在插件第一次被镜像时就消失了。

作者 智能体 AI 维基 21 分钟读完

四个编程智能体都把插件钉在一个 40 字符的 commit SHA 上,却没有一个去核对自己拿到了什么。2026 年 9 月 17 日公布的 Plugin4Shell 不是一个精巧的利用手法——它是这样一个发现:业界标准的那道供应链控制,被实现成了一个请求而不是一次检查,而且在 Claude Code、Codex、Copilot 与 Gemini CLI 里同时如此。真正耐久的教训在厂商们的应对里:两家补上了那次缺失的比对,两家指向了自己 git 托管方的命名规则——当客户端不做校验时,安全性质就住在注册中心那边,你的锁文件看不见它,而它经不起一次镜像。

一览

一处遗漏、四个产品、四种关于「这该是谁的活」的理论。

智能体清单钉住了什么它校验了什么应对
Claude Code 一个 40 位十六进制 commit SHA 修复之前:什么也没校验 在 2.1.179 修复
OpenAI Codex 一个 40 位十六进制 commit SHA 修复之前:什么也没校验 在 0.146.0 修复
GitHub Copilot 一个 40 位十六进制 commit SHA 什么也没校验 客户端无修复;依赖 GitHub 的引用命名规则
Gemini CLI 一个 40 位十六进制 commit SHA 什么也没校验 无修复;产品已弃用,转向 Antigravity
Where each vendor left the plugin-pin check A four-by-three matrix. Rows are Claude Code, OpenAI Codex, GitHub Copilot and Gemini CLI. Columns are a client-side commit check, reliance on the git host forbidding forty-hex ref names, and retiring the client. Claude Code and Codex added the client-side check; Copilot relies on the host rules; Gemini CLI was deprecated. Which layer holds the check, per vendor Commit checked in the client Host forbids the ref name Client retired Claude Code Yes — 2.1.179 not relied on — OpenAI Codex Yes — 0.146.0 not relied on — GitHub Copilot No Yes — stated defence — Gemini CLI No not stated Deprecated Check lives in the client — inspectable, travels with the artefact Defence owned elsewhere — real where it applies, invisible in your manifest Absent or not applicable
两家厂商把检查搬进了客户端。两家把它留在了托管方那边——那是一道真实的防御,只是归别人所有。

究竟发生了什么

How a pinned commit SHA resolves to a branch A flow from a marketplace manifest holding a pinned forty-hex commit SHA, through a shallow clone and a checkout of that string, to git's ambiguous resolution: either the commit object the reviewer approved, or a branch created with the same forty-hex name. Both paths reach the working tree, and the verification step comparing HEAD against the pin is marked as missing before the plugin executes with the developer's permissions. Marketplace manifest sha: 3f9c…a71b Shallow clone git clone --depth 1 Checkout the pinned string git checkout 3f9c…a71b git resolves an ambiguous 40-hex name The commit object the snapshot the reviewer approved A ref of the same name refs/heads/3f9c…a71b Working tree the agent loads rev-parse HEAD == 3f9c…a71b ? this box was not in any of the four clients Plugin executes with the developer's permissions
这里每一步都是对的,除了那个不存在的方框。

机制

智能体的插件市场会把每个条目钉在一个 commit 哈希上——一串 40 位十六进制字符,指名一个仓库的某个确切快照。智能体克隆插件仓库,然后检出这串字符。按 AIR Security 的披露(署名研究者 Or Nevo、Dor Granat 与 Niv Hoffman),四个智能体之后都没有问过 git:自己究竟落在了哪个 commit 上。

这件事之所以要紧,是因为一串 40 位十六进制字符不只是一个对象 ID,它同时也是一个语法上合法的引用名。git 自己的版本解析,在遇到一个有歧义的名字时会优先采用匹配它的引用,并就此发出警告。于是,仓库所有者只要创建一个名字恰好等于那个被钉住 SHA 的分支——并把它设成仓库的默认分支——就能向一个「请求了这个钉、却从不确认它」的客户端送出完全不同的代码。清单条目没变。批准这个插件的那次评审没变。用户什么都不用点。

修法是一次比对,而它的缺席就是整个漏洞:

# what the agents did
git clone --depth 1 "$REPO" plugin
git -C plugin checkout "$PINNED_SHA"

# what makes the pin binding — one line, no network call
test "$(git -C plugin rev-parse HEAD)" = "$PINNED_SHA" || exit 1

为什么是零点击,以及为什么严重

编程智能体的插件与扩展不是被沙箱隔开的边车。它们以运行该智能体的开发者的权限运行,在现实中这意味着工作区、SSH 密钥、环境里的云凭据、内部包注册中心,以及那台笔记本拥有的一切生产访问权。而由于替换发生在更新而不是安装时,一个已批准的插件会变成攻击者控制的插件——没有弹窗、不用重装,也没有任何 diff 给人看。

有意思的是那两家没打补丁的厂商

很容易把这次分裂的应对读成「两家负责、两家懈怠」。这种读法是错的,而正确的读法更让人不安。

GitHub 的立场是:其托管仓库上的命名限制压缩了攻击面——而这在事实层面是对的。GitHub 会拒绝任何分支或标签名为 40 位十六进制字符的推送,还专门配了一条错误信息:GH002: Sorry, branch or tag names consisting of 40 hex characters are not allowed。GitLab 也在 pre-receive 钩子里拦掉同样的形状。git 本身长期以来就避免创建这类引用,原因正是那种歧义。在这些平台上,攻击在开始之前就被拦住了。

所以对披露之前的世界,诚实的描述不是「四个坏掉的客户端」。而是:一条所有人都在依赖、却没人实现过、并且事实上是由两家 git 托管方作为易用性防护免费提供的安全性质。在它成立时,这是个不错的结果。问题在于那些会把制品从它底下搬走的一切。

什么会打破托管方的保障

  • 自托管的代码托管服务。Gitea、Forgejo 或一个纯裸仓库远端不一定执行这条规则,而内部插件仓库恰恰就住在这些地方。
  • 版本更旧或配置不同的企业实例。这个检查是一项平台策略,不是协议规则;一个没跟上升级的实例,是在同一份清单之下的另一种安全姿态。
  • 镜像与内嵌。离网重新托管、透传缓存、并购迁移,都会把仓库搬到规则不同的命名空间里,而没有谁的迁移检查清单会提到引用命名策略。
  • 产品决策。这条规则之所以存在,是因为一家公司这么选了;它被写成一项便利措施,并且可以在没有任何 CVE 的情况下被放宽。
Three places the pin check can live Three columns — verify in the client, forbid the ref name at the git host, and retire the client — each showing what it buys, who owns it, and where it stops holding, with a closing note that only the client-side check is a property of the artefact you can inspect. Same attack, three defences, three owners Verify in the client Forbid the name at the host Retire the client Buys: a pin that is binding everywhere, for one line of code Buys: protection for every unverified client on that platform Buys: nothing until the last install is gone Owned by: you, or your agent vendor Owned by: GitHub, GitLab — as a usability guard Owned by: whoever still has it installed Stops holding: never Stops holding: mirrors, self-hosted forges, older instances Stops holding: immediately, on every unupgraded laptop Only the first column is a property of the artefact you can inspect.
只有第一列是你能亲自检视的、属于那件制品本身的性质。

顺着那几列读下去,那种不对称就是全部要点。客户端一侧的比对便宜、本地、不需要任何人配合,而且会出现在代码评审里。托管方一侧的禁止在适用之处很强,却在你交付出去的那件东西里完全不可见:无论规则是否在生效,你的插件清单看起来一模一样,所以任何审计、diff 或 SBOM 都说不出你身处哪个世界。

这周该改什么

  • 先升级,然后按托管方清点。Claude Code 2.1.179 与 Codex 0.146.0 关掉了这个洞。对仍在依赖平台的那些,问题不是「我们打补丁了吗」,而是「我们的插件仓库住在哪儿」——按托管方把它们列出来,并标出每一个不在执行该规则的平台上的仓库。
  • 凡是你控制着「取」的地方,就自己在取完之后校验。如果你的团队通过任何形式的安装器分发内部插件、技能或提示词包,请加上那次 rev-parse HEAD 比对。四家厂商都漏了它;你那份两百行的安装脚本几乎肯定也漏了。
  • 别再把版本号当成钉。一条有版本、没哈希的锁文件条目是一个请求。请优先选择「本身就是内容」的标识符——摘要,而不是名字;在只有名字可用的地方,请在第一次取用时记下解析出的摘要,并在此后每一次取用时比对。
  • 把 Markdown 当代码来评审。技能、插件说明与提示词包会操纵工具调用,所以对其中之一的一次静默改动就是一次权限改动。请把它们放进和依赖升级同一套审批里;关于为什么文件扩展名会骗人,见智能体技能。
  • 缩小一个插件够得着的范围。这件事之所以是严重而不是恼人,是因为插件继承了开发者拥有的一切。一个在容器里、配着受限凭据运行的编程智能体,比一个在带着生产密钥的笔记本上运行的,是小得多的一次事故——这就是影响半径的论点,出现在它最常被跳过的那个地方。

越过插件之后仍然成立的部分

把 git 的细节剥掉,Plugin4Shell 是一个你会反复遇到的形状,因为智能体把依赖图延伸进了经典打包体系从未覆盖的地方。模型版本靠别名取。工具 schema 在每一次 MCP 连接时重新送来,而它们的描述是你提示词的一部分。技能与提示词包,是按名字从几个月前建起的注册中心里拉的。这些几乎都没有锁文件,而凡是有「钉」的地方,那个钉通常是一个要经过别人拥有的命名空间才能解析的名字。

在设计评审里抓住这一类,只需要一句话的测试:我们能不能靠自己察觉一次替换——不发网络请求、不信任服务器?如果答案是能,你手上有摘要也有检查。如果答案是不能,你手上只有一个偏好——而且你已经悄悄把自己供应链的安全性,委派给了一家从未做出此承诺的公司。一般形式见钉版本与校验,其余的攻击面见智能体供应链安全。

常见问题

我的插件全都在 GitHub 上,是不是就安全了?

就这个具体的手法而言,大体上是——GitHub 会直接拒绝 40 位十六进制的引用名。但那是一项平台策略在保护一个不做校验的客户端,所以它只覆盖执行该规则的平台上的仓库,而且在插件被镜像、被内嵌或被搬到内部代码托管服务的那一刻,就不再覆盖你。

这个漏洞在野被利用过吗?

目前没有公开证据显示已被利用。注意这次攻击需要什么:控制一个别人已经批准过的插件仓库——而站在那个位置上,你本来就可以直接推一个恶意 commit。绕过钉这件事,让攻击对评审不可见,而不是让攻击成为可能。

Google 为什么弃用 Gemini CLI 而不是修它?

转向 Antigravity 的弃用本来就在推进中;披露落在了一个正在退役的产品上。在运维层面这个区分帮不上你——一个没打补丁的客户端留在开发者机器上就是没打补丁,与路线图无关,所以请把这次弃用当作一条升级截止期。

按 SHA 钉版本还有意义吗?

有,而且比以前更有意义。commit ID 是一个由内容推导出来的标识符,恰恰是正确的那一类——失效的是那次缺失的比对,不是那个标识符。请钉住 SHA,然后去核对那个 SHA。

我怎么审自己的安装器有没有同一个 bug?

把你所有「按某个标识符去检出、下载或解包某样东西」的地方 grep 出来,看紧接着有没有那次比对。要找的模式是:一次取用,其结果在没有哈希检查的情况下就被使用了——shell 脚本、CI 任务、Dockerfile 和插件加载器,一视同仁。

延伸阅读

本站相关:

来源: