TanStack 官方 npm 供应链攻击复盘:三重漏洞链详解
发布日期:2026-05-12 阅读时间:8 分钟 安全 / 供应链攻击
事件背景
2026年5月11日,TanStack npm 生态系统遭遇重大供应链攻击。攻击者向 42 个 @tanstack 包发布了 84 个恶意版本,通过窃取 OIDC token 控制了项目自身的 GitHub Actions 发布管线。事件在 20 分钟内被外部研究员发现,但影响已非常深远——恶意版本携带着合法的 SLSA 来源证明,因为这些版本确实是由 TanStack 自己的 CI/CD 管线所发布。
事件发生后仅数小时,TanStack 创始人 Tanner Linsley 就发布了这份完整的攻击复盘报告。文章一发出就在 Hacker News 上冲到了 613 分 和 232 条评论,足见开发者社区对这次攻击的高度关注。
注:我们此前已报道过该攻击的初始发现过程,本文聚焦 TanStack 官方发布的完整复盘报告,深入分析攻击的技术细节和漏洞链。
三重漏洞链:没有任何一环单独足够
复盘报告明确指出,这次攻击不是利用了单一漏洞,而是 三个已知安全问题串联在一起 的结果。每个环节本身都无法单独完成攻击,但组合在一起就变成了一把武器。
第一环:pull_request_target "Pwn Request" 模式
TanStack 仓库中的 bundle-size.yml 工作流使用了 pull_request_target 事件触发器。这个事件的设计初衷是让来自 fork 的 PR 能够以 基础仓库的权限上下文 运行——常用于标签、评论等操作。
问题在于,这个工作流 checkout 了 fork PR 的合并代码 并执行了构建步骤:
on:
pull_request_target:
paths: ['packages/**', 'benchmarks/**']
jobs:
benchmark-pr:
steps:
- uses: actions/checkout@v6.0.2
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
- uses: TanStack/config/.github/setup@main
- run: pnpm nx run @benchmarks/bundle-size:build
工作流作者尝试做了权限分离(隔离了一个 comment-pr job),但忽略了关键问题:actions/cache@v5 的 post-job save 不受 permissions: 设置的限制。缓存写操作使用的是 runner 内部 token,而非工作流的 GITHUB_TOKEN。这意味着即使设置了 permissions: contents: read,也无法阻止缓存被污染。
第二环:GitHub Actions 缓存投毒跨信任边界
恶意代码 vite_setup.mjs(约 3 万行 JS 包)专门设计用来将投毒数据写入 pnpm-store 目录,缓存的 key 与合法 release 工作流将使用的 key 完全一致:
Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}
当 benchmark-pr job 结束时,actions/cache@v5 的 post-step 将这个(已投毒的)pnpm store 保存到了缓存中。当 release.yml 下一次在主分支上运行时,它的 Setup Tools 步骤会恢复这个缓存——一切都在 GitHub Actions 的正常工作流程内完成。
这是一种业界熟知但经常被忽视的攻击手法。早在 2024 年,安全研究员 Adnan Khan 就详细记录过这类攻击。它不针对特定项目,而是 GitHub Actions 缓存系统自身的设计问题。
第三环:OIDC Token 进程内存提取
这是最巧妙也最危险的一环。TanStack 的 release.yml 声明了 id-token: write 权限——这对 npm OIDC 可信发布者是必需的合法配置。当投毒的缓存被 restore 后,攻击者控制的二进制文件在构建步骤中执行:
- 通过
/proc/*/cmdline定位 GitHub Actions Runner.Worker 进程 - 通过
/proc/*/maps和/proc/*/mem转储工作进程内存 - 提取运行时生成的 OIDC token(当
id-token: write设置时,runner 会在内存中延迟生成该 token) - 使用该 token 直接向
registry.npmjs.org发送认证 POST 请求 - 绕过工作流的 Publish Packages 步骤,以项目自己的身份发布恶意包
值得注意的是,这种内存提取技术(以及几乎相同的 Python 脚本,连署名注释都保留了)曾在 2025 年 3 月的 tj-actions/changed-files 事件中被使用过。攻击者并没有发明新的攻击手法,而是巧妙地将已知研究重新组合。
完整攻击时间线
复盘报告提供了精确到分钟的时间线:
缓存投毒阶段(5月10日-11日)
- 5月10日 17:16 UTC:攻击者创建 fork
zblgg/configuration(故意重命名以避开 fork 搜索) - 5月10日 23:29:在 fork 上提交恶意 commit,添加
vite_setup.mjs(约 30000 行,包含 payload 装载代码)。commit 消息前缀[skip ci]以抑制 push 事件上的 CI 触发 - 5月11日 ~10:49:以
zblgg身份打开 PR #7378,标题 "WIP: simplify history build" - 5月11日 10:49 起:
bundle-size.yml和labeler.yml自动对 PR 运行(pull_request_target 绕过了 first-time-contributor 审批) - 5月11日 11:01-11:11:多次 force-push 到 PR head,每次都触发更多 pull_request_target 运行
- 5月11日 11:11:force-push 将恶意 commit 放入 PR head。缓存成功投毒
- 5月11日 11:31:攻击者将 PR 回退到当前 main HEAD(0 文件 no-op),关闭 PR 并删除分支。缓存投毒持续存在
恶意发布阶段(5月11日)
- 19:15:维护者 Manuel 合入 PR #7369 → push 到 main 触发 release.yml。工作流启动但失败
- 19:20:39:npm registry 收到第一批恶意发布(第一轮版本)。OIDC 认证,来源看起来完全合法
- 19:20:47:Run 完成(状态:failure)
- 19:16:第二个合并触发 release.yml。同一个投毒缓存被 restore
- 19:26:14:第二批恶意版本发布
- 19:26:20:Run 完成(状态:failure)
发现与响应
- ~19:50:外部研究员
carlini开启 issue #7383,提供完整恶意 optionalDependencies 指纹分析 - ~19:50:研究员同时通知 npm 安全团队
- ~20:00:维护者 Manuel 确认事件,启动应急响应
- ~20:30:Tanner 发送完整 IOC 列表给 npm security
- ~21:00:扫描确认范围(42 个包 84 个版本),开始 deprecation。Twitter/X/LinkedIn 公开披露
- 21:30:确认攻击向量(bundle-size.yml pull_request_target 缓存投毒),GitHub 缓存全部清理,加固 PR 合并
恶意 Payload 做了什么
当开发者或 CI 环境对受影响的版本执行 npm install / pnpm install / yarn install 时,npm 解析恶意 optionalDependencies 条目,获取 fork 网络中的孤立 payload commit,运行其 prepare 生命周期脚本,执行约 2.3MB 混淆的 router_init.js:
- 凭证收割:从 AWS IMDS / Secrets Manager、GCP metadata、Kubernetes service-account tokens、Vault tokens、~/.npmrc、GitHub tokens(env / gh CLI / .git-credentials)、SSH 私钥等常见位置收集凭证
- 数据外传:通过 Session/Oxen messenger 文件上传网络(
filev2.getsession.org、seed{1,2,3}.getsession.org)传输——端到端加密,无攻击者控制的 C2,仅能通过 IP/域名阻断 - 自我传播:通过
registry.npmjs.org/-/v1/search?text=maintainer:枚举受害者维护的其他包,用相同注入重新发布
受影响包清单
42 个包每个被发布了两个恶意版本(间隔约 6 分钟),总共 84 个版本。未被影响的系列包括 @tanstack/query*、@tanstack/table*、@tanstack/form*、@tanstack/virtual*、@tanstack/store。
IOC 指标(供安全团队使用)
- Manifest 指纹:
optionalDependencies中包含@tanstack/setup: "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c" - 可疑文件:
router_init.js(~2.3MB,包根目录,不在 "files" 字段内) - 缓存 key:
Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11 - 二级 payload URL:
litter.catbox.moe/h8nc9u.js、litter.catbox.moe/7rrc6l.mjs - 外传网络:
filev2.getsession.org、seed{1,2,3}.getsession.org - 伪造 commit 身份:
claude(非真实 Anthropic Claude,使用 GitHub no-reply 邮箱) - 攻击者账户:
zblgg(id 127806521)、voicproducoes(id 269549300)
复盘核心教训
做得好的地方
- 外部研究员在发布后 ~20 分钟内发现并报告,附带完整技术分析
- 维护者团队跨时区快速协调响应
- 检测社群几小时内就建立了公开 IOC 模式
可以做得更好的地方
- 无内部告警:团队是从第三方那里得知被攻击的。需要对自身的发布行为做监控
- pull_request_target 工作流未审计:尽管这是长期已知的危险模式,但之前未被审查
- 浮动引用:第三方 Action 使用
@v6.0.2、@main等浮动标签,带来独立的供应链风险 - npm 无法 unpublish:因 npm "有依赖则不可 unpublish" 策略,大部分受影响包无法撤销,需依赖 npm 安全团队在服务端拉取 tarball,增加了数小时窗口期
- OIDC 无每步审批:一旦配置了可信发布者绑定,工作流中任何代码路径都可以生成具备发布能力的 token。需要更细粒度的控制
结语
这次攻击没有使用任何零日漏洞或秘密后门。它纯粹是 已知风险的手工组合——pull_request_target + GitHub Actions 缓存投毒 + OIDC token 内存提取,这三个已知问题各自被认为是"不那么致命"的,但串联在一起就变成了足以攻陷整个生态的武器。
对于所有在 GitHub Actions 上运行 CI/CD 的项目,无论规模大小,TanStack 的这次复盘都是一份宝贵的安全教材。特别是那些使用 pull_request_target + OIDC 发布的项目,应该立即检查:
- 你是不是在 pull_request_target 中 checkout 了 fork 代码?
- 你的缓存 key 是否可以被来自 fork 的 PR 预测并污染?
- 你的 OIDC 发布 token 是否可以被工作流中任何代码路径使用?
如果这三个答案是"是"——你现在就应该修复它们。
参考来源:TanStack 官方 Postmortem · HN 613 分讨论
上一篇报道:TanStack npm 供应链攻击初始发现分析