【翻译】事后复盘:TanStack npm 供应链被入侵

0 阅读15分钟

事后复盘:TanStack npm 供应链被入侵

文章头图

处理记录

  • 抽取: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 installpnpm installyarn install 时,npm 会解析恶意的 optionalDependencies 条目,从 fork 网络拉取孤立的 payload 提交,运行其 prepare 生命周期脚本,并执行被打包进受影响 tarball、体积约 2.3 MB 的混淆 router_init.js。该脚本会:

  • 从常见位置收割凭据:AWS IMDS / Secrets Manager、GCP 元数据、Kubernetes ServiceAccount 令牌、Vault 令牌、~/.npmrc、GitHub 令牌(环境变量、gh CLI、.git-credentials)、SSH 私钥
  • 通过 Session/Oxen 信使的文件上传网络外泄(filev2.getsession.orgseed{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:49PR #7378 对 TanStack/router#main 打开,标题为「WIP: simplify history build」,作者 zblgg
2026-05-11 10:49 起bundle-size.ymllabeler.yml(均使用 pull_request_target)对该 PR 自动运行——需要首次贡献者审批,因为 pull_request_target 会绕过该门槛。使用 pull_requestpr.yml 不会运行,因等待审批而一直被阻塞
2026-05-11 11:01–11:11zblgg 多次 force-push PR 头分支,每次都会触发更多 pull_request_target 运行
2026-05-11 11:11force-push 将恶意提交 65bf499d 落到 PR 头。bundle-size.ymlbenchmark-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:15Manuel 合并 PR #7369(Shkumbin 的 CSS.supports 修复)→ push 到 main 触发 release.yml。工作流运行 25613093674 于 19:15:44 启动,并失败
2026-05-11 19:20:39npm 注册表收到 @tanstack/history@1.161.9 及 41 个兄弟包的发布(约 84 个版本跨 42 个包;仅约一半显示在这一秒,其余在第二次运行期间到达)。发布通过 OIDC 可信发布者绑定认证:TanStack/routerrelease.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:16Manuel 合并 PR #7382(jiti tsconfig paths 修复)→ 第二次 push 到 main 再次触发 release.yml
2026-05-11 19:16:22工作流运行 25691781302 启动。同样恢复了被投毒的缓存
2026-05-11 19:26:14npm 注册表收到每个包的第二组版本(如 @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:00Manuel 在 #7383 中确认——事件响应开始
2026-05-11 ~20:10Manuel 移除 GitHub 上其余团队成员的 push 权限,以防单机已被入侵
2026-05-11 ~20:30Tanner 向 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.ymlpull_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 Actions Runner.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.js7rrc6l.mjs
  • 外泄网络:filev2.getsession.orgseed{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
  • 执行恶意发布的工作流运行:

经验教训

做得好的地方

  • 外部研究者在事件后约 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 id 25666610798)核实。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 政策:


附录 A — 受影响版本

受影响版本的完整列表见 GitHub 安全公告:GHSA-g7cv-rxg3-hmpx 在 GitHub 上编辑原文