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

旧名字照样能发布

10 月 13 日,Bloom Security 披露了 SlugFest:npm 的信任发布按“所有者/名称”字符串来匹配 GitHub 仓库,因此任何人只要抢下一个已改名或已删除仓库的旧名字,就能为不属于自己的包发布新版本,根本不用偷任何凭据。Bloom 发现了 1,500 多个暴露的包,总下载量超过 1,000 万次,并点名 AI 代理正是在无人看管时拉入新版本的安装者。在 Bromure Agentic Coding 里,这么新的版本对代理来说并不存在,安装在只放了替身的 VM 里运行,而打开漏洞的那次改名会等你点一下。

npm 的信任发布记住了仓库的名字,却忘了那是哪一个仓库。维护者一改名,下一个用这个名字创建仓库的人就能发布。

10 月 13 日星期一,Bloom Security 的 Golan Myers 发表了 SlugFest, 这是 npm 信任发布(trusted publishing)中的一个漏洞。The Hacker News 当天就做了报道。Myers 用一把锁来解释:“这就像一把只核对门牌、不核对房主是谁的锁;房主搬走了,谁搬进来谁就拿到钥匙。”

Bloom 统计出 1,500 多个暴露的包,总下载量超过 1,000 万次,每月超过 100 万次。这种攻击“不需要内部知识,不需要窃取凭据,也不需要竞态条件”。Bloom 没有公布是哪些包:“由于问题仍然普遍存在,我们不公开受影响包的清单。”

Bloom 还点出了如今是谁在安装这些包。按它的统计,一个编程代理平均每月安装 70 个包,一台开发者机器约 350 个。用 Bloom 的话说,通过被抢回的名字发布的恶意版本“会被拉进来,在没有人参与的情况下运行”。

信任发布是怎么核对仓库的

npm 在 2025 年 7 月向所有人开放了信任发布。维护者不再把 npm 令牌存在 CI 里,而是运行 npm trust github --allow-publish,告诉 npm 哪个 GitHub 仓库、哪个 workflow 文件可以发布这个包。发布时,GitHub Actions 签发一个描述本次 workflow 运行的短期 OIDC 令牌,npm 拿它与绑定比对,再返回一个发布凭据。没有任何长期密钥可偷。

绑定把仓库存成一个 slug,owner/repo,外加 workflow 的文件名。它没有存 GitHub 终身分配的数字 repository_id 和 repository_owner_id。npm 要等第一次发布成功后才记录这两个值。在此之前,任何顶着正确名字、跑着正确 workflow 文件的仓库都能对上。

GitHub 上的名字会被释放。维护者给仓库改名、把它转移给某个组织、删掉它,或者改了自己的账号名。旧的 slug 随之空出,别人可以注册。Bloom 的步骤如下:

  1. 在空出来的 slug 上创建一个仓库。GitHub 给它分配一个新的数字 ID。
  2. 添加一个文件名与绑定相同、带 id-token: write 权限的 workflow,并运行它。
  3. GitHub 签发一个 OIDC 令牌,其中的仓库 claim 是旧的 slug。
  4. npm 的交换端点比对 slug 和文件名后,签发发布凭据。数字 ID 就在令牌里,但 npm 手上没有可以拿来比的东西。
  5. 为一个不属于你的包发布新版本,连同它的名字、它的下载量,以及所有依赖它的项目。

Bloom 用自己控制的 npm scope 和仓库把整条链从头到尾跑了一遍,从另一个只有 slug 相符的组织成功发布。GitLab 的信任发布也有同样的缺口。

这样产出的版本甚至带着签名。从公开仓库进行的信任发布会生成来源证明,而这份证明会在维护者配置的 slug 下,指向攻击者仓库里的 workflow 运行。8 月份,pull request 上一条两个词的评论就让一个项目自己的发布流水线推出了十个带着有效来源证明的恶意版本。SlugFest 连受害者的流水线都用不着。

