你锁定了插件的 commit,它却还是被换掉了:Plugin4Shell 零点击攻击拆解

一个不需要你动手的替换#
设想一个很守规矩的开发者。
他没有装来路不明的插件,只从官方市场里挑经过审核的那几个;他也没有关闭版本锁定,插件被钉在一个具体 commit 上,这是市场给出的安全承诺。安装那天他看过 diff,没有可疑的网络请求,没有读 ~/.ssh 的代码,一切正常。
然后某一天,那台机器上运行的代码已经不是他审核过的那一份了。
他没有点过任何链接,没有确认过任何弹窗,也没有重新安装任何东西。恶意代码是在后台自己进来的。
2026 年 9 月中旬,安全公司 Air Security 公开披露了这个漏洞,取名 Plugin4Shell,沿用 Log4Shell 那套命名习惯。受影响的不是一个工具,而是四个主流的命令行编码 Agent:Anthropic 的 Claude Code、OpenAI 的 Codex、微软的 GitHub Copilot,以及谷歌的 Gemini CLI。披露时还没有分配 CVE 编号。
这条消息最让人不舒服的地方不在于漏洞本身有多精巧,而在于它精准打击的是最有安全意识的用户:那些老老实实只用审核过的插件、老老实实让版本锁定生效的人。他们的防线之所以失效,是因为这把锁从一开始就没有真正锁在任何东西上。
插件的 SHA 锁,承诺的到底是什么#
要理解这次的失效,得先看清版本锁定的设计意图。
一个插件市场存在的前提是:市场上架的东西和人实际跑的东西是同一份代码。为了维持这个前提,市场会让每个插件钉在一个 commit hash 上,那串 40 位的十六进制字符代表代码库在某一刻的精确快照。它是不可变的,你审核的时候看的是它,别人后来往仓库里推新提交也改不动它。
这串 hash 承担两个功能。往未来看,它是自动更新的起点,Agent 只从这个锚点往后取新版本。往过去看,它是回滚的坐标,出问题了可以退回到那个确认过的时间点。
听起来很稳。但这次披露揭开的正是:这个承诺在客户端的实现里缺了最后一环。
Air Security 的说法很直白:Agent 会签出市场钉住的那个 commit,但从不验证它是否真的落在了那里。攻击者一旦控制插件仓库,就能让这次签出解析到恶意代码,而 pin 从表面看依然完好无损。
一句话总结就是,请求发对了,结果是错的,中间没有一句断言。
一个 git 的歧义,吃掉了整把锁#
漏洞的根子在 git 自己的引用解析规则上。
git 的 ref(分支、标签)和 object id(commit hash)本来是两个不同的命名空间。但当同一个名字既是合法的 ref、又是合法的 object id 时,git 在解析时会优先把 ref 拿出来用,只在日志里留一句 refname is ambiguous 的警告。对机器来说这是提示,对人类来说这是一个隐藏了很久的地雷。
攻击路径因此变得很短。控制插件仓库的人新建一个分支,名字就是那个被钉住的 40 位 hash,然后把这个分支设成仓库的默认分支。下一个执行 git checkout <sha> 的 Agent 就会落到这个分支的内容上,而它自己以为签出的是那个不可变的 commit。
这里有两个前提,缺一不可。第一,托管平台必须允许 hash 形状的分支名,git 自己的 git check-ref-format 是接受的,不少平台也跟着接受。第二,那个分支必须是仓库的默认分支。如果只是普通分支,克隆时它只会作为远程跟踪引用被拉下来,checkout 会正常回退到真正的 commit。所以攻击者需要的不只是写权限,而是把恶意分支摆到仓库的正门位置。
Gemini CLI 走的是另一条路径,但病因相同。它先执行 git fetch origin <sha> 再执行 git checkout FETCH_HEAD。如果默认分支恰好叫 FETCH_HEAD,同样的歧义就会反过来咬人:刚取下来的那个正确 commit 被丢在一边,签出解析到的是攻击者控制的分支内容。
顺着这条链往回看,最有意思的细节不是 git 的行为,而是修复的方向。正确的断言只有一句话:签出完成之后,去解析真正的 HEAD,拿它和当初请求的 commit 逐字比对,不一致就中止。必须核对的是"解析之后的结果",而不是"当初请求的那个引用"。这个区别,恰好就是 Gemini CLI 那个变体钻过去的缝。
为什么整个过程没有一秒提示#
单靠安装期的替换还不够可怕,真正让它变成"零点击"的是自动更新。
自动更新这个设计本身完全说得通。插件的世界里,手动更新的结局就是大量开发者长期跑着带已知漏洞的老版本,所以默认开启后台更新是行业共识,操作系统、浏览器、包管理器都这么做。安全论证没有问题。
问题在于:自动更新继承了这个 pin 解析逻辑,于是同一个 git checkout 会在后台定期重跑。市场一旦把钉住的 SHA 往前推一格,替换就会自动送达已经安装好的插件,不需要重新安装,不需要确认,也不会在界面上留下任何异常。攻击者不需要说服任何人装一个新东西,他只需要那个善意插件已经躺在那里。
关于攻击面,这里有一处必须说清的边界,否则容易自己吓自己。The Hacker News 在披露当天做了核查:默认开启后台自动更新的,是各家官方内置的市场,而这些市场托管的仓库都在 GitHub 上。GitHub 默认拒绝 hash 形状的分支和标签名,所以变体一在这条路径上被挡住了。第三方市场的自动更新则默认关闭,或者需要显式打开。换句话说,只从官方默认市场装插件的用户,不在这个变体的射程之内。但这个结论不能外扩:它不覆盖非 GitHub 的托管来源,也完全不覆盖 Gemini CLI 的 FETCH_HEAD 变体。
四个 Agent,同一种缺口,四种结局#
Air Security 在 2026 年 6 月私下通报了四个厂商。9 月公开披露时,各家的状态并不一致。
Codex 的修复在 0.146.0 里。这个版本的发布说明里有一条不起眼但很关键的条目,PR #34644,标题就叫 Verify Git plugin SHA checkouts,核验 Git 插件的 SHA 签出。它的做法是在按 SHA 签出之后解析 HEAD,与请求的 commit 比对,不一致就拒绝。顺带说一句,这个修复保留了按 ref 名签出的原有行为,也就是说选分支或标签是另一套策略,和选择不可变的 commit 并不是一回事。
Claude Code 的修复是 Air Security 报出的 2.1.179,官方的 changelog 里并没有点名这一项,所以这条只能按"厂商披露方称已修复"来理解,不能当成官方记录。
GitHub Copilot 在披露时没有修复。Air Security 说 6 月就通知了微软。Copilot 支持从非 GitHub 的宿主安装插件,而风险正好落在那部分来源上,所以这个缺口目前还开着。
Gemini CLI 的结局最干脆:谷歌已经弃用这条产品线,不再打补丁。Air Security 的建议是迁移到 Antigravity,因为后者没有这套插件市场 SHA pinning,也就没有这个可以被绕过的面。
还有一个悬着的问题,各家来源都没有给出明确答案:升级只能阻止未来的替换,还是能连带清除已经被换掉的那份插件。这个空白很重要,它意味着"升到 0.146.0 以上"是必要条件,但不足以构成一次完整的处置,你还需要自己核对一遍现在装着什么。
这件事真正暴露的三层裂缝#
把技术细节放在一边,我认为这次披露值得记下来的有三层,而且每一层都不只属于 Codex。
第一层:验证的是请求,不是结果。
pin 表达的是意图,HEAD 表达的是事实,两者中间少了一个断言。这是基础设施层面反复出现的反模式:凡是"下载然后执行"的系统,都必须有一个后置条件检查,用独立的方式确认拿到的字节就是想要的字节。npm 的 integrity 字段、容器镜像的 digest、下载页旁边的校验和,存在的理由都一样。插件市场给了你一个坐标,但没有人在坐标上盖章。
第二层:自我认证。
出问题的时候,Agent 报告的是"已按 pin 安装成功"。这句话看起来是证据,其实是自称。它来自同一个已经被污染的解析过程,等于自己给自己发证书。真正的信任只能来自独立可验证的事实。就这次而言,那个事实只有一个:签出之后解析出来的 HEAD,和钉住的 SHA 是不是同一个。修复要做的事,本质上就是把自证换成实证。
第三层:权限继承。
插件和运行它的 Agent 同权限,也就是和开发者本人同权限。插件被换掉,等于把攻击者的代码放进了那台每天在拉代码、跑测试、握着凭据的机器。供应链攻击的性价比在这里最高:不需要攻破任何一家大厂的边界,只要拿到一个被信任的仓库的写权限就够。
还有一条属于组织层的裂缝:市场侧根本兜不住这件事。因为 pin 是在客户端解析的,市场无论怎么加固自己的审核流程,都无法强制保证客户端真的落地到了那个 commit。一个市场能做的,最多是限制只接受会拒绝 hash 形状分支名的托管平台,而那等于把自托管 Git 服务排除在外,同时完全管不了 Gemini CLI 的变体。这条约束值得记住:信任链上每一环都必须自己闭合,不能指望上游帮你补下游的洞。
现在该做什么#
如果你在用 Codex,或者任何一个装插件的编码 Agent,下面这份清单可以直接照做。
先确认版本。 codex --version,任何低于 0.146.0 的输出都落在受影响范围里。Claude Code 的对应门槛是 2.1.179。
再升级。 独立安装重跑安装脚本,Homebrew 走 brew upgrade codex,npm 全局安装走 npm update -g @openai/codex。
暂时升不了,就先切断零点击那条路。 关掉市场的自动更新,比如 Codex 的 codex plugins set-auto-update off。要清楚这只是止血:它挡不住在你关闭之前就已经被替换掉的那份插件,所以升级仍然是正解。
审计来源。 列出已安装插件和它们的来源,把非 GitHub 来源的那几个标成高风险。Bitbucket、自托管 Gitea、默认命名策略下的 GitLab 社区版都在"允许 hash 形状分支名"这一侧的名单里。在你能把它们和某个已知良好的 commit 对上之前,先禁用。
CI 里禁止自动更新。 自动化会话跑到一半偷偷换插件是最坏的情况。给无人值守的 profile 显式写上 auto_update = false。
把插件当依赖管。 在 AGENTS.md 里记录插件版本与来源哈希,任何偏差都当成异常处理;对碰生产或共享基础设施的团队,考虑给插件来源加一份白名单。
给会话加一道前置检查。 用一个 PreToolUse 钩子在会话开始前核对插件清单与来源,和一份已知良好的基线比对。这样即使下一次再出现同类问题,也能在任何工具真正执行之前把它拦下来。
写在最后#
Plugin4Shell 这个名字借了 Log4Shell 的势,但两者的性质不太一样。Log4Shell 是一个实现里的错误,改掉那几行就结束了。Plugin4Shell 更像一记提醒:它指着的是"我以为我验证过了"这种心态。
插件市场承诺你锁在某个 commit 上,但真正决定你跑什么代码的,是签出之后那一次解析到底落在哪里。前者是意图,后者是事实,只有当有人把两者放在一起比对的时候,锁才真正存在。
这次是四个 Agent 同时缺了那一步,所以四个同时中招。下一次可能换个形式出现,但只要系统里还有"请求一个版本、执行另一个版本"的缝,就还会有人踩上去。
参考来源:
- Air Security 的漏洞披露:Plugin4Shell
- Cloud Security Alliance 的研究简报:Plugin4Shell: SHA-Pinning Bypass Enables AI Coding Agent RCE
- The Hacker News 的报道与边界核查:Plugin4Shell Lets Repository Owners Swap Pinned Plugin Code Across Four AI Coding Agents
- Codex 0.146.0 发布说明(含 PR #34644 Verify Git plugin SHA checkouts):rust-v0.146.0
- 本文主要参考并重写的技术拆解:Plugin4Shell Explained: How a Zero-Click Exploit Targets Your Codex CLI Setup