← 随机比特 / 所有内容

42 个 npm 包被劫持,"快 rotate token" 这次是无效动作

2026-05-13 · 随机比特
42 个 npm 包被劫持,"快 rotate token" 这次是无效动作

42 个 npm 包被劫持,“快 rotate token” 这次是无效动作

6 分钟,42 个 npm 包,84 个版本。

5 月 11 日晚上 19 点 20 分 39 秒,TanStack——React Query、Table、Form、Router 的母公司——发布机器开始按秒发包。维护者本人正在睡觉。20 分钟后被一个外部研究员撞上。期间没有任何人审批过任何 PR,也没有任何 npm token 被泄露。

npm token 不是这次的钥匙

刷到"某 npm 包被劫持",做前端的人脑子里通常自动跳出一串动作:登录 npm,rotate token,开 2FA,复查最近 publish 记录。

这次这套全部无效。

攻击者从头到尾没碰过任何人的 npm 凭证,甚至不需要知道 TanStack 的 token 长什么样。

为什么?因为现在 npm 发包早就不靠"刷工牌"了。

trusted publisher 这个机制的本质:npm 不再认 token,每次有人想发包,它直接问 GitHub——“这是不是 TanStack/router 仓库 main 分支上跑的 workflow?” GitHub 答"是",npm 当场签发一张一次性凭证,publish 立刻通过。等于把"刷门禁卡"换成"门口的保安直接问 GitHub 你是不是从总部那间办公室走出来的"。

漏洞在于:保安只看你从哪间办公室出来,不看你出来前在那间办公室里干了什么。只要恶意代码寄生在那间办公室的任何一个 workflow step 里——比如 test、cleanup、上传 artifact——它跑起来照样属于"main 分支上跑的 workflow",照样能让 GitHub 替它签发那张一次性凭证。

具体到这次事件:攻击者先用一条 pull_request_target 触发的 workflow 把 1.1 GB 恶意 pnpm 缓存写进 GitHub Actions cache。等下次有人合 PR 到 main,release.yml 复用了那个缓存——测试阶段的一段代码读 runner 进程内存,拿到一次性 OIDC token,直接 POST 到 registry.npmjs.org

release.yml 里那个名字叫 “Publish Packages” 的 step,因为测试失败,从头到尾根本没跑过。

rotate token 没拦住任何一环。攻击链根本不路过那一层。

<figure><img src=“images/01-credential-vs-oidc.png” alt=“01-credential-vs-oidc”></figure>

yaml 里的几行配置,比维护者警惕性低更致命

过去几年的供应链攻击教程都在讲一件事:维护者要警惕陌生 PR、要 review 依赖更新、要小心钓鱼邮件。这次 TanStack 的维护者全程做对了——攻击者那个 PR (#7378) 没被 merge,最后还被改成 0 文件的 no-op 后关闭。

但这次维护者警觉不警觉,跟攻击成不成功没关系。攻击靠的是三件事:

  1. pull_request_target 不查 first-time contributor:GitHub 文档管这叫"Pwn Request"反模式,警告早就写在那里。但它太好用——bundle-size benchmark、自动打 label,这种便利 workflow 一律靠它拿 secrets。
  2. Actions cache 没有 fork / base 隔离:fork 触发的 workflow 写入的 cache,base 仓库同 key 的 workflow 会直接复用。投毒成本极低。
  3. OIDC trusted publisher 的 ref binding 绑得太宽:绑到 refs/heads/main 等于"main 上任何 workflow 都能 publish"。如果绑到 refs/tags/v*,恶意代码凑不出 release tag 这个触发上下文。

这三件事写在 yaml 里。没人 review,没人 lint,也没有谁的岗位叫"yaml 安全审计"。维护者眼睛再尖,也不会盯在这几行配置上。

该 rotate 的不是 npm token

攻击载荷在 npm install lifecycle 跑起来之后,扫的是:AWS IMDS 元数据、GCP metadata 接口、Kubernetes service account token、Vault token、~/.npmrc、本地 GitHub token、SSH 私钥。

所以 5 月 11 日装过受影响版本的人,要 rotate 的是上面这一整串。npm token 这次根本没被偷,被偷的是那台跑 install 的机器里所有能扫到的凭证。

值得记一下:StepSecurity 把这波归到一个叫 Mini Shai-Hulud 的蠕虫家族,背后归因 TeamPCP。TanStack 在 npm 上签出来的恶意包,带着 valid SLSA provenance——这是公开记录里第一次。供应链攻击靠"看 provenance 是不是合法"判断真假,这条防线本周也作废了。

<figure><img src=“images/02-3-fixes.png” alt=“02-3-fixes”></figure>

明天上班可以做的三件事

如果你维护任何一个会被 publish 到 npm 的仓库,今天值得做三件 10 分钟以内的事:

1. 把 pull_request_target 扒一遍

grep -rn "pull_request_target" .github/workflows/

每一个命中点都问一遍:“这个 workflow 真的需要 secrets?真的需要陌生 PR 自动触发?” 大多数 bundle-size、labeler 类工作流改成 pull_request + 手动审批完全够用。

2. OIDC trusted publisher 的 ref 收紧

去 npm 设置页面把 publisher 绑定改紧一档。yaml 这边对应改:

# 之前:任何在 main 上跑的 workflow 都能 publish
on:
  push:
    branches: [main]

# 之后:只有打了 v* tag 才走 publish
on:
  push:
    tags: ['v*']

npm 那边对应把绑定从 refs/heads/main 改成 refs/tags/v*。一行配置的事。

3. 审一遍 release workflow 的 cache 来源

发布流程在 install 阶段如果复用 actions/cache,问一遍这个 cache 的 scope 能不能被 fork PR 投毒。

这三件事任意一件 TanStack 当时做了,攻击链都断。

钥匙换地方了

过去十年 npm 安全圈都卷在一层:怎么让 maintainer 的密码不泄露、token 不被复用、发布过程必须走 2FA。这一层卷得很深。TanStack 这次纸面上全都做对了——2FA、OIDC trusted publishing、signed provenance,一样不缺。

结果攻击者绕开整个这一层,从 CI 的边门走了进来。

以前钥匙在维护者的密码里。现在钥匙在仓库 .github/workflows/ 的几行 yaml 里。

下次再刷到"某 npm 包被劫持",停一秒,先问一句:偷的是 token,还是 OIDC 信任链?两层的防御方法完全不一样,事后 rotate 哪样东西也完全不一样。

(顺便:攻击者在那个 orphan commit 里给自己起的名字叫 claude。但 pull_request_target 根本不查 commit author 是谁,这只是个伪装小动作。)