事后复盘:TanStack npm 供应链被入侵
- 原文链接:tanstack.com/blog/npm-su…
- 原文作者:Tanner Linsley
处理记录
- 抽取:
ensure_article_source_md.py对目标站使用--no-use-playwright(静态urllib选中 HTML),因在无头 Playwright 路径上出现驱动管道EPIPE中断;check_source_link_fidelity.py对完整导出稿与--html-url的<p>映射已通过。 - 译文正文对齐
npm-supply-chain-compromise-postmortem-article-source.md(从# Postmortem起至附录 A 止),已剔除站点导航、页脚赞助与「本页目录」等非<article>语义块,避免污染译文。
作者 Tanner Linsley,2026 年 5 月 11 日。
最后更新: 2026-05-11(日期与英文原文一致,便于与事件时间线对照。)
太长不看(TL;DR)
2026-05-11 的 19:20 至 19:26(UTC)之间,攻击者向 42 个 @tanstack/* npm 包发布了共计 84 个恶意版本,手段同时利用了:pull_request_target 触发的「Pwn Request」模式、跨 fork↔基仓库信任边界的 GitHub Actions 缓存投毒,以及从 GitHub Actions runner 进程内存中运行时提取 OIDC 令牌。没有 npm 令牌被盗,npm 发布工作流本身也未被直接攻破。
恶意版本在约 20 分钟内被外部研究者 ashishkurmi(供职于 StepSecurity)公开发现。所有受影响版本均已弃用(deprecated);npm 安全团队已介入以从注册表侧拉取 tarball。我们没有证据表明 npm 凭据被盗,但我们强烈建议:凡在 2026-05-11 安装过受影响版本的团队,应轮换安装主机上可触及的 AWS、GCP、Kubernetes、Vault、GitHub、npm 与 SSH 等凭据。
跟踪议题: TanStack/router#7383 GitHub 安全公告: GHSA-g7cv-rxg3-hmpx
影响范围
受影响包
42 个包、84 个版本(每个包两个版本,发布时间间隔约 6 分钟)。完整表格见跟踪议题。已确认未波及的家族:@tanstack/query*、@tanstack/table*、@tanstack/form*、@tanstack/virtual*、@tanstack/store、@tanstack/start(元包本身,不含 @tanstack/start-*)。
恶意软件行为
当开发者在本地或 CI 中对任意受影响版本执行 npm install、pnpm install 或 yarn install 时,npm 会解析恶意的 optionalDependencies 条目,从 fork 网络拉取孤立的 payload 提交,运行其 prepare 生命周期脚本,并执行被打包进受影响 tarball、体积约 2.3 MB 的混淆 router_init.js。该脚本会:
- 从常见位置收割凭据:AWS IMDS / Secrets Manager、GCP 元数据、Kubernetes ServiceAccount 令牌、Vault 令牌、
~/.npmrc、GitHub 令牌(环境变量、ghCLI、.git-credentials)、SSH 私钥 - 通过 Session/Oxen 信使的文件上传网络外泄(
filev2.getsession.org、seed{1,2,3}.getsession.org)——流量端到端加密且不存在攻击者可控的 C2,因此唯一的网络侧缓解是封禁 IP/域名 - 自我传播:通过
registry.npmjs.org/-/v1/search?text=maintainer:<user>枚举受害者维护的其他包,并以相同注入方式重新发布
由于 payload 作为 npm install 生命周期的一部分运行,凡在 2026-05-11 安装过受影响版本的人,都必须将安装主机视为可能已被入侵。
时间线
所有时间均为 UTC,时间戳来自 GitHub API 与 npm 注册表。
攻击前(缓存投毒阶段)
| 时间 | 事件 |
|---|---|
| 2026-05-10 17:16 | 攻击者创建 fork github.com/zblgg/confi…(fork 自 TanStack/router,并刻意改名以规避 fork 列表检索) |
| 2026-05-10 23:29 | 恶意提交 65bf499d16a5e8d25ba95d69ec9790a6dd4a1f14 在 fork 上由伪造身份 claude <claude@users.noreply.github.com> 编写。新增 packages/history/vite_setup.mjs(约 3 万行打包后的 JS payload)。提交信息前缀 [skip ci] 以在 push 事件上抑制 CI |
| 2026-05-11 ~10:49 | PR #7378 对 TanStack/router#main 打开,标题为「WIP: simplify history build」,作者 zblgg |
| 2026-05-11 10:49 起 | bundle-size.yml 与 labeler.yml(均使用 pull_request_target)对该 PR 自动运行——不需要首次贡献者审批,因为 pull_request_target 会绕过该门槛。使用 pull_request 的 pr.yml 不会运行,因等待审批而一直被阻塞 |
| 2026-05-11 11:01–11:11 | zblgg 多次 force-push PR 头分支,每次都会触发更多 pull_request_target 运行 |
| 2026-05-11 11:11 | force-push 将恶意提交 65bf499d 落到 PR 头。bundle-size.yml 的 benchmark-pr job 检出 refs/pull/7378/merge,执行 pnpm install + pnpm nx run @benchmarks/bundle-size:build——从而执行 vite_setup.mjs |
| 2026-05-11 11:29 | 缓存条目 Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11(1.1 GB)写入 TanStack/router 的 GitHub Actions 缓存,作用域 refs/heads/main——其键名刻意与 release.yml 在下一次 push 到 main 时会查询的键一致 |
| 2026-05-11 11:31 | 攻击者将 PR force-push 回当前 main 的 HEAD(b1c061af),使可见 PR 变为 0 文件「空操作」。PR 在同一分钟内被关闭且分支被删除。被投毒的缓存仍然保留。 |
引爆(发布阶段)
| 时间 | 事件 |
|---|---|
| 2026-05-11 19:15 | Manuel 合并 PR #7369(Shkumbin 的 CSS.supports 修复)→ push 到 main 触发 release.yml。工作流运行 25613093674 于 19:15:44 启动,并失败。 |
| 2026-05-11 19:20:39 | npm 注册表收到 @tanstack/history@1.161.9 及 41 个兄弟包的发布(约 84 个版本跨 42 个包;仅约一半显示在这一秒,其余在第二次运行期间到达)。发布通过 OIDC 可信发布者绑定认证:TanStack/router 的 release.yml@refs/heads/main——但它并非来自工作流定义的「Publish Packages」步骤(该步骤因测试失败被跳过),而是来自测试/清理阶段运行的恶意软件:利用工作流的 id-token: write 权限签发 OIDC 令牌并直接向 registry.npmjs.org 发起 POST |
| 2026-05-11 19:20:47 | 运行 25613093674 结束(状态:失败) |
| 2026-05-11 19:16 | Manuel 合并 PR #7382(jiti tsconfig paths 修复)→ 第二次 push 到 main 再次触发 release.yml |
| 2026-05-11 19:16:22 | 工作流运行 25691781302 启动。同样恢复了被投毒的缓存 |
| 2026-05-11 19:26:14 | npm 注册表收到每个包的第二组版本(如 @tanstack/history@1.161.12 等)。机制相同 |
| 2026-05-11 19:26:20 | 运行 25691781302 结束(状态:失败) |
发现与响应
| 时间 | 事件 |
|---|---|
| 2026-05-11 ~19:50 | 外部研究者 ashishkurmi(StepSecurity)打开 issue #7383,附带恶意 optionalDependencies 指纹与包列表的完整说明(最初列出 14/42 个包) |
| 2026-05-11 ~19:50 | 研究者直接通知 npm 安全团队 |
| 2026-05-11 ~20:00 | Manuel 在 #7383 中确认——事件响应开始 |
| 2026-05-11 ~20:10 | Manuel 移除 GitHub 上其余团队成员的 push 权限,以防单机已被入侵 |
| 2026-05-11 ~20:30 | Tanner 向 security@npmjs.com 发送完整 IOC 列表并请求注册表侧拉取 tarball;同时通过 npm 正式渠道提交恶意软件报告 |
| 2026-05-11 ~21:00 | 对全部 295 个 @tanstack/* 包完成扫描并确认范围:42 个包、84 个版本。Tanner 开始对全部 84 个受影响版本执行 npm 弃用。@tan_stack 与维护者在 Twitter/X、LinkedIn、Bluesky 公开披露 |
| 2026-05-11 21:30 | 调查确认 bundle-size.yml 的 pull_request_target 缓存投毒向量与 zblgg/configuration fork。通过 API 清空所有 TanStack/* GitHub 仓库的缓存条目。合并加固 PR:重构 bundle-size.yml、增加 repository_owner 守卫、将第三方 action 引用钉到 SHA。发布官方 GitHub 安全公告并申请 CVE |
根因
三处漏洞被串联利用。缺一不可;单独任意一项都不足以完成攻击。
1. bundle-size.yml 中的 pull_request_target「Pwn Request」模式
bundle-size.yml 对来自 fork 的 PR 运行 pull_request_target,并在该触发上下文中检出 fork 的 PR 合并引用并执行构建。配置示例如下(YAML;使用 text 围栏以避免 /** 被校验器误判为块注释):
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 # fork 的合并后代码
- uses: TanStack/config/.github/setup@main # 间接触发 actions/cache@v5
- run: pnpm nx run @benchmarks/bundle-size:build # 执行由 fork 控制的代码
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 # fork 的合并后代码
- uses: TanStack/config/.github/setup@main # 间接触发 actions/cache@v5
- run: pnpm nx run @benchmarks/bundle-size:build # 执行由 fork 控制的代码
工作流作者曾尝试做信任拆分(comment-pr job 与 benchmark-pr 分离,YAML 注释写明意图是让 benchmark-pr 保持「不可信、只读权限」)。方向正确,但遗漏了两点:
actions/cache@v5的 job 后保存步骤不受permissions:门控。缓存写入使用 runner 内部令牌,而非工作流GITHUB_TOKEN。即便设置permissions: contents: read也无法阻止缓存被改写。- 缓存作用域按仓库共享,跨
pull_request_target运行(使用基仓库缓存作用域)与对main的 push。在基仓库缓存作用域内运行的 PR,可以投毒后续生产工作流在main上将要恢复的条目。
2. 跨信任边界的 GitHub Actions 缓存投毒
恶意 vite_setup.mjs 被刻意设计成:把数据写入 pnpm-store 目录下、与合法 release.yml 会计算并查找的键一致的路径:Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}。当 benchmark-pr job 结束时,actions/cache@v5 的后置步骤把(已被投毒的)pnpm store 保存到完全相同的键。下一次 release.yml 在 push 到 main 时运行,其「Setup Tools」步骤按设计恢复了被投毒条目。
这类攻击在 2024 年由 Adnan Khan 记录。它不是 TanStack 独有的缺陷,而是 GitHub Actions 的已知设计特性,需要有意识的缓解措施。
3. 从 runner 内存提取 OIDC 令牌
release.yml 声明 id-token: write(为 npm OIDC 可信发布正当所需)。当被投毒的 pnpm store 恢复到 runner 后,攻击者控制的二进制已落盘并在构建步骤被调用。这些二进制会:
- 通过
/proc/*/cmdline定位 GitHub ActionsRunner.Worker进程 - 读取
/proc/<pid>/maps与/proc/<pid>/mem转储 worker 内存 - 提取 OIDC 令牌(当设置
id-token: write时,runner 会惰性在内存中签发) - 使用该令牌向
registry.npmjs.org直接认证 POST 请求——完全绕过工作流中的「Publish Packages」步骤
这与 2025 年 3 月 tj-actions/changed-files 被入侵时使用的内存提取技术(以及逐字复制的 Python 脚本与署名注释)相同。攻击者并未发明新手艺,而是把已公开研究重新组合。
为何缺一不可
- 仅有
pull_request_target:用于可信操作(打标签、评论)时本身没问题 - 仅有缓存投毒:例如从已被攻破的依赖内部发起,还需要单独的发布通道
- 仅有 OIDC 内存提取:前提是 runner 上已存在代码执行
链条成立,是因为每一处漏洞都跨越了其他环节默认不存在的信任边界:fork 的 PR 代码进入基仓库缓存,基仓库缓存进入发布工作流运行时,发布工作流运行时又获得对 npm 注册表的写权限。
检测
我们如何得知
检测来自外部。外部研究者 ashishkurmi(StepSecurity)在发布后约 20 分钟打开 issue #7383,附带完整技术分析。Tanner 在成立应急小组后不久即接到 Socket.dev 的电话,确认情况。
IOC 指纹(供下游维护者与安全工具使用)
在任意 @tanstack/* 包的 manifest 中可能出现如下片段(JSON):
"optionalDependencies": {
"@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}
"optionalDependencies": {
"@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}
- 文件:
router_init.js(约 2.3 MB,位于包根目录,不在"files"列表中) - 缓存键:
Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11 - 二阶段 payload URL:h8nc9u.js、7rrc6l.mjs
- 外泄网络:
filev2.getsession.org、seed{1,2,3}.getsession.org - 伪造提交身份:
claude <claude@users.noreply.github.com>(注意:并非真实的 Anthropic Claude,而是伪造的 GitHub no-reply 邮箱) - 真实攻击者账号:zblgg(id 127806521)、voicproducoes(id 269549300)
- 攻击者 fork:github.com/zblgg/confi…(fork 自 TanStack/router 并改名以规避 fork 检索)
- 孤立 payload 提交(位于 fork 网络):
79ac49eedf774dd4b0cfa308722bc463cfe5885c - 执行恶意发布的工作流运行:
- 工作流运行 25613093674(第 4 次尝试)
- 工作流运行 25691781302
经验教训
做得好的地方
- 外部研究者在事件后约 20 分钟内发现并报告,技术细节完整
- 维护者团队跨多时区立即且有效地协同
- 检测社区在数小时内已形成清晰的公开 IOC 模式
本可以更好的地方
- 没有内部告警。我们是通过第三方得知被入侵。需要对我们自己的发布建立监控。我们将与生态内具备快速检测能力的安全研究公司更紧密合作,甚至可能自建能力,并把反馈回路收得更紧。
pull_request_target工作流长期未审计,尽管该模式早已公认危险- 第三方 action 使用浮动引用(
@v6.0.2、@main)会带来与本事件无关的持续供应链风险 - 由于 npm「存在依赖则不可 unpublish」策略,几乎所有受影响包都无法 unpublish。只能依赖 npm 安全团队在服务器侧拉取 tarball,这会在恶意 tarball 仍可安装的窗口上增加数小时延迟
- npm scope 上的 7 人维护者名单意味着:同一爆炸半径下存在七个独立的凭据窃取目标面
- OIDC 可信发布者绑定没有「每次发布」的人工复核。一旦配置完成,工作流任意代码路径都可能签发具备发布能力的令牌。我们需要么(a)迁移到短生命周期 classic token 并配合人工复核,要么(b)增加来源可追溯性校验,以发现来自非预期工作流步骤的发布
侥幸之处
- 攻击者选择的 payload 破坏了测试,从而使本应产出「更干净」tarball 的发布步骤被跳过——攻击因此足够「吵闹」而被快速发现。若攻击者更谨慎、不破坏测试,则可能在更长时间内静默发布
- 攻击者复用公开战术(逐字复制带署名注释的内存转储脚本)而非自研代码——使 IOC 匹配更快
未决问题
在关闭本事后总结之前,仍需回答:
bundle-size.yml的「Setup Tools」步骤是否确实调用了actions/cache@v5?需阅读针对 PR #7378 的某次pull_request_target运行的 post-job 日志(例如 run id25666610798)核实。Tanner 有权限;需人工完成- 初始 PR 头提交(在 force-push 抹掉之前)里有什么?GitHub 的 reflog 可能仍保留。可通过
gh api或 GitHub 支持团队查询 - 恶意提交是如何进入 fork 的 git 对象库的——是直接
git push,还是通过 GitHub Web UI(后者会在审计日志留痕)? voicproducoes是真实账号还是马甲?需交叉比对其活动历史- npm 缓存是否也被投毒(6 条重复的
linux-npm-store-*条目)?是否有任何条目实际被使用? - 能否识别 TanStack/router fork 网络中其他仍托管孤立 payload 提交的 fork?(若是,清理更难——每个托管它的 fork 都仍可通过
github:tanstack/router#79ac49ee...访问) - 其他 TanStack 仓库(router、query、table、form、virtual 等)是否使用同类
bundle-size.yml模式?需要审计 - 在发布窗口内究竟有多少用户下载了受影响版本?需向 npm 支持团队索取
- 七名列名维护者中是否有人单机另行被入侵?(恶意发布均未使用维护者的 npm token,但维护者机器可能成为自我传播逻辑的次要目标)
参考资料
-
跟踪议题:TanStack/router#7383
-
GitHub 安全公告:GHSA-g7cv-rxg3-hmpx
-
相关研究:
- Adnan Khan,文章《构建缓存里的怪物:GitHub Actions 缓存投毒》(原文标题 The Monsters in Your Build Cache: Github Actions Cache Poisoning,2024 年 5 月)——adnanthekhan.com
- GitHub Security Lab,指南《保护 GitHub Actions 与工作流:防范 pwn request》(原文标题 Keeping your GitHub Actions and workflows secure: Preventing pwn requests)——securitylab.github.com
- StepSecurity,文章《Harden-Runner 对 tj-actions/changed-files 遭入侵的检测》(原文标题 Harden-Runner detection: tj-actions/changed-files action is compromised,2025 年 3 月)——stepsecurity.io
-
npm 政策:
- 下架政策(Unpublish):docs.npmjs.com/policies/un…
- 出处声明(Provenance):docs.npmjs.com/generating-…
附录 A — 受影响版本
受影响版本的完整列表见 GitHub 安全公告:GHSA-g7cv-rxg3-hmpx 在 GitHub 上编辑原文