8 月 4 日上午,无数条 CI 流水线照常在跑 npm install。装一个缓存库、跑测试、出包,几分钟完事,没人会多看日志一眼。当天下午,安全公司 Aikido 公布:这批安装里有恶意版本,代号 Shai-Hulud(沙虫),截至当天 13:37 已感染 434 个包、1381 个版本,合计月安装量超过 20 亿次。
keyv 是这场风暴的中心:一个 key-value 存储抽象层,周下载量约 1.27 亿次,支持 Redis、SQLite、Postgres 等后端。它极少被直接安装,大多藏在依赖树深处,常见链路是 eslint → file-entry-cache → flat-cache → keyv。多数受影响的项目,开发者根本不知道这个包存在。
一次 push 引发的全家桶沦陷
攻击的起点是一把钥匙:keyv 维护者账号(Jaredwray)的 GitHub 凭证。8 月 4 日,攻击者登录账号,把恶意文件直接推上 main 分支,随即发版。keyv 6.0.0、flat-cache 6.1.24、file-entry-cache 11.1.6、cacheable-request 13.0.20、cacheable 2.5.1、cache-manager 7.2.10 等一批缓存库先后中招,每个都带着由 GitHub Actions 签名的合法 provenance 发布。
投毒方式很克制。每个包只多了两个文件 setup.mjs 和 Math_Symbol.js,package.json 里加了一行 "preinstall": "node setup.mjs"。任何人执行 npm install,这段脚本都会在安装完成前自动跑起来。对比 SHA-256 会发现,包的 dist 产物与干净的 6.0.0-rc.1 构建逐字节一致——库代码本身没被动,被改的是安装流程。
图:Socket 的 AI 扫描器在发布约 6 分钟后将 [email protected] 标记为已知恶意软件,告警指向 Math_Symbol.js。来源:socket.dev 博客
先借 Bun 跑起来
setup.mjs 是个轻量下载器:从 GitHub 官方 oven-sh/bun 发布页拉取 Bun 1.3.13 运行时(无校验和),再用 Bun 执行真正的 payload。绕这一圈有实际作用——大量安全监控只盯着 Node 进程,Bun 是监控盲区。下载器还会清理临时目录,尽量不留磁盘痕迹。
真正的 payload Math_Symbol.js 约 728 KB,字符串用多态 basE91 编码混淆,静态分析需要逐个破解字母表。它的目标清单相当完整:读 npm token、抓 GitHub token、扫 AWS 凭证、枚举 Kubernetes secrets、撬 HashiCorp Vault、找 Stripe 和 Slack 的 key,外加按约 200 种文件模式全盘扫描 .env、私钥、SSH 配置、Terraform state、KeePass 数据库。
窃取器的实现很有针对性。GitHub Actions runner 上,它会直接读 runner 进程内存,把整个 secret store 倒出来,再顺走用于 npm 发布的 OIDC token;AWS 侧先试 IMDSv2,失败再退回 IMDSv1,还会跨区域枚举 Secrets Manager;Kubernetes 环境里则用挂载的 service account token 直接调 API,把命名空间里的 secrets 全部拉走。扫描器还配了正则引擎,识别 PEM 私钥、Azure 存储密钥、带内嵌密码的数据库连接串。
偷来的钥匙会自我复制
拿到密钥之后,蠕虫开始借壳繁殖。用偷来的 npm token,payload 列出该账号有发布权的所有包,下载 tarball、补丁版本号 +1、注入 setup.mjs 和 math_init.js,重新发布。二次感染的包用 math_init.js 命名,与初代 Math_Symbol.js 区分。到当天下午,社区扩散已感染 400 多个包,波及 @picsart/ai-sdk、@qlik/embed-runtime、@deliveroo/reevent、picasso.js 等。
GitHub 侧的传播路径更隐蔽。拿到 ghs_ token 后,payload 向仓库的每个分支提交(最多 50 个,跳过 dependabot 和 copilot 分支),往 .claude/settings.json 和 .vscode/tasks.json 里埋钩子。开发者用 VS Code 或 Claude Code 打开仓库就会触发,完全绕开 npm install。提交伪装成 claude 账号,commit message 是普通的「chore: update config」。
外传的数据先加密,只有攻击者持有的私钥能解开。落点是公开 GitHub 仓库,描述统一写成「Shai-Hulud: Here We Go Again」,当时已发现约 1300 个;GitHub 上传失败时走备用域名,域名地址由以太坊合约动态下发,基础设施随时可换。Shai-Hulud 是《沙丘》里弗雷曼人对沙虫的称呼,worm 一词在英文里兼有蠕虫之意,代号起得很贴切。
图:GitHub 上搜索「Shai-Hulud」返回约 3000 个仓库,描述写着「Shai-Hulud: Here We Go Again」的仓库就是受害者加密凭证的落点。来源:Aikido Security 博客
时间线:6 分钟的窗口
npm 的发布时间戳拼出了完整过程。09:30–09:32,@keyv/* 系列先发 6.0.0;09:35,[email protected] 成为首个携带恶意钩子的版本;09:38,第一个家族外的包 @thiennq/docs-viewer 被感染;09:39 前后,攻击者还在对源码仓库强制 push、删除 v6.0.0 tag,实时清理痕迹。10:09 到 10:14,cacheable 家族 9 个包集中爆发。
Socket 的扫描器在 [email protected] 发布约 6 分钟后标记了它,整个战役的平均检测时间是 5 分 18 秒。检测速度不慢,但蠕虫的扩散更快。npm 官方随后将 [email protected] 标记为 Known malware。
截至发稿,战役仍在扩散。Aikido 在 13:37 的更新统计为 434 个包,稍后 HN 讨论中引用的同一更新已变成 868 个包——受感染清单本身就在实时增长。
图:npm 官方页面将 [email protected] 标记为已知恶意软件。来源:socket.dev 博客
「带着合法签名的版本怎么会有毒」是这次事件里被问得最多的问题。provenance 证明的是「发布流程被正确执行」,而这次发布流程构建的源码本身就已被投毒——签名照常有效,内容照常有毒。攻击者还利用 npm 的 OIDC trusted publishing 现场铸造全新签名,二次感染的包同样带着新鲜的 sigstore 证明。
蠕虫还留了死亡开关:窃取的 GitHub token 一旦被撤销(HTTP 4xx),本机常驻的 watcher 会执行攻击者预先下发的一段命令,然后自毁退出;24 小时 TTL 一到也会自动清理。撤销凭证这个动作本身,就成了远程代码执行的触发器。macOS 上它注册 LaunchAgent,Linux 上挂 systemd 用户服务,登出后照样存活。
自查与防线
先确认是否中招:在 node_modules 里搜 setup.mjs、Math_Symbol.js、math_init.js 三个文件名,fd -HI "^(setup\.mjs|Math_Symbol\.js|math_init\.js)$" 一条命令即可。中招的话,npm token、GitHub token、云厂商密钥、Vault 凭证、CI secrets 全部轮换,并检查 GitHub 上描述含「Shai-Hulud: Here We Go Again」的仓库。
防线层面,HN 讨论里共识度较高的有几条:给版本加冷却期(.npmrc 里 min-release-age=5,拒绝 5 天内的新版本);发布必须 MFA;pnpm 默认拦截 install 脚本,值得推广;开发环境尽量隔离,别让 npm install 直接摸到整台机器的凭证。GitHub 在 7 月 28 日刚宣布 npm 发布时恶意扫描,这类事件大概还会反复。
一个维护者账号的失守,把 20 亿月安装量背后的密钥都送了出去。供应链安全的信任链,每一环都押在「发版的人没有被劫持」这个假设上。沙虫爬过之后,剩下的工作只有一件:把假设改成机制。
参考链接: Aikido Security 博客:Keyv and friends compromised in active Shai-Hulud supply chain attack Socket Research 博客:Popular npm Packages in the keyv and Cacheable Namespaces Compromised in Active Supply Chain Attack Snyk 博客:Inside the keyv npm Supply Chain Compromise The Hacker News:Keyv-Linked npm Worm Poisons Hundreds of Packages HN 讨论 (49166874)