乐于分享
好东西不私藏

月下载20亿的缓存库被投毒,npm的信任何以崩塌

月下载20亿的缓存库被投毒,npm的信任何以崩塌

凌晨一点多,我盯着终端里的一行输出发呆。

find / -xdev \  \( -name setup.mjs -o -name Math_Symbol.js -o -name math_init.js \)

12.7 秒。全盘扫描恶意文件, 12.7 秒出结果。两处命中,全是误报。

松了口气吗?并没有。因为同一时间,我的另一块屏幕上放着另一个数字: 444 。三天, 444 个包, 1381 个版本,合计月下载量超过 20 亿。那是 8 月 4 日开始的一场供应链攻击留下的战果,而我这台机器,只是风暴边缘的一粒沙。

事情得从三天前说起。

8 月 4 日, keyv 的维护者账号被人拿下了。 keyv 你可能没听过,但它的周下载量是 1.27 亿次,是 npm 缓存方案的地基; flat-cache 、 file-entry-cache 、 cacheable-request 、 cache-manager ,这些名字全都藏在你几乎每个项目的依赖树深处。

攻击者往 main 分支推了两个文件,改了一行 package.json ,发了个版。

就这些。

但这一版,带着 GitHub Actions 签发的 provenance——官方认证,可信发布。信任链的第一环,从这里裂开了。

表面信息:三条主流叙事,三条盲区

这几天的报道我基本都翻了一遍,主流叙事就三条。

一,维护者账号被黑,单点失守。二,升级 npm 12 就行。三,又是 Shai-Hulud ,又是 TeamPCP ,老熟人了。

三条各有盲区。字面上,都是对的。

账号被黑只是入场券,不是武器。真正的杀伤力在后面的自复制传播——11 个种子包只是起点,到 8 月 5 日已经滚成 444 个( JFrog 的口径是 400 多包、 1700 多版本,还在涨)。把一场蠕虫式的生态级供应链攻击,讲成"某个倒霉蛋账号被盗",是捡了芝麻。

升级 npm 12 ?它只堵住"安装时自动执行"这一条链路,剩下三个传播向量跟 npm 的版本号半毛钱关系没有。

归因呢?一个工具族,四月份拿下 PyTorch Lightning 的 PyPI 包,五月份污染 @antv 生态(有分析把账算到 TeamPCP 头上,说累计污染 600 多个包、 50 万台设备),八月份又端了 keyv 。这已经不是"又是谁干的"的问题了,是"为什么我们一直拦不住"的问题。四个月,三连击,愣是没拦住。这仗打得够憋屈。

当然,三条也不是全错。它们错在把一场系统性攻击,讲成了三个互不相干的单点事故。这本身就是这起事件最讽刺的地方——连安全的解读方式,都还在单点思维里打转。

那个包,在你 install 的时候做了什么

被投毒的包长这样:多了 setup.mjs 和 Math_Symbol.js 两个文件, package.json 里多了一行。

"preinstall": "node setup.mjs"

就这一行。在你敲下 npm install 的瞬间,在你还没来得及 import 任何东西之前,脚本已经跑起来了。

setup.mjs 是个挺"贴心"的 dropper——自己不干坏事,先从 GitHub 官方地址下载 Bun 1.3.13 运行时,再用它执行真正的载荷。为什么绕这么一圈?

因为下载源是 github.com 的官方 release ,正规到你安全网关的日志里看不出任何异常。跑完,临时文件一删,干干净净,连个残渣都不留。

真正的载荷 Math_Symbol.js , 728KB ,混淆到亲妈都不认识。它做的事情,翻译成人话就一句:把这台机器翻个底朝天。

读 .npmrc 里的 token 。而且不傻读——先调 registry 的 whoami 接口验一下是死是活,活的才偷,省得浪费流量。

读 GitHub 的凭证:经典 PAT 、 OAuth token 、 App token ,还有 CI 里的 OIDC token 。在 GitHub Actions 的 runner 上,它直接翻 runner 进程的内存,把整个 secret store 端走。对,物理意义上的"端走"。

