2026年5月11日19:20 UTC,npm registry开始接收一批异常的包发布——42个@tanstack/*包,84个恶意版本,在6分钟内集中上线。20分钟后,外部安全研究员ashishkurmi在GitHub issue中贴出了完整的技术分析。
这不是一起简单的npm token泄露事件。攻击者没有窃取任何npm凭据,没有入侵维护者的机器,甚至没有触碰发布工作流本身。他们用了三把钥匙,每把单独都打不开门,但三把组合在一起,门就开了。
TanStack事件是2026年供应链攻击精密化趋势的缩影。本文将从前置知识出发,建立供应链安全的认知框架,然后深入复盘TanStack事件的技术细节,最后构建CI/CD管道的防御体系。
一、前置知识:软件供应链与信任模型
1.1 什么是软件供应链
软件供应链(Software Supply Chain)是指从源代码到最终运行产物的完整路径——每一个环节都可能被攻击者利用:
传统安全关注的是运行时(F)的防护——WAF、EDR、微隔离。供应链安全关注的是到达运行时之前的路径(A→E)是否可信。如果构建产物在到达部署环境之前就已经被植入后门,运行时的所有防护都将失效。
1.2 npm生态的信任模型
npm生态的信任模型建立在几个隐含假设上:
| 信任假设 | 含义 | 攻击面 |
|---|---|---|
| 包名即身份 | @tanstack/react-query就是官方包 | Typosquatting:注册@tanstack/react-qurey |
| 维护者可信 | 有发布权限的人不会发布恶意代码 | 账号接管:钓鱼/凭据窃取 |
| registry可信 | npmjs.org上的包就是源码对应的包 | 中间人/registry劫持 |
| CI/CD可信 | GitHub Actions产出的包就是预期产物 | Cache投毒/Action投毒/OIDC提取 |
供应链攻击的本质,就是打破这些隐含假设。
1.3 GitHub Actions CI/CD基础
理解TanStack事件需要了解GitHub Actions的几个关键概念:
pull_request触发器:在Fork的PR中运行,使用Fork的代码和Fork的CI配置,没有Base仓库的写权限——安全pull_request_target触发器:在Fork的PR中运行,但使用Base仓库的CI配置和密钥——危险,因为Fork的代码在Base仓库的信任上下文中执行GITHUB_TOKEN:临时token,受permissions:约束,PR触发的workflow默认只有read权限- OIDC token:通过
id-token: write权限获取的临时身份令牌,用于npm trusted publishing actions/cache:CI缓存,scope为per-repository,post-job save不受permissions:约束
1.4 当前态势:供应链攻击演进
供应链攻击在过去三年经历了从”包级别”到”CI/CD级别”再到”生态级别”的演进:
关键趋势:攻击者不再满足于单个漏洞的利用,而是将多个已知漏洞串联成链式攻击,每个漏洞单独都不足以完成攻击,但组合后可以跨越多道信任边界。
二、TanStack事件复盘:三链攻击
2.1 攻击全景
攻击者将三个已知漏洞串联,每个单独都不足以完成攻击,但三者组合跨越了三道信任边界:
| 漏洞 | 跨越的信任边界 | 单独是否可利用 |
|---|---|---|
pull_request_target Pwn Request | Fork代码 → Base仓库CI | 否(仅能执行代码) |
| GitHub Actions Cache投毒 | PR的CI → Main的发布CI | 否(需要先有代码执行) |
| OIDC令牌内存提取 | 发布CI运行时 → npm registry写权限 | 否(需要先在Runner上执行代码) |
2.2 第一链:Pwn Request
bundle-size.yml使用了pull_request_target触发器,对Fork的PR自动运行,并在该上下文中checkout了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 - uses: TanStack/config/.github/setup@main - run: pnpm nx run @benchmarks/bundle-size:build攻击者创建了一个Fork(zblgg/configuration,故意重命名以规避Fork列表搜索),提交了包含3万行捆绑JS载荷的恶意commit,然后以[skip ci]前缀压制CI,再通过PR将恶意代码引入pull_request_target的执行上下文。
关键问题:pull_request_target绕过了首次贡献者审批门控。PR的pull_request触发的工作流被正确阻止了,但pull_request_target的工作流直接放行。
2.3 第二链:Cache投毒
恶意代码vite_setup.mjs被精心设计,向pnpm store目录写入数据,使用的key恰好匹配正式release.yml工作流会查找的key:
Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}当benchmark-pr任务结束,actions/cache@v5的post-step将已被投毒的pnpm store保存到该key下。当release.yml下次在main分支上运行时,其Setup Tools步骤恢复了被投毒的缓存条目。
这是GitHub Actions的已知设计问题,由Adnan Khan在2024年5月公开文档化。核心矛盾:
actions/cache@v5的post-job save不受permissions:约束——Cache写入使用Runner内部token,不是GITHUB_TOKEN- 设置
permissions: contents: read不会阻止Cache写入 - Cache scope是per-repo的,
pull_request_target运行和main上的push共享同一Cache scope
2.4 第三链:OIDC令牌内存提取
release.yml声明了id-token: write(合法的npm OIDC可信发布所需)。当被投毒的pnpm store恢复到Runner后,攻击者控制的二进制文件被调用:
# 攻击流程(简化)# 1. 定位GitHub Actions Runner.Worker进程runner_pid = find_process("/proc/*/cmdline", "Runner.Worker")
# 2. 读取进程内存映射maps = read_file(f"/proc/{runner_pid}/maps")mem = read_file(f"/proc/{runner_pid}/mem")
# 3. 从内存中提取OIDC令牌oidc_token = extract_oidc_token(maps, mem)
# 4. 直接POST到npm registryrequests.post("https://registry.npmjs.org/-/npm/v1/tarball", headers={"Authorization": f"Bearer {oidc_token}"})这绕过了工作流中定义的”Publish Packages”步骤——恶意代码在测试/清理阶段就铸造了OIDC令牌并直接发布。
这是与2025年3月tj-actions/changed-files妥协事件完全相同的内存提取技术,攻击者甚至使用了原文的Python脚本(保留了归属注释)。
2.5 恶意载荷行为
当开发者或CI环境对受影响版本执行npm install时:
外泄通道值得注意:使用Session/Oxen Messenger的文件上传网络(filev2.getsession.org),端到端加密,没有攻击者控制的C2——因此只能通过IP/域名阻断,无法通过内容检测拦截。
自传播机制:通过registry.npmjs.org/-/v1/search?text=maintainer:<user>枚举受害者维护的其他包,然后重新发布注入版本。这意味着一个受感染的CI环境可以污染整个npm生态中该维护者的所有包。
2.6 时间线
| 时间 (UTC) | 事件 |
|---|---|
| 5月10日 17:16 | 攻击者创建Fork zblgg/configuration |
| 5月10日 23:29 | 恶意commit 65bf499d写入Fork,作者伪造为claude <claude@users.noreply.github.com> |
| 5月11日 10:49 | PR #7378开启,pull_request_target工作流自动运行 |
| 5月11日 11:11 | Force-push将恶意commit推到PR head,bundle-size.yml执行恶意代码 |
| 5月11日 11:29 | 被投毒的Cache条目(1.1GB)保存到refs/heads/main scope |
| 5月11日 11:31 | 攻击者force-push回当前main HEAD,PR变为0文件no-op,关闭并删除分支。Cache投毒持续存在 |
| 5月11日 19:15 | 合法PR合并触发release.yml,恢复被投毒的Cache |
| 5月11日 19:20 | npm registry接收首批恶意发布(42包,~42版本) |
| 5月11日 19:26 | npm registry接收第二批恶意发布(~42版本) |
| 5月11日 ~19:50 | 外部研究员ashishkurmi (StepSecurity) 开issue报告,完整技术分析 |
三、CI/CD攻击面深度分析
3.1 六大攻击手法对比
| 攻击手法 | 原理 | 典型案例 | 检测难度 | 防御复杂度 |
|---|---|---|---|---|
| Typosquatting | 注册与流行包名相似的恶意包 | crossenv冒充cross-env | 低 | 低 |
| 依赖混淆 | 利用npm解析优先级植入内部同名包 | 2021年Alex Birsan研究 | 中 | 中 |
| 维护者账号接管 | 钓鱼/凭据窃取获取npm发布权限 | 2022年ua-parser-js | 中 | 高 |
| Action投毒 | 入侵热门GitHub Action注入恶意代码 | 2025年tj-actions/changed-files | 高 | 高 |
| Cache投毒 | 跨信任边界污染CI缓存 | 2026年TanStack事件 | 极高 | 极高 |
| OIDC内存提取 | 从Runner进程内存读取临时令牌 | 2025-2026年多次复现 | 极高 | 极高 |
3.2 为什么Cache投毒特别危险
GitHub Actions的Cache设计存在一个根本性的信任边界问题:
- Cache scope是per-repository的,不是per-workflow或per-trigger的
pull_request_target运行使用base仓库的Cache scopeactions/cache的post-job save不受permissions:约束- 这意味着Fork PR的CI可以向Main分支发布CI使用的Cache写入任意数据
这不是TanStack特有的问题——任何使用pull_request_target + actions/cache的仓库都面临相同风险。
3.3 信任链视角
从信任链的角度看,供应链攻击的本质是信任的传递与滥用:
每一步都是信任的传递,每一步都假设上一步是干净的。攻击者的策略是找到信任链中最弱的一环,然后沿着链向下渗透。
四、防御体系
4.1 GitHub Actions加固
# GitHub Actions安全加固清单github_actions_hardening: pull_request_target: - 规则: 绝不在pull_request_target中checkout Fork代码 - 规则: 绝不在pull_request_target中运行Fork控制的构建命令 - 规则: 如必须使用,仅用于只读操作(标签、评论) - 替代: 使用pull_request触发 + 首次贡献者审批
cache: - 规则: 不在pull_request_target工作流中使用actions/cache - 规则: 如必须使用,设置cache-key前缀隔离PR和Main的Cache - 规则: 定期审查和清除Cache条目 - 替代: 使用actions/upload-artifact + download-artifact(scope为workflow run)
oidc: - 规则: 限制id-token: write仅用于实际发布步骤 - 规则: 将发布步骤与测试步骤分离为独立job - 规则: 考虑添加provenance-source-verification检测异常发布路径 - 替代: 短期classic token + 手动审批
actions: - 规则: 所有第三方Action引用固定到SHA而非tag - 规则: 定期审计所有使用的第三方Action - 规则: 使用StepSecurity的harden-runner Action限制网络和文件系统访问4.2 npm生态防护
npm_ecosystem_defense: 安装时: - 使用npm audit持续扫描已知漏洞 - 部署Socket.dev或Phylum等供应链安全工具 - 在CI中添加package-lock.json完整性校验 - 监控optionalDependencies中的异常github:引用
发布时: - 使用npm OIDC trusted publishing替代长期token - 配置npm provenance签名 - 限制scope的maintainer列表(减少凭据暴露面) - 发布后自动验证tarball内容与预期一致
响应时: - 建立npm publish实时监控和告警 - 准备应急deprecation脚本 - 与npm安全团队建立快速沟通渠道 - 受影响后立即轮换所有可达凭据4.3 凭据轮换清单
任何在2026-05-11安装了受影响TanStack版本的主机,应视为可能被入侵,需轮换:
credential_rotation: 云平台: - AWS: 所有AccessKey + IMDS可达的Secrets Manager值 - GCP: 所有Service Account Key + metadata可达的凭据 - Kubernetes: 所有ServiceAccount Token - Vault: 所有Token和动态凭据
开发平台: - GitHub: Personal Access Token + SSH Key - npm: 所有发布token - Git: .git-credentials中存储的所有凭据
基础设施: - SSH: ~/.ssh/下所有私钥 - CI/CD: 所有CI环境变量中的密钥4.4 “信任中断”原则
纵深防御的本质不是在每一层都加锁,而是在信任链中插入不信任的断点:
| 信任断点 | 实现方式 |
|---|---|
| Cache恢复后校验内容hash | 不盲目信任缓存数据 |
| OIDC令牌铸造时验证调用来源 | 检测是否从预期workflow step铸造 |
| npm install后验证tarball | 比对provenance签名与预期内容 |
CI运行时监控/proc/*/mem访问 | 检测异常的进程内存读取行为 |
| 发布后验证产物完整性 | 自动比对发布产物与构建日志 |
五、我的思考
5.1 供应链安全是云安全的阿喀琉斯之踵
云安全的所有技术——零信任、微隔离、EDR、CNAPP——都建立在一个隐含假设上:你部署的代码是你以为的代码。当这个假设被打破,所有上层防护都是空中楼阁。TanStack事件表明,攻击者不需要突破你的云安全体系,只需要在你信任的包里植入一行代码。
5.2 “信任传递”是核心问题
从Fork代码到Base仓库Cache,从Cache到发布Runner,从Runner到npm registry——每一步都是信任的传递,每一步都假设上一步是干净的。攻击者的策略永远是找到信任链中最弱的一环,然后沿着链向下渗透。
5.3 防御者需要”信任中断”
纵深防御的本质不是在每一层都加锁,而是在信任链中插入不信任的断点——Cache恢复后校验hash、OIDC铸造时验证来源、npm install后验证签名。每一个断点都是对”上一步是干净的”这个假设的挑战。
5.4 开源生态的系统性风险
TanStack事件暴露了npm生态的几个结构性问题:
- npm的unpublish政策:如果包有依赖者就不能unpublish,导致恶意tarball只能等待npm安全团队服务端移除,增加了数小时的暴露窗口
- OIDC trusted publishing的盲区:一旦配置,workflow中任何代码路径都可以铸造发布令牌,没有per-publish review
- 维护者凭据的爆炸半径:7个maintainer的npm scope意味着7个独立的凭据窃取目标,但影响范围相同
这些问题不是TanStack能独自解决的,需要npm/GitHub生态层面的系统性改进。
六、总结
TanStack npm供应链投毒事件是2026年云安全领域的一个标志性事件——不是因为它的规模(42个包、84个版本),而是因为它的精密程度。攻击者没有使用任何novel技术,而是将三个已知漏洞串联成一个完整的攻击链,每个漏洞单独都不足以完成攻击。
这揭示了一个更深层的问题:云安全的威胁正在从”单点突破”演变为”链式攻击”。防御者需要从”加固每一层”转向”中断信任链”。
2026年的供应链安全需要三个转变:
- 从静态扫描到行为监控——不仅检查包内容,还监控CI运行时行为(异常内存访问、异常网络请求)
- 从信任传递到信任中断——在每个信任边界插入验证断点
- 从单点防御到纵深防御——GitHub Actions加固 + npm生态防护 + 运行时检测,缺一不可
供应链安全不是可选的加固项,而是云安全的基石。当基石松动,上层建筑再精密也无法立足。
事件来源:Postmortem: TanStack npm supply-chain compromise — Tanner Linsley, 2026-05-11. GitHub Security Advisory: GHSA-g7cv-rxg3-hmpx.
参考
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






