npm 蠕虫污染 2000+ 个包版本:深度技术剖析
2026 年 8 月 4 日究竟发生了什么,以及每位开发者必须知道的事
全景概览
2026 年 8 月 4 日,攻击者入侵了 Jared Wray 的 GitHub 账号, 他是广泛使用的 keyv 包的维护者,该包是一个键值存储抽象库,每周下载量约 1.27 亿次。短短数小时内,一种名为 ChainDrop 的自我复制蠕虫(Shai-Hulud 恶意软件家族的变种)就污染了 444 个不同包中的 2,234 个版本 。这些受影响包的合计月下载量超过 20 亿次。
这次事件之所以令人震惊,不仅在于其规模,更在于其精巧程度。该蠕虫并未利用 npm 本身的任何漏洞,而是滥用了合法功能:可信维护者的身份、自动化发布流水线以及经过签名的来源证明(provenance)。
攻击步骤详解
步骤 1:账户入侵
攻击者首先控制了维护者的 GitHub 账户。初始入侵途径尚未确认,但结果很明确:攻击者现在拥有了推送代码和发布包的合法凭证。
为何发生: 维护者的账户缺乏足够保护, 没有硬件安全密钥,未对关键操作强制要求双因素认证,或者凭证通过其他途径暴露。这是整个链条中最关键的一个失败点。
步骤 2:直接注入主分支
UTC 时间 8 月 4 日 09:02,攻击者直接向 keyv 仓库的 main 分支推送了一个恶意提交。该提交添加了两个文件:
setup.mjs------ 一个 29 KB 的加载器脚本Math_Symbol.js------ 一个 728 KB 的混淆第二阶段载荷
同时修改了 package.json,添加了 preinstall 钩子:
json
"scripts": {
"preinstall": "node setup.mjs"
}
为何发生: 攻击者拥有主分支的写入权限。仓库没有配置分支保护规则,要求合并到主分支前必须经过 Pull Request 审查。这是 GitHub 仓库安全配置的基本失误。
攻击者得到了什么: 通过直接注入主分支,恶意代码成为了自动化发布流水线将会构建和发布的源码的一部分。
步骤 3:IDE 和 AI 代理持久化钩子
UTC 09:04,攻击者推送了额外的提交,在开发者工具配置中植入了执行钩子:
.claude/settings.json------ 一个SessionStart钩子,在 Claude Code 会话启动时运行node .claude/setup.mjs.vscode/tasks.json------ 一个folderOpen任务,在仓库于 VS Code 中打开时运行node .vscode/setup.mjs
为何发生: 攻击者深知开发者不只是安装包,他们还会克隆仓库并在 IDE 中打开。这就创建了一个完全不需要 npm install 的次级感染向量。开发者或 AI 编码助手仅仅因为打开克隆的仓库文件夹就可能被攻陷。
步骤 4:发布到 npm
UTC 09:35,keyv@6.0.0 被发布到 npm。合法的发布流水线构建了已被木马化的源码,并附带了有效的 npm 来源证明 ------ 一份签名的 SLSA 证明。
keyv@6.0.0 附带有效的 npm 来源证明发布
合法的发布流水线构建了已被木马化的源码
签名验证在恶意软件上通过了
为何发生: 来源证明证明的是构建完整性,而非源码完整性。构建系统执行了它设计要做的事, 构建仓库中存在的任何源码,并对结果签名。系统信任源码,因为维护者的账户是受信的。
关键教训: 经过签名的来源证明并不意味着代码安全。它只说明该代码确实来自所声称的构建过程。
步骤 5:蠕虫激活
当开发者运行 npm install 安装受影响包时,preinstall 钩子自动执行。setup.mjs 实际执行的操作:
- 检查 Bun 运行时 ------ 若未安装,则直接从 GitHub 官方发布页下载
bun v1.3.13 - 执行载荷 ------ 使用 Bun 运行
Math_Symbol.js - 清除痕迹 ------ 执行后删除临时 Bun 目录
Math_Symbol.js 载荷经过重度混淆(728 KB),采用控制流扁平化和 Base91 字符串编码。
载荷功能:
- 使用 TruffleHog 风格的正则表达式扫描磁盘,查找凭证信息
- 窃取 npm 和 GitHub 访问令牌
- 窃取 AWS、GCP 和 Azure 云凭证
- 提取 HashiCorp Vault 令牌和 Kubernetes 服务账户令牌
- 读取 GitHub Actions OIDC 令牌和实例元数据
- 捕获 Stripe 和 Slack 令牌、SSH 密钥以及数据库凭证
- 使用 AES-256-GCM 在操作者公钥下加密窃取的数据
- 将数据外泄至攻击者控制的 GitHub 仓库和通过 DNS 解析的目标地址
步骤 6:自我复制
这才是它成为蠕虫的关键。利用窃取到的 npm 令牌,恶意软件:
- 搜索被入侵身份拥有发布权限的其他包
- 下载这些包
- 注入相同的恶意文件(
setup.mjs以及Math_Symbol.js或math_init.js) - 修改
package.json添加preinstall钩子 - 增加版本号
- 将木马化后的包重新发布到 npm
速度令人咋舌: 蠕虫在大约两小时内(09:40 至 11:44 UTC)重新发布了 435 个包中的 1,557 个恶意版本。每隔两到七分钟,就有一波新的被污染包发布。
当窃取的凭据中包含 GitHub App 令牌时,蠕虫进一步扩大影响范围 ------ 向每个可访问仓库的最多 50 个分支提交恶意钩子。
为何发生: npm 令牌授予发布权限。一旦被窃取,它们就成为蠕虫的主要传播机制。整个过程没有任何速率限制或异常检测来标记这种异常的发布模式。
步骤 7:动态 C2 基础设施
恶意软件放弃了在载荷中硬编码 C2 域名的传统做法,而是:
- 使用
eth_call调用主网上的一个以太坊智能合约(StringListStore) - 动态获取当前的 C2 域名列表
- 攻击者可以随时更新合约来更换 C2 基础设施,而无需重新部署恶意软件
为何发生: 静态 C2 域名容易被封堵。通过将以太坊作为动态配置存储,攻击者使恶意软件能够抵御域名封禁。初始链记录显示有 3 个域名,后来更新为仅返回一个。
步骤 8:令牌撤销陷阱
这里尤其阴险。载荷安装了一个自毁开关:
- 将窃取的 GitHub 令牌和攻击者提供的处理命令写入
~/.config/gh-token-monitor/ - 将自身持久化为 macOS LaunchAgent 或 Linux systemd 用户服务,并启用
loginctl enable-linger以在注销后继续运行 - 监控令牌是否被撤销
陷阱: 如果你作为第一响应措施撤销了被窃令牌,就会触发自毁开关。载荷将执行其处理命令 ------ 可能销毁证据、外泄额外数据或造成其他破坏。
为何发生: 攻击者预判了应急响应流程,并内置了对抗措施。这是专业级别的战术手段。
受影响的包
值得注意的被入侵版本包括:
keyv@6.0.0------ 月下载量约 6 亿+flat-cache@6.1.24------ 月下载量约 5.8 亿file-entry-cache@11.1.6------ 月下载量约 5.7 亿cacheable-request@13.0.20cacheable@2.5.1cache-manager@7.2.10ecto@5.0.1
蠕虫还波及了 Deliveroo、Ornikar、OneReach、Picsart、Qlik 和 ServiceTitan 等其他组织下的包。
代码分析:恶意载荷
preinstall 钩子
json
{
"scripts": {
"preinstall": "node setup.mjs"
}
}
问题所在: npm 的 preinstall 钩子在包安装之前 执行,且无需任何用户交互。受害者不需要导入包或编写任何应用代码 ------ 仅仅运行 npm install 就足够了。
攻击面: 这并不是 npm 的漏洞,而是合法功能被滥用。包管理器信任包作者不会作恶。
加载器(setup.mjs ------ 简化示意)
javascript
// 伪代码示意
import { platform, arch } from 'os';
import { execSync } from 'child_process';
import { existsSync, mkdtempSync, rmSync } from 'fs';
// 检查 Bun 是否已安装
const bunPath = whichBun();
if (!bunPath) {
// 从 GitHub 下载 Bun v1.3.13
const url = `https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-${platform()}-${arch()}.zip`;
downloadAndExtract(url);
}
// 执行混淆后的载荷
execSync(`bun run ${__dirname}/Math_Symbol.js`, { stdio: 'inherit' });
// 清理
rmSync(tempDir, { recursive: true, force: true });
危险之处: 加载器下载了一个完整的 JavaScript 运行时(Bun)并执行任意代码。这绕过了任何 Node.js 特定的安全控制。728 KB 的载荷经过重度混淆,使得静态分析极为困难。
凭证收割器(Math_Symbol.js ------ 概念性)
javascript
// 基于模式的凭证扫描(TruffleHog 风格)
const patterns = {
aws: /AKIA[0-9A-Z]{16}/g,
github: /gh[psu]_[0-9a-zA-Z]{36,}/g,
npm: /npm_[a-zA-Z0-9]{36,}/g,
// ... 数百种其他模式
};
// 扫描磁盘上的文件
function scanDirectory(path) {
for (const file of walkSync(path)) {
const content = readFile(file);
for (const [type, pattern] of Object.entries(patterns)) {
const matches = content.match(pattern);
if (matches) {
exfiltrate({ type, matches, file });
}
}
}
}
// 自我复制
function propagate(npmToken) {
const packages = getPublishablePackages(npmToken);
for (const pkg of packages) {
const tarball = downloadPackage(pkg);
const modified = injectPayload(tarball);
const newVersion = bumpVersion(pkg.version);
publishPackage(pkg.name, newVersion, modified, npmToken);
}
}
之所以如此有效的原因:
- 使用合法的 npm API 进行传播 ------ 无需任何漏洞利用
- 为修改后的包重新计算完整性哈希值
- 为篡改后的版本生成全新的 Sigstore 来源记录
- 安装后包的行为正常,但主机已被攻陷
这对开发者意味着什么
攻击面已经扩大
此次攻击表明,供应链威胁模型现在包括:
- 维护者账户 ------ 开源中最关键的资产
- 源码仓库 ------ 不仅是已发布的包
- IDE 配置 ------
.vscode/、.claude/、.cursor/现在成为执行入口 - AI 编码助手 ------ 打开仓库即可触发执行
- 构建流水线 ------ 带有 OIDC 令牌的 GitHub Actions 是重点目标
来源证明并非银弹
keyv@6.0.0 版本具有有效的 npm 来源证明,附带签名的 SLSA 证明。证明验证通过,因为:
- 构建系统从被入侵的源码构建
- 构建系统对结果进行了签名
- 签名证明该构建确实来自合法的流水线
来源证明证明的是构建完整性,而非源码完整性。这是业界许多人一直忽略的关键区别。
如何修复:补救指南
立即行动
1. 检查是否受影响
查找以下入侵指标(SHA-256 哈希值):
9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc
→ Math_Symbol.js / math_init.js(728 KB 载荷)
54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668
→ setup.mjs(加载器,29,918 字节)
fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb
→ setup.mjs(加载器变体,11,017 字节)
检查你的 package-lock.json 或 yarn.lock 中是否存在受影响的版本。
2. 不要首先撤销令牌
如果你怀疑被入侵,切勿作为第一步就撤销 npm 令牌或 GitHub 令牌 ------ 这会触发自毁开关。正确做法:
- 首先识别并终止持久化机制
- 删除 systemd 服务或 LaunchAgent
- 删除
~/.config/gh-token-monitor/ - 然后轮换凭证
3. 识别并移除受影响版本
bash
# 检查锁文件中是否存在受影响版本
grep -E '"keyv": "6\.0\.0"' package-lock.json
grep -E '"flat-cache": "6\.1\.24"' package-lock.json
grep -E '"file-entry-cache": "11\.1\.6"' package-lock.json
移除受影响版本,改用已知良好版本。
4. 将受影响系统视为已被入侵
任何安装了受影响包或打开了受感染仓库的系统,都应被视为可能已被入侵。从干净镜像重建这些系统。
5. 轮换所有暴露的凭证
在移除持久化机制后,轮换:
- npm 令牌
- GitHub 令牌和 PAT
- 云服务商凭证(AWS、GCP、Azure)
- HashiCorp Vault 令牌
- Kubernetes 服务账户令牌
- SSH 密钥
- 数据库凭证
- Stripe 和 Slack 令牌
长期预防
对于维护者:
- 启用分支保护 ------ 要求合并到主分支前必须经过 Pull Request 审查,禁止直接推送。
- 使用硬件安全密钥 ------ 对所有关键操作强制使用 WebAuthn 双因素认证。
- 审计发布权限 ------ 定期审查谁和什么可以发布包。
- 监控异常活动 ------ 留意意外提交,尤其是对主分支的提交。
- 使用独立账户 ------ 维护者账户用于编写代码,使用单独且权限受限的服务账户进行自动化发布。
对于组织:
- 在 CI 中阻止安装脚本 ------ 在 CI/CD 流水线中尽可能使用
npm install --ignore-scripts。 - 扫描依赖 ------ 使用能够检测恶意 preinstall 钩子和混淆代码的工具。
- 锁定依赖版本 ------ 使用精确版本号,而非
^或~范围,以避免意外的补丁升级。 - 审计锁文件 ------ 定期审查
package-lock.json是否有意外更改。 - 将仓库视为执行面 ------ 在隔离环境中克隆和打开仓库,尤其是在调查安全事件时。
对于生态:
- npm 应考虑对多年未使用
preinstall钩子却突然添加该钩子的包进行额外验证。 - GitHub 应考虑标记那些通常使用 PR 流程却突然直接向主分支提交的账户。
- Sigstore 来源证明应包含源码完整性验证,而不仅仅是构建完整性。
真正的问题:没有验证的信任
此次事件从根本上说并非 npm、GitHub 或任何特定技术的漏洞,而是关于 我们如何将整个软件供应链建立在隐式信任之上。
我们信任了:
- 维护者账户是安全的
- 仓库中的源码是维护者所意图的
- 来源签名意味着代码是安全的
- 包管理器在安装期间只执行安全代码
这些假设没有一项成立。
攻击链之所以优雅,并非因为它利用了一个零日漏洞,而是因为它利用了 信任 ------ 我们对维护者、构建系统、包注册表以及日常工具的信任。
结语
2026 年 8 月的 npm 蠕虫攻击是软件供应链安全的一个分水岭。它表明:
- 账户入侵已成为新的零日漏洞 ------ 最关键的漏洞不在代码中,而在身份中
- 来源证明不等于安全 ------ 一个已签名的包仍然可能是恶意的
- 你的 IDE 现在是攻击向量 ------ 打开仓库与运行未知代码同样危险
- AI 编码助手是攻击目标 ------ 它们会在无人审查的情况下执行配置钩子
- 应急响应必须进化 ------ 旧的行动手册(先撤销,后提问)可能会适得其反
维护者是受害者。npm 和 Sigstore 流水线完全按照设计运行。系统按设计工作 ------ 而这正是问题所在。
我们需要构建不依赖隐式信任的系统。每一步都需要验证。我们必须认识到,在开源世界中,维护者的笔记本电脑现在已成为攻击面的一部分。