AWS : credentials 文件、环境变量、 IMDSv2 (失败就降级 v1 )、 ECS 容器元数据,然后跨 17 个区域枚举 Secrets Manager , SSM Parameter Store 用 WithDecryption 解密着拿。

K8s : service account 的 token 拿到手,直接调 API 把命名空间里所有 secrets 拉走。 Vault 有六条路可以拿 token ,拿完枚举所有 KV 挂载点, KV v1 v2 全读。

Stripe 的密钥、 Slack 的 token ,顺带的事。

压轴的,是全盘扫描:约 200 个文件模式,.env 、私钥、 SSH 配置、 Terraform state 、 KeePass 数据库、 VPN 配置……连 .claude/settings.json 都不放过。 AI 工具的配置文件,也在猎捕名单里。

收集完, AES-256-GCM 加密,再用 RSA 公钥一裹,上传。

传到哪? GitHub 上的公开仓库——描述写着 "Shai-Hulud: Here We Go Again",现在大概有 1300 个这种仓库,全是受害者的数据投递点。公开仓库,谁都看得见,但谁也解不开,只有攥着 RSA 私钥的人能读。

GitHub 上传失败?有备胎: npm-cache.com:443/router ,一个 5 月 22 号才注册、看不出任何正经用途的域名。这域名藏在以太坊的一个智能合约里,攻击者想换随时换,载荷一行都不用改。

嗯。写到这里我停下来,去喝了口水。

然后继续往下看它的传播逻辑,越看越冷。

四路并进的传播

最吓人的一路:它偷到你的 npm token 之后,先验证两件事——能不能绕过 2FA ,有没有写权限。

都满足?

好,它枚举你有发布权限的每一个包,补丁号加一,塞进 setup.mjs ,重新发布。

你的一个 token ,就是下一批包的入场券。有人 token 的权限横跨几百个包,一波全带走。这就是为什么 11 个种子包三天滚成 444 个——不是人肉扩散,是自复制。它甚至要求 token 带 bypass_2fa 特性, 2FA 都拦不住它,人家专挑"能绕过 2FA"的下手。

第二路,仓库钩子。它往你能写到的仓库分支里提交五个文件:.vscode/tasks.json 、.vscode/setup.mjs 、.claude/settings.json 、.claude/setup.mjs 、.claude/math_init.js 。两个配置文件互相引用——VS Code 打开文件夹,跑一个叫 "Environment Setup" 的任务; Claude Code 开个会话,跑一个 SessionStart 钩子。你什么都没安装,打开仓库就中招。 commit 信息伪装成 "chore: update config",署名是 claude@users.noreply.github.com 。

连 AI 的邮箱都借来用。这事想起来还挺膈应。

第三路, GitHub Actions 窃密。注入一个叫 "Run Copilot" 的工作流,把${{ toJSON(secrets) }}整个写进一个文件,当 artifact 下载走。一个仓库的全部 secrets 上下文,从一条看起来人畜无害的 CI 通道里流走了。然后工作流删掉、分支删掉,现场收拾得比我家还干净。

第四路最阴。它盯上 opensearch-js 的发布流程——一个用 release-drafter.yml 自动发版的仓库。它拿工作流上下文里的 OIDC token ,换 npm 的发布凭证,往依赖里塞一个指向攻击者 commit 的条目,再生成一套 Sigstore/Fulcio/Rekor 签名。

恶意 tarball ,带着货真价实的 provenance 上线。

看到这儿我才彻底反应过来:这四路,分别打穿了账号、生命周期脚本、 IDE/AI 工具、发布证明。

我们默认成立的信任链,一次,四环。

影响预判: npm 12 与新的社工入口

官方的回应来得不慢,这波操作倒是利索: npm 12 默认不再执行依赖的安装脚本。 allowScripts 默认关, preinstall 、 install 、 postinstall 全拦。

我实测过,真的拦。装 esbuild , postinstall 被掐了, npm 打了一行警告: install scripts blocked 。连本地 tarball 装的依赖都拦——这个覆盖比官方文档写的还全。

但你要让脚本跑起来,得手动批准。

npm install-scripts approve

批准。这词听着轻巧,对吧。