空出的名字、相符的 workflow、一张发布凭据npm trust 绑定repo: acme/sdkworkflow: release.ymlrepo_id: (未存储)维护者改名acme/sdk → acme/sdk-v2acme/sdk 空了出来,谁都能再注册攻击者的新仓库 acme/sdkrepository_id: new.github/workflows/release.ymlpermissions: id-token: writeOIDC 令牌:repository = acme/sdknpm 交换:slug ✓ workflow ✓ID:没有东西可比→ 签发发布凭据npm 注册表@acme/[email protected]标记为 latest附带来源证明某个编程代理:npm install @acme/sdk解析 latest,运行它,没人在看名称仅为示意。Bloom 发现了 1,500 多个处于这种状态的包,尚未公开清单。
SlugFest 的攻击链。绑定只存了名字,改名让名字空了出来,用旧名字创建的新仓库通过了 npm 的检查,以原有包的身份发布。

修复花了四个月

Bloom 在 6 月 6 日通过 GitHub 的 HackerOne 项目报告了这个漏洞。GitHub 在 6 月 9 日确认。最后一次有实质内容的回复是 7 月 8 日,说团队“正在修复这个问题,并评估其风险和严重程度”。此后六次跟进和一次向 HackerOne 提出的调解请求都石沉大海。

GitHub 10 月 2 日的 changelog 带来了修复。创建后 48 小时内从未发布过的信任发布绑定,现在会过期,不能再授权发布。第一次发布成功后,绑定就会绑定到仓库的数字身份。要换仓库,就得新建绑定,重新开始 48 小时的期限。npm 现在也会拒绝由 issue_comment workflow 签发的令牌,那正是 8 月那次攻击的触发点。

changelog 没有说 10 月 2 日之前创建的绑定如何处理。Bloom 给维护者的建议是:对每个包运行 npm trust list,确认每个 slug 仍指向自己控制的仓库,并在每次改名或转移之后重建绑定。

如果你是安装包而不是发布包,这条建议对你毫无用处。从 npm install 看不到一个包的信任绑定。那 1,500 个名字没有公开。从被抢回的 slug 发出的恶意版本,看起来和其他任何版本一样,连来源证明都有。Bloom 针对代理场景的原话是:“它只需要是代理挑中的那个包。”

同样的安装,换到 Bromure 机器上

在 Bromure Agentic Coding 里,代理运行在你 Mac 上的 Linux VM 中,VM 发出的每个请求,包括 registry.npmjs.org,都要经过主机上的代理服务器。供应链策略在这台代理服务器里执行,位于 VM 之外,所以代理或包在机器里做什么都改不了它。我们跟着 SlugFest 的版本走进一台 Bromure 机器。

这么新的版本不会被列出

每台新机器都默认开启存在时间闸门,最少两天。当 npm 向注册表请求 @acme/sdk 时,代理服务器会把过去两天内发布的版本全部从列表中移除,并把 latest 移到足够旧的最新版本。

代理运行 npm install @acme/sdk,和它在任何地方会运行的命令一样,拿到的是维护者最后一个真正的版本。攻击者的版本不在代理看到的列表里。如果代理因为某个网页或 issue 让它这么做而指定了版本号,tarball 请求就会收到 HTTP 451,npm 会在代理的终端里打印原因:

Bromure Supply-Chain Security blocked this request: npm package @acme/[email protected]
published 3 hours ago — policy requires 2 days minimum

闸门只看发布日期,所以你不需要 Bloom 的清单,也不需要恶意软件特征码。抢回 slug 的攻击者必须发布新版本,而新版本就是年轻的版本。代理安装上一个版本的同时,维护者、npm 和扫描器有两天时间,去发现一个团队里没人发过的版本。绑定从未用过的维护者,在它第一次发布时,有充分理由去看一眼。

两天之后还有更多检查

在机器的 Supply Chain 面板里开启更多防护层,版本一通过闸门就会遇上它们。选中 socket.dev 后,每个 tarball 在提供前都会被检查,被标记为恶意软件的包会收到 451。开启 OSV 检查后,已有公开安全公告的版本会按你选择的严重程度被拦下。开启 Strip preinstall / install / postinstall / prepare from npm tarballs on the fly 后,代理服务器会在 npm 看到 tarball 之前移除其中的安装 hook,拿掉大多数 npm 恶意软件赖以投递的途径。

载荷找到的是替身

