5 月 11 号 UTC,TanStack 维护者起床看到 npm registry 上多出来一堆没人发过的包版本。
@tanstack/react-router、@tanstack/react-query、@tanstack/table——这些是 React 圈装机量最大的几个库之一。每分钟全球有几千次 npm install 在拉它们。一夜之间,84 个版本同时变成了"看起来正常、跑起来偷你 AWS key"的特洛伊木马。
同一天倒下的还有 Mistral AI、UiPath、Guardrails AI——AI 工具链上的硬通货。攻击者一个团伙:TeamPCP,2025 年底冒头,专攻云原生供应链。蠕虫叫 Mini Shai-Hulud——去年那波原始 Shai-Hulud 的"迷你"版,但杀伤面更宽。
漏洞编号已经下来:CVE-2026-45321,评分 9.6。
把恶意代码塞进 @tanstack/react-router 的官方 npm 发布,难度有几层。
最容易想到的办法是钓鱼。给维护者发个伪装邮件骗 npm token、social engineering 套出二次验证码。但 TanStack 的维护者用了硬件 key,钓鱼这条路堵死。
第二条路是偷 secret。把 GitHub 仓库的 NPM_TOKEN 偷出来,自己跑发布。但 TanStack 已经不存长期 npm token——他们用的是 GitHub OIDC,发布时由 GitHub 现场颁发短期 token 给 npm registry。这是 2024 年起被推荐的"最佳实践"。
攻击者没走任何一条传统路。他们盯上了一个所有人都默认信任的位置:CI 系统自己。
攻击者 fork 了 TanStack/router 仓库,然后在 fork 里推了一个 orphaned commit——跟 TanStack 主干没有共同祖先的孤立提交。
为什么要 orphaned?因为它让 GitHub 的历史检查变得复杂。CI 系统判断"这个 commit 是不是从可信源派生"时,遇到 orphaned commit 会走默认路径,而默认路径几乎没人加固。
攻击者用 fork 开了一个 PR。这个 PR 触发了 TanStack 仓库里的 pull_request_target workflow。
这是 GitHub Actions 的经典 footgun,安全圈叫它 "Pwn Request"。pull_request_target 触发器有个特点:它跑的是 base 仓库的 workflow 定义(所以维护者觉得"安全"),但 checkout 默认拿的是 PR 来源的代码(攻击者的 fork)。
维护者写 workflow 时常忘记这一点。结果是攻击者的代码在 TanStack 主仓库的 secrets 上下文里被执行——拿到了访问仓库 secret 的能力。
攻击者没急着干票大的。他们做了一件看起来很无聊的事:往 GitHub Actions cache 里写了一份恶意 pnpm store。
然后关掉 PR,安静等着。
GitHub Actions 的 cache 是按仓库共享的。维护者后续合任何一个 PR 到 main,release workflow 跑起来,第一步就是从 cache 取 pnpm store——取到了被污染的那份。
几小时后,一个完全正常的 maintainer PR 被合进 main。release 自动跑起来。整个发布流程跑在被污染的 node_modules 上,但维护者完全看不出来——构建没报错,测试都过了。
被污染的 pnpm store 里塞了一个二进制。它在 release workflow 启动后做了一件事:读 /proc/,直接从 GitHub Actions runner 进程的内存里抠 OIDC token。
这是关键。OIDC token 设计来代替长期 secret,理论上更安全——它只有几分钟有效期,绑定特定 audience。但这个"安全"的前提是:跟 token 同进程的所有代码都是可信的。
恶意二进制跟合法发布脚本跑在同一个 runner 上、同一个 Linux 用户、同一个进程组。读对方内存几乎没门槛。token 到手,攻击者直接调 npm registry 发了 84 个恶意版本。
全程没碰任何长期凭据。
直接受害:42 个 @tanstack 包,84 版本,已下架但已经被大量 npm install 拉走。
二次传播更可怕。恶意脚本在受害者 CI 上跑起来,做的不止是收集——它会扫描所有能访问的环境变量,把 AWS key、Anthropic API key、Discord webhook、加密钱包助记词、SSH key 打包外发。然后蠕虫复用同一台 CI 上其他 npm 维护者的发布权限,继续往外感染包。
OpenAI 是被波及的一个。他们公告里说"两台员工设备在公司环境里受到这次攻击影响"。看似规模小,但这两台机器之一参与了 macOS 应用的签名流程。
结果是:OpenAI 必须把所有 macOS 应用的代码签名证书全部轮换。ChatGPT Desktop、Codex、Codex CLI、Atlas——四个都得重签。macOS 用户必须在 2026 年 6 月 12 日前升级到新证书签名的版本,否则 macOS 系统会直接拒绝运行。
这不是 OpenAI 第一次。一个月前的 Axios 攻击已经让他们的 macOS 签名管道挨过一次,证书刚轮换完。这次又一次。同一个团伙,同一个套路。
复盘到这一步,很多人第一反应是"npm 又不行了"。
错。npm 在这次事件里几乎是受害者——他们提供的短期 OIDC token 机制本身是更安全的设计。
真正出问题的是 GitHub Actions 的信任模型:
pull_request_target 触发器跟普通 pull_request 长得像,但安全语义完全不同,文档藏在角落,维护者踩坑率极高。pull_request_target 让攻击者能跑 workflow。零信任、短期凭据、最小权限——这三个安全圈讲了十年的原则,在 cache poisoning + 进程内存读取面前一起失效。短期凭据没用,因为攻击者活在那几分钟里。最小权限没用,因为他们要的就是"发包"这一个最小权限。
给个人开发者:
package.json 写死版本号,pnpm-lock.yaml / package-lock.json 跟到 git,每个 PR 看 lockfile diff。这次很多人中招就是因为 ^x.y.z 自动拉到了恶意版本。~/.npm、~/.cache/pnpm、~/.yarn/cache 这些目录如果在恶意期间被污染过,你机器上的开发环境就带毒。--ignore-scripts=false。装一个 install-scripts-blocker 这类工具。给团队:
pull_request_target 触发器永远不要 checkout PR 来源代码。如果非要,先把 PR 的 SHA 钉死,再加一层人工审批。id-token: write 不要写在 workflow 顶层。provenance + sigstore,让消费方能校验包的发布来源。最重要的一条认知:你的 CI 不是个跑测试的容器,是个"代你以维护者身份操作所有外部系统的特工"。它有你 npm 的发包权限、你 AWS 的部署权限、你 Slack 的发消息权限。它被攻破等于你被攻破。
我自己产品的 release 流程里这周已经做了三件事:把 pull_request_target 全部换掉、把 release 环境跟 PR 环境拆开、给发包加 required reviewer。代价是发包从 1 分钟变成 5 分钟——值。