破绽在于时间戳
npm 在 2026 年 7 月 8 日推出了它十六年来最大的安全变更——安装脚本默认停用。不到一周,两起各自独立的 npm 遭入侵事件把有效载荷改为在包被导入时触发,而非在安装时,直接绕过新的默认值,也绕过 --ignore-scripts。整个生态系统刚花了一年学会盯防的机制,早已移动了位置。唯一没有移动的,是恶意版本的新旧——而那正是 Bromure 的时效闸门所检查的信号。
整个生态系统花了一年学到,npm 的安装脚本才是危险。7 月 8 日,npm 把它们 默认关掉。三天后,一起入侵把有效载荷移出了安装脚本;再过三天,第二起则 完全没有安装脚本。两者都在你导入包时触发。修正与绕道在同一周交错而过。
某位开发者在 7 月 14 日把 @asyncapi/generator 加进项目,并运行 npm install。那个包没有 preinstall、没有 install、没有 postinstall
挂钩——没有任何东西可供脚本扫描器标记,也没有任何东西需要 npm 全新的默认值
去跳过。安装干净地完成。当代码第一次运行 require('@asyncapi/generator')
时,一个接进包自身入口点的加载器启动了一个分离的 Node 进程,经 IPFS 拉下
一个 8.2 MB 的第二阶段,将它解密,并开始搜捕凭据。安装过程看起来没有任何
不对,因为不对的那部分并不发生在安装时。
为去年的攻击而发布的修正
将近一年来,npm 供应链攻击的故事都是同一个:一个遭入侵的包带着一个
postinstall 挂钩,而 npm install 会在你的机器上自动运行它。于是 7 月 8
日,npm 推出了版本 12——这个工具十六年来
最大的安全重新设计。来自依赖包的生命周期脚本现在默认停用:“依赖包的
preinstall、install 与 postinstall 脚本,除非你允许,否则不会运行”。Git
依赖与远程 URL 来源也变成需要选择加入。这是实实在在的改善,关上了一年来蠕虫
走过的一道门。
它关上了一道门。攻击者早已找到了另一道。
有效载荷搬到了导入时
7 月 11 日,npm 包 jscrambler 遭到入侵
——在约三小时内发布了五个恶意版本。前三个,8.14.0 到 8.17.0,“从一个
preinstall 挂钩运行投放程序”,正是经典模式。接着在行动进行到一半时,攻击者
换了门。“从 8.18.0 起,安装挂钩完全消失——相同的投放程序改为以自执行函数
注入到 dist/index.js 的顶端”,于是它“在包被导入或其 CLI 被运行时触发,
而非在安装时”。Socket 直白地点出原因:“这是蓄意的规避:它击败只检查
preinstall/postinstall 脚本的扫描器,并在 npm install --ignore-scripts
下存活”。
三天后,npm 的 @asyncapi
组织
以同样的方式遭到入侵,但从一开始就是为此打造的。横跨四个包的五个版本——
@asyncapi/specs 6.11.2-alpha.1 与 6.11.2、@asyncapi/generator 3.3.1、
@asyncapi/generator-components 0.7.1、@asyncapi/generator-helpers
1.1.1——在攻击者利用一个配置错误的 GitHub Actions 工作流程外泄了项目的机器人
令牌之后被重新发布。Microsoft 的发现才是这里的关键:“所有受影响的包在
package.json 中都没有声明任何 preinstall、install 或 postinstall 挂钩”。
加载器改为坐在每个包正常的入口点里——specs 是 index.js,生成器则是深藏在
模板里的一个 validator.js。而缓解建议写得很明白:“不要把 npm install --ignore-scripts 当作缓解手段;这场行动是在模块被导入时运行,而非通过生命
周期挂钩”。
两个彼此无关的攻击者,相隔三天,都在生态系统旗舰修正上线的同一周落在同一步 棋上。这不是巧合。当一项防御点名了某个机制时,就会这样——那机制成了立足点, 而站到别处对攻击者只需几行代码。一个问“这个包有安装挂钩吗?”的扫描器,会从 AsyncAPI 得到诚实的“没有”,然后放它过关。
唯一没有移动的
拿掉递送机制,看看在两起入侵中维持不变的是什么。jscrambler 的版本只上线了 几个小时。AsyncAPI 的稳定版在 08:30 UTC 发布,下游检测在 08:49 开始——十九 分钟。两起事件里每一个恶意版本都在上线后远不到两小时内被撤下。有效载荷从安装 挂钩搬到了入口点;递送从生命周期脚本变成了运行期加载器。唯一没有移动的性质, 是每一个被下毒的版本都是崭新的。一个刚发布的版本,是入侵共有的指纹,因为那 正是攻击的形状——偷一个令牌、发布、被逮到、被撤下。这个窗口在结构上就很短。
那正是 Bromure 的时效闸门所检查的性质,而且它默认开启。
每一次包获取——npm、PyPI、Cargo 等等——都会在代理程序的安装看到任何一个字节
之前,先通过主机的中间人代理,而代理会拒绝任何比阈值更年轻的版本,默认是
两天。像 latest 这样的浮动引用或一个 semver 范围,会静默解析到够老的最新
版本;一个固定指向过新版本的引用,会连同一则解释原因的 Bromure 错误,以
451 返回。它从不过问有效载荷在哪里。它不在乎代码是在 preinstall 还是在
require() 触发、挂钩在不在、--ignore-scripts 有没有设置。一个十九分钟前
发布的版本会被拒绝,因为它十九分钟大。这两场行动面对两天的闸门,一到就已
阵亡——不是因为 Bromure 认出了有效载荷,而是因为它根本从未看过有效载荷。
时效闸门并不是在主张新包是恶意的——大多数都没问题,而两天大的版本仍然很新。 它主张的是风险集中在哪里。入侵的危险窗口,是在被偷的令牌推上一个版本之后、 维护者察觉并撤下它之前的最初几个小时。等两天并不会让包变安全,但它把你的安装 移出这些攻击所生存的那个确切区间,而且是在完全不检查你即将运行的任何一行代码 的情况下做到。对于你在发布当天就需要的包,闸门接受逐包的豁免;其余一切都等它 的两天。
当一个版本反正已经够老时
时效闸门是过滤器,不是墙。设一个更短的阈值、豁免一个你第一天就需要的包,或 撤下一个未被检测到、久到老过两天的恶意版本,被下毒的代码就会抵达安装。Bromure 对那种情况的答案,正是先前那些供应链文章所描述的,也正是时效闸门得以只当一个 过滤器、而非整套防御的原因——运行安装的代理程序,不在你的 Mac 上。
它在一个用完即弃的 Linux VM 里,隔着一道 Hypervisor 边界。所以当 AsyncAPI 的
加载器触发、它的凭据收集器出动搜寻时——而它搜得很凶,扫过超过一百个环境
变量,
包括 NPM_TOKEN、AWS_ACCESS_KEY 与 ANTHROPIC_API_KEY,再加上磁盘上的
.npmrc、~/.aws/credentials、id_ed25519 与 kubeconfig——它找到的是 VM
的副本,而那些是占位用的 brm_… 字符串。真正的值住在主机上。当代理程序发出
一个需要用到它的正当请求时,主机代理会在线路上、仅为那一跳,把假的换成真正的
值,再换回来;秘密永远不会落到 VM 的内存里供收集器刮取。有效载荷复制的
~/.ssh 密钥,是为那个配置文件铸出的用完即弃的一对。而有效载荷尝试的 C2
上传——这次是送往 85.137.53.71——是经由同一个会记录每个目的地的主机代理
离开,于是一个你从未启动的 Node 进程对一个未知 IP 开启套接字,就是安全日志
里的一行,而不是一次静默的传输。当窗口关闭时,VM 以及有效载荷在其中所做的
一切都消失了。
这正是今年稍早骑进 Red Hat 自家 npm 范围的同一个 Miasma 运行期家族。 收集器已然成熟,并在各场行动间重复使用;不断改变的,是递送机制那一侧。
这一周特别令人不安的教训是:一项绑定在某机制上的防御,其半衰期以天计。生态 系统花了一年把安装脚本变成该盯防的东西、推出了修正,然后眼看着两个各自独立的 攻击者,在发行说明都还没冷却之前就把有效载荷挪走。这场竞赛不能靠点名下一个 机制来赢,因为代码总还有别的地方可以触发。要赢,得靠检查一件攻击者无法廉价 改变的事——版本有多老——并把溜过去的东西,放在一个它什么都带不走的地方运行。 试试看。