你以为 CI 安全就是保护好 token,今天这件事被 TanStack 一次供应链攻击彻底翻了
2026 年 5 月 11 日,TanStack 的 42 个 npm 包被人在六分钟内篡改发布了恶意版本。攻击者没有偷任何人的密码,没有黑进 GitHub,也没有利用 npm 的漏洞。他们只是利用了 GitHub Actions 里一个看起来完全正常的缓存机制。
整个攻击链是这样的:先从 fork 提交一个 PR,这个 PR 触发了 TanStack 的 pull_request_target 工作流——这个工作流本来是用来在 PR 上评论构建大小的,所以它会检出 PR 的代码到主仓库的上下文里运行。问题出在 TanStack 的 setup action 用了 actions/cache,而这个缓存的 key 只跟 lockfile 有关,跟代码来源无关。所以攻击者的恶意脚本在那个工作流里写入了一个被污染的 pnpm store,缓存 key 是 Linux-pnpm-store-${hashFiles(**/pnpm-lock.yaml)}。工作流结束后,缓存就保存了这个污染版本。
接下来,当 TanStack 的维护者合并了一个完全无关的 PR,触发了正式的 release 工作流——这个工作流声明了 id-token: write(为了 OIDC Trusted Publishing 发布到 npm)。release 工作流执行时,actions/cache 把之前那个被污染的缓存恢复了回来。污染的 pnpm store 里有攻击者植入的恶意二进制文件,在构建过程中被执行。这些二进制文件通过 /proc 文件系统读取了 GitHub Actions Runner 的进程内存,从中提取出了 OIDC token——这个 token 是 GitHub 在 id-token: write 时动态铸造的、存在内存里的短命 token。攻击者拿到 token 之后直接调 npm registry API,绕过了工作流本身的 Publish Packages 步骤,在六分钟内发布了 84 个恶意版本到 42 个 @tanstack/* 包。
这件事让很多人震惊,因为它打破了一个常见误解:OIDC Trusted Publishing 不用存 long-lived token,比传统 PAT 安全。但这次攻击证明,只要你能在 CI runner 里执行代码,你就能从内存里把 OIDC token 挖出来用——这个 token 虽然短命,但在有效期内足够用来发布恶意包。
这不是第一次。2024 年 3 月,Angular 被通过同样的方式攻击,攻击者写入了污染缓存并差点拿到 Angular Robot 的 token。2025 年 3 月,tj-actions/changed-files 这个被 23000 个仓库引用的 action 本身被污染,攻击者通过内存 dump 窃取了所有下游 runner 的凭证。2026 年 2 月,Cline CLI 被污染,4000 名开发者装上了带后门的 OpenClaw。TanStack 这次是同类攻击里最完整、破坏力最大的一次。
核心问题在哪里?pull_request_target 工作流会在主仓库的上下文里执行 fork 的代码——这是设计行为,不是 bug。但这个上下文能写缓存,而缓存的 key 只跟 lockfile 内容有关,不跟触发来源绑定。actions/cache 的写入不受 permissions: 块控制,即使你把 job 权限设成 contents: read,缓存写入依然会发生。这两件事加起来,就形成了一条跨越信任边界的污染路径:fork 代码 → 写主仓库缓存 → release 工作流恢复缓存 → 在高权限上下文里执行污染产物。
GitHub 在 2026-09-10 上了 cache-mode 配置项,可以控制缓存的读写权限:read 只恢复不写入、write 恢复也写入、write-only 只写入不恢复、none 完全禁用。这是缓解手段之一。但根本解法是把缓存操作从 pull_request_target 工作流里移除,或者把 release 用的缓存 key 跟 PR 构建用的完全隔离开。
对于正在用 GitHub Actions 跑发布流水线的团队,有几件事现在就可以做:第一,检查所有 pull_request_target 工作流里有没有 actions/cache 或其他写缓存的操作,如果有,要么删掉要么隔离 key 前缀;第二,所有跑在 main 分支上的 release 工作流,不要依赖可能被 PR 污染的缓存;第三,如果工作流声明了 id-token: write,不要让它同时恢复任何来自 PR 构建的缓存;第四,考虑用 cache-mode: read 强制 release 工作流只能读取可信的缓存来源;第五,把第三方 action 固定到 commit SHA 而不是 tag,防止供应链的二次污染。
最后说一个很多人没注意到的细节:攻击者的 payload 里有一个死手开关——它会在受害机器上装一个 systemd 服务,每 60 秒去 GitHub 检查被盗 token 是否还有效,如果 token 被吊销了就执行 rm -rf ~/。这意味着即使团队发现了攻击、吊销了 token,在吊销顺序上如果处理不当,反而会触发对开发者本地机器的破坏。这是个很小但很真实的风险,说明这类供应链攻击的复杂度已经远超大多数人的预期。
如果你维护的开源项目里有发布流水线,现在就花五分钟过一遍你的 workflow 文件——pull_request_target 配了哪些 step、哪些 step 写了缓存、release 流程恢复了哪些缓存。这五分钟可能比写任何代码都值。
评论区
登录后可评论。