恶意包的 README 以后会怎么写,我猜都不用猜:"安装完成后请运行 npm install-scripts approve"。这行字,跟"请把密码发到客服邮箱"一个味道。下一波供应链攻击,大概率就从这行字开始。

我的判断是——这个判断可能需要修正——npm 12 没有把风险消除,它把风险从"平台替你决定"换成了"你自己决定"。对技术敏感的人,这是进步;对百分之九十九只想赶紧把依赖装完的普通开发者,这是把一道安全决策,硬塞进一个没时间思考的瞬间。挺荒谬的,对吧。

短期里还有件事更麻烦: provenance 的公信力,回不来了。

opensearch-js 那个案例说明,可信发布流程本身可以被定向利用。以后看到"provenance 有效"的包,你敢直接装吗?

反正我犹豫了。说不上恶心,就是荒唐。

本机实测: 12 秒,虚惊,然后冷汗

回到开头那个凌晨。

我自查用了三层。第一层,按文件名全盘扫恶意工件, 12.7 秒出结果,两处 Math_Symbol.js 命中,点开一看: regenerate-unicode-properties 包里的 Unicode 数据文件, 1KB ,正经得不能再正经。跟 728KB 的混淆载荷差了十万八千里。

那一刻我意识到,这次攻击连文件名都在埋雷——Math_Symbol.js 既是恶意载荷的名字,也是 npm 上一个正经包的合法文件名。只看名字不鉴别,误报能把你吓死,真货能把你骗过。

第二层,核对 lockfile 。我机器上 keyv 是 4.0.4 , flat-cache 是 3.0.4 , cacheable-request 是 7.0.2 ,全是攻击前的旧版。干净。

这里我要说句实话:这是运气。

这些包不是我主动装的。它们是传递依赖——别的包的依赖的依赖。你问我它们为什么是这个版本?我不知道。没人知道。

而这恰恰是这次攻击最扎心的地方: npm 生态里,我们每个人都在跑一堆自己根本没听说过名字的包,而这些包的主人,可能只是一个密码的事。

第三层,实测 npm 12 的行为。三组探针: registry 包的脚本,阻断;本地 tarball 包的脚本,也阻断;显式批准之后,放行。机制是好的。

但机制越好,越提醒我一件事——防线的尽头,站着的还是人。

而这,恰恰是我最没把握的部分。

我不确定这些建议能撑多久。也许明年就有新变种绕过去。也许不用明年。

行动清单

还是给点能用的。自查三步,按顺序,别跳。(如果你扫出了点什么,欢迎回来留言——我很好奇大家的机器里藏着几个"惊喜"。)

第一步,扫工件。按文件名全盘扫 setup.mjs 、 Math_Symbol.js 、 math_init.js——别嫌糙,先把这个跑完。命中之后用哈希和文件大小鉴别,别被同名文件骗,也别放过真货。

第二步,核对版本。 lockfile 里找 keyv 6.0.0 、 flat-cache 6.1.24 、 file-entry-cache 11.1.6 、 cacheable-request 13.0.20 、 cache-manager 7.2.10 ,一个都不许出现。 npm 已经把 5.6.0 、 6.1.23 这些旧版恢复成 latest 了,但别信 latest 标签,按 lockfile 的精确版本说话。

第三步,查钩子。所有 clone 下来的仓库,打开之前先看 .claude/settings.json 和 .vscode/tasks.json ,有没有指向 setup.mjs 的钩子。这一步,在打开仓库之前做。

中了任何一条:先取证,再轮换凭证。

这个变种有个休眠逻辑——它盯着你的 token ,一旦发现失效反而会触发动作。所以别急着吊销,先把证据留好。然后全量轮换: npm 的发布 token 优先, GitHub 的 PAT 和 OIDC , AWS 、 K8s 、 Vault 、数据库、 SSH ,能想到的都换。 CI 的 runner 直接从干净镜像重建,别想着清理。

npm 升级到 12 ,或者干脆把npm install --ignore-scripts变成肌肉记忆。

就这些。

写这篇文章的时候,我的机器又扫了一遍。 12.7 秒。依然干净。

但"依然干净"这四个字,现在读起来,一点都不可靠。

嗯。就这样吧。

本文由 AI 辅助创作,作者进行了实测验证和编辑修改。