假设某个版本还是进来并运行了。它在 VM 里运行,而那里的凭据都是替身。GH_TOKEN、~/.git-credentials、Anthropic 密钥、AWS 密钥:每一个都是和真品形状相同的字符串,由真实值和每次安装独有的 salt 推导而来,除了经由你 Mac 上的代理服务器、发往它所属的主机之外,在哪里都没用。载荷把它们发到自己的服务器,发出去的只是到处都无法通过认证的字符串。

那个请求同样要经过代理服务器,它会检查每个出站请求,看有没有替身被发往错误的主机。一个正发往收集服务器的 ghp_ 替身会收到 451,VM 随即暂停,Mac 上会打开 This environment may have been compromised,列出该凭据的名称、它所属的主机,以及它被发往的主机。

你的改名要等你点一下

SlugFest 始于一次改名、一次转移或一个被删除的仓库,而代理也会做这类整理工作。在 Bromure 机器里,GitHub 默认处于 Prompt before write 之下。给仓库改名是对 GitHub API 的 PATCH,删除则是 DELETE。两者都会停在一个 macOS 对话框 Allow write on "github.com" from workspace "…"? 上,显示线路上实际出现的方法和路径。当代理整理项目、把 acme/sdk 改名为 acme/sdk-v2 时,你会在旧名字空出来之前看到它,也知道该重建哪个 npm 绑定。

清单公布的时候

Bloom 迟早会公布受影响的包。到时就会有人问:你的哪台机器装过其中一个,什么时候装的。Security Timeline 会记录每台机器的每一次包下载,无论策略如何:npm · @acme/[email protected]、机器、时间,以及它是被下载、被存在时间闸门挡住,还是被拦截。按包名筛选,把结果导出为 CSV。记录保存在 Mac 上,代理改不了。

在 Bromure 里:对代理来说,新版本还不存在机器 VM(Ubuntu)$ npm install @acme/sdk+ @acme/[email protected]$ npm i @acme/[email protected]451 published 3 hours agoGH_TOKEN=ghp_… (替身)ANTHROPIC_API_KEY=sk-ant-api03-brm-…VM 里没有真正的密钥可拿代理服务器,在 Mac 上存在时间闸门(默认 2 天):不满 2 天的移除,latest → 4.1.3可选:socket.dev、OSV、移除安装脚本替身发往别的主机 → 451,VM 暂停PATCH /repos/acme/sdk(改名)→ Prompt before write 对话框策略在主机上,代理碰不到npm 注册表4.2.0 3 小时前来自被抢回的 slug4.1.3 5 个月前维护者的版本两天时间让维护者、npm 和扫描器发现它Security Timeline,在 Mac 上npm · @acme/[email protected] · 已下载 · 机器、时间 · 按包筛选,清单公布时导出 CSV
同样的安装,换到 Bromure 机器上。刚发布的版本不在代理看到的列表里,指定版本的请求收到 451,即使载荷真的运行了也只找到替身,而每一次下载都有记录。

新版本不会被列出

存在时间闸门默认设为两天,会把新鲜的版本从注册表列表中移除,并把 latest 往回移。代理安装的是维护者最后一个真正的版本,完全不知道发生过什么。

指定版本的请求收到 451

如果代理按版本号请求新版本,下载会被拒绝,npm 会在代理的终端里打印包的存在时间和策略。

载荷找到的是替身

在 VM 里运行的任何东西,找到的都是替身令牌。被发往错误的主机时,它会收到 451,VM 暂停,Mac 弹出警报。

改名和安装都有记录

Prompt before write 会拦住让 slug 空出来的仓库改名。Security Timeline 按机器和版本记录每一次包下载。

下一个名字

GitHub 的修复会在第一次发布后把绑定绑到仓库的数字身份上,并让从未发布的绑定过期,这就堵上了这个缺口。信任发布保护的是发布凭据,并告诉你是哪条流水线构建了这个版本。8 月那次,流水线是受害者自己的,被陌生人的一条评论触发。这一次,是陌生人的仓库顶上了正确的名字。两次的版本都带着来源证明。

你的代理会安装解析器返回的任何东西,按 Bloom 的统计每月 70 个包。让它们在 Bromure 机器里运行:在那里,两天内发布的版本还不存在,即使载荷真的运行了,也只找得到替身。想进一步了解存在时间闸门,请阅读任何人都能发布那个包和吊销令牌,就是引爆点。