导语:5月11日 UTC 19:20,npm 仓库凭空多出 84 个恶意版本,涵盖 42 个 @tanstack/* 包——而且全部是通过 TanStack(栈式全家桶)自己的 CI/CD 管道合法签名的。这不是简单的凭据被盗,而是一条精妙的攻击链:Pwn Request(权限获取请求)→ 缓存投毒 → OIDC(开放身份验证)凭据内存提取 → 直接发布。攻击者在合法流水线眼皮底下完成了这一切,Socket(插座安全)在 6 分钟内标记了所有恶意版本,但 npm 的未发布限制政策让清除工作被拖延了数小时。
事件概览
北京时间 5 月 12 日凌晨,Socket(插座安全)官方账号率先披露:84 个恶意 npm 包版本已发布,攻击者将其命名为 “Mini Shai-Hulud(沙丘之蝎迷你版)”,与 2025 年秋季以来的多波 Shai-Hulud(沙丘之蝎)系列供应链攻击同属一个系列,此次攻击归因于威胁组织 TeamPCP(PCP团队)。
TanStack(栈式全家桶)官方于事发后紧急发布复盘报告,确认受影响版本中每一个都通过了有效的 SLSA(软件供应链级别)构建认证签名——这是首例携带有有效 SLSA Level 3(SLSA三级签名)签名的恶意 npm 包的公开记录。攻击者伪造的不是签名,而是整个构建过程本身。
关键数字:
-
42 个包,84 个恶意版本(每个包 2 个,间隔约 6 分钟分两批发布) -
169+ 个包 受波及(含 @mistralai、@uipath、@squawk 等) -
12.7 万次/周 下载量的 @tanstack/react-router 首当其冲 -
20 分钟内 外部研究员 carlini(卡里尼)发现并提交完整技术报告 -
6 分钟 Socket(插座安全)完成所有恶意版本标记
攻击时间线:一条精密的 CI/CD 劫持路线
攻击者没有从外部窃取凭据。他们利用的是 CI/CD 管道本身的三个信任漏洞,将三条攻击向量串联成一条完整链路。
第一步:准备阶段(5月10日 ~5月11日上午)
攻击者在 GitHub(盖ithub)上注册账户 zblgg(ID 127806521),将 TanStack/router 仓库 fork 并故意重命名为 zblgg/configuration 以规避 fork 列表搜索。随后提交一个伪造身份的 commit,冒充 “claude@users.noreply.github.com”(Anthropic(anthro pik) Claude 的 GitHub 无回复地址),注入约 3 万行的捆绑 JS 载荷,并标记 [skip ci] 以躲避触发 CI。
5月11日 10:49 UTC,攻击者以此 commit 为基础向 TanStack/router#main 提交 PR #7378,标题为 “WIP: simplify history build”。这个看似正常的 PR,恰好触发了 bundle-size.yml 的 pull_request_target 工作流——该工作流直接检出并执行了来自 fork 的代码。
这就是业界熟知的 “Pwn Request(权限获取请求)” 模式,安全研究员 Adnan Khan(阿德南·汗)早在 2024 年 5 月就已公开发布相关研究报告。
第二步:缓存投毒(5月11日 11:01-11:29 UTC)
fork 代码执行时,它没有立即窃取数据,而是做了更隐蔽的事:将恶意 pnpm store 写入缓存,缓存键精确匹配 production release.yml 的计算逻辑(Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')})。
缓存于 11:29 UTC 成功写入 1.1 GB 的投毒条目,并持续潜伏了将近 8 小时。
bundle-size.yml 的作者尝试做了权限分离——将 benchmark 作业注释为”仅读权限”——但这道防线形同虚设:actions/cache@v5 的缓存写入不依赖 GITHUB_TOKEN,而是使用 runner 内部 token,permissions: contents: read 的配置完全不起作用。
第三步:发布恶意版本(5月11日 19:15-19:26 UTC)
19:15 UTC,一次合法的 PR 合并触发了 release.yml。缓存恢复后,攻击者控制的可执行文件在构建阶段被调用,从 GitHub(盖ithub)Actions runner 进程内存中提取 OIDC(开放身份验证)token,直接向 npm registry 发起发布请求,绕过了工作流中本应执行的发布审查步骤。
整个过程在 6 分钟内完成了两批次的 84 个包发布,两批均通过合法 OIDC(开放身份验证)身份签名,附带有效 SLSA(软件供应链级别)证明。而 release.yml 报告的状态均为 “failure”——发布是通过恶意代码旁路完成的,根本不在正常发布步骤内。
受影响包全貌
@tanstack 生态(42 包 84 版本)
首当其冲的是 @tanstack/react-router(每周下载量 1270 万+)。受影响版本包括 1.169.5/1.169.8(Router 系列)和 1.167.68/1.167.71(Start 系列)等。
核心路由包全系中招:@tanstack/vue-router、@tanstack/solid-router、@tanstack/router-core、@tanstack/router-plugin、@tanstack/react-start、@tanstack/solid-start、@tanstack/vue-start 等。但确认安全的包也不少:@tanstack/query*、@tanstack/table*、@tanstack/form*、@tanstack/virtual*、@tanstack/store。
次生受害者(自传播机制)
攻击载荷内置了自传播逻辑:枚举受害者 npm scope 内的所有包,用相同的 OIDC(开放身份验证)凭据重新发布被注入的版本。数小时内,攻击波蔓延到:
-
@mistralai:Mistral AI(迷雾 AI)官方 TypeScript 客户端,版本 2.2.2-2.2.4 受影响 -
@uipath:UiPath(优路径)自动化平台 40+ 个 npm 包 -
@squawk:19 个航空数据包 -
其他零散包:safe-action、cmux-agent-mcp、nextmove-mcp 等
截至发稿,至少 170+ 个 npm 包已在各安全厂商数据库中被标记。
恶意载荷深度剖析:router_init.js
每个受损 tarball 的根目录都被悄悄植入了约 2.3 MB 的混淆 JavaScript 文件 router_init.js——它不在 package.json 的 files 字段中,这意味着这个文件是构建过程之外被注入的,任何 tarball 完整性校验都能立刻发现异常。
三层混淆
payload 采用三层保护,Upwind Security(劲风安全)逆向后还原为 221,771 行可读代码:
Layer 1:标准 JavaScript 混淆器——字符串数组轮转自调用 + _0x253b 分发函数被调用 2864 次。所有字符串字面量替换为数组查找。
Layer 2:逐字节 Fisher-Yates(费舍尔-耶茨)替换密码,主密钥硬编码,通过 PBKDF2-SHA256(20 万次迭代)派生。解密后暴露出 C2 域名、凭据路径、内部分区名 “EveryBoiWeBuildIsAWormyBoi”。
Layer 3:11 个 AES-256-GCM 加密载荷,gzip 压缩,需 Bun 运行时解密。Unit 42(42小队)确认相同的 PRNG 种子出现在 SAP、Bitwarden 和本次 TanStack(栈式全家桶)攻击中,证明三波攻击来自同一代码库。
凭据收割清单
payload 启动后首先完成 守护进程化——通过 fork+unref 方式完全脱离终端会话,静默后台运行。然后系统性扫描以下凭据来源:
-
AWS(亚马逊云):IMDSv2 元数据服务、Secrets Manager -
GCP(谷歌云):元数据端点 -
Azure(微软云):服务主体凭据 -
Kubernetes(K8s):Service Account Token -
HashiCorp Vault(哈希公司保险库):本地 token 缓存 -
GitHub(盖ithub):环境变量、gh CLI、 .git-credentials -
npm: ~/.npmrc中的 auth token -
SSH: ~/.ssh/私钥
三路 C2 泄露通道
-
Typosquat 域名: git-tanstack.com——故意拼写与 tanstack.com 接近 -
Session P2P 网络:通过 *.getsession.org加密传输,端对端加密,无法通过 IP/域名封堵 -
GitHub(盖ithub)API 暗桩:用窃取的 token 创建带有 “Shai-Hulud: Here We Go Again” 描述的石器主题 repo
持久化与破坏机制
发现有效的 ghp_/gho_ GitHub token 后,payload 会安装一个 gh-token-monitor 守护进程(macOS LaunchAgent / Linux systemd),每 60 秒轮询一次 GitHub。一旦 token 被吊销,守护进程触发 rm -rf ~/——在删除前 24 小时自动退出。这是典型的** retaliatory wiper** 策略。
值得注意的是:发现俄语区域设置会立即退出,不泄露数据——攻击者对特定地域做了精确规避。
开发者自查指南
第一关:排查安装记录
检查 package-lock.json、pnpm-lock.yaml、yarn.lock 中是否包含以下版本:
@tanstack/*: 1.161.9, 1.161.12, 1.166.x, 1.167.x, 1.168.x, 1.169.x
@mistralai/mistralai: 2.2.2, 2.2.3, 2.2.4
最简单的全局扫描命令:
# npm
npm ls @tanstack/react-router @tanstack/router-core @tanstack/history
# pnpm
pnpm list @tanstack/*
# yarn
yarn list @tanstack/*
第二关:查找恶意文件
# 检查项目中是否存在 router_init.js
find . -name "router_init.js" 2>/dev/null
# 检查 node_modules 中的可疑 setup.mjs
find . -path "*/node_modules/*/setup.mjs" 2>/dev/null
第三关:排查持久化守护
# Linux 检查 systemd
ls ~/.config/systemd/user/gh-token-monitor.service 2>/dev/null
# macOS 检查 LaunchAgent
ls ~/Library/LaunchAgents/com.user.gh-token-monitor.plist 2>/dev/null
第四关:轮换凭据
如果曾在 2026-05-11 安装过受影响版本,立即轮换以下凭据:
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
第五关:封堵 C2
在防火墙/代理层封堵以下域名和 IP:
git-tanstack.com
*.getsession.org
83.142.209.194
复盘与教训
这场攻防的讽刺之处
整场攻击中,npm 仓库从未被攻破。npm 的凭据从未被盗。攻击者使用的 OIDC(开放身份验证)token 是 TanStack(栈式全家桶)自己合法发布的,SLSA(软件供应链级别)签名的验证也全部通过。这不是凭据被盗,而是信任链被拆解后逐段利用。
六个值得记录的问题
一、内部告警缺失。 TanStack(栈式全家桶)在复盘报告中承认:”我们从第三方那里才知道被入侵,没有内部告警机制。” 这是大多数企业供应链安全的盲区——监控自己的发布行为本身,比监控外部威胁情报更难落地。
二、pull_request_target 未经审计。 TanStack(栈式全家桶)的 bundle-size.yml 使用了 pull_request_target 但从未被审计,这是 2024 年以来公开研究的已知高危模式,仍然大量存在于开源项目中。
三、第三方 Action 浮点引用。 使用 @v6.0.2 或 @main 引用第三方 Action,意味着任何上游修改都会自动进入你的流水线。唯一解法:锁定到 commit SHA。
四、npm 不可取消发布政策。 npm “存在依赖包时禁止取消发布” 的政策让清除工作了 10 小时以上,恶意 tarball 在这段时间内依然可安装。
五、OIDC 可信发布无法按发布来源审核。 OIDC(开放身份验证)凭据一旦绑定,工作流内任何代码路径都可以发布。TanStack(栈式全家桶)选择添加来源验证或回退短生命周期经典 token 加人工审核。
六、SLSA 证明的盲点。 SLSA(软件供应链级别)证明验证的是”构建过程本身”,不验证”构建的代码是否安全”——这在 Shai-Hulud(沙丘之蝎)攻击中首次被实战证明。有了合法 SLSA 证明的恶意包在 npm registry 上 indistinguishable(无法区分)于正常包。
84 个包,两批发布,六分钟。TanStack(栈式全家桶)的复盘报告写得很诚实:他们从第三方那里学到了这个教训。但代价是所有开发者的机器都成了潜在的攻击目标——至少在那 20 分钟内,任何 npm install @tanstack/react-router@1.169.5 的人都在下载一个经过合法签名的恶意包。供应链攻击从来不是技术问题,而是信任假设出了问题。
版权声明:本文由华盟网原创发布,保留所有权利。
👇 点击阅读原文,访问我的网站

















暂无评论内容