返回所有文章
发布于 · 作者 Renaud Deraison

破绽在于时间戳

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 当作缓解手段;这场行动是在模块被导入时运行,而非通过生命 周期挂钩”。

jscrambler · 7/118.18.0: 移除挂钩AsyncAPI · 7/14没有挂钩安装脚本之门preinstall / postinstallnpm v12 默认关闭--ignore-scripts 关闭已关闭导入时之门require() / import加载器在入口点npm v12 不触及开启同一有效载荷分离的 node 进程8.2 MB 经 IPFS凭据收集器C2 · 85.137.53.71
同一有效载荷,不同的门。一年来,有效载荷走的是安装脚本那道门:npm 会自动运行 preinstall/postinstall。npm v12(7 月 8 日)默认关上了那道门,而 --ignore-scripts 则手动关上它。但第二道门通往同一个房间——那是你导入包时运行的代码。jscrambler(7 月 11 日)把投放程序从 preinstall 挂钩搬进 dist/index.js;AsyncAPI(7 月 14 日)则完全没有安装挂钩,加载器接进包自身的入口点。关上安装之门,并不会碰到导入之门。

两个彼此无关的攻击者,相隔三天,都在生态系统旗舰修正上线的同一周落在同一步 棋上。这不是巧合。当一项防御点名了某个机制时,就会这样——那机制成了立足点, 而站到别处对攻击者只需几行代码。一个问“这个包有安装挂钩吗?”的扫描器,会从 AsyncAPI 得到诚实的“没有”,然后放它过关。

唯一没有移动的

拿掉递送机制,看看在两起入侵中维持不变的是什么。jscrambler 的版本只上线了 几个小时。AsyncAPI 的稳定版在 08:30 UTC 发布,下游检测在 08:49 开始——十九 分钟。两起事件里每一个恶意版本都在上线后远不到两小时内被撤下。有效载荷从安装 挂钩搬到了入口点;递送从生命周期脚本变成了运行期加载器。唯一没有移动的性质, 是每一个被下毒的版本都是崭新的。一个刚发布的版本,是入侵共有的指纹,因为那 正是攻击的形状——偷一个令牌、发布、被逮到、被撤下。这个窗口在结构上就很短。

那正是 Bromure 的时效闸门所检查的性质,而且它默认开启。 每一次包获取——npm、PyPI、Cargo 等等——都会在代理程序的安装看到任何一个字节 之前,先通过主机的中间人代理,而代理会拒绝任何比阈值更年轻的版本,默认是 两天。像 latest 这样的浮动引用或一个 semver 范围,会静默解析到够老的最新 版本;一个固定指向过新版本的引用,会连同一则解释原因的 Bromure 错误,以 451 返回。它从不过问有效载荷在哪里。它不在乎代码是在 preinstall 还是在 require() 触发、挂钩在不在、--ignore-scripts 有没有设置。一个十九分钟前 发布的版本会被拒绝,因为它十九分钟大。这两场行动面对两天的闸门,一到就已 阵亡——不是因为 Bromure 认出了有效载荷,而是因为它根本从未看过有效载荷。

机制检查 · 检视有效载荷有安装挂钩吗?preinstall / install / postinstallAsyncAPI 诚实回答:没有通过检查require() 时触发Bromure 时效闸门 · 检视版本这个版本多久前发布?阈值: 2 天, 默认开启注册表显示发布于:19 分钟前已拒绝 · 451有效载荷永不解析
决定是否放行一个包的两种方式。机制检查问的是有效载荷的问题——有安装挂钩吗?——而 AsyncAPI 诚实地回答“没有”,所以检查放它过关。Bromure 的时效闸门问的是版本的问题——它多老了?——而一个十九分钟前发布的版本,无论其代码在哪里触发,都会被以 451 拒绝。机制在一周内、于两起攻击之间,从安装时搬到了导入时;时效没有移动,因为崭新的发布正是入侵的形状。

时效闸门并不是在主张新包是恶意的——大多数都没问题,而两天大的版本仍然很新。 它主张的是风险集中在哪里。入侵的危险窗口,是在被偷的令牌推上一个版本之后、 维护者察觉并撤下它之前的最初几个小时。等两天并不会让包变安全,但它把你的安装 移出这些攻击所生存的那个确切区间,而且是在完全不检查你即将运行的任何一行代码 的情况下做到。对于你在发布当天就需要的包,闸门接受逐包的豁免;其余一切都等它 的两天。

当一个版本反正已经够老时

时效闸门是过滤器,不是墙。设一个更短的阈值、豁免一个你第一天就需要的包,或 撤下一个未被检测到、久到老过两天的恶意版本,被下毒的代码就会抵达安装。Bromure 对那种情况的答案,正是先前那些供应链文章所描述的,也正是时效闸门得以只当一个 过滤器、而非整套防御的原因——运行安装的代理程序,不在你的 Mac 上。

它在一个用完即弃的 Linux VM 里,隔着一道 Hypervisor 边界。所以当 AsyncAPI 的 加载器触发、它的凭据收集器出动搜寻时——而它搜得很凶,扫过超过一百个环境 变量, 包括 NPM_TOKENAWS_ACCESS_KEYANTHROPIC_API_KEY,再加上磁盘上的 .npmrc~/.aws/credentialsid_ed25519 与 kubeconfig——它找到的是 VM 的副本,而那些是占位用的 brm_… 字符串。真正的值住在主机上。当代理程序发出 一个需要用到它的正当请求时,主机代理会在线路上、仅为那一跳,把假的换成真正的 值,再换回来;秘密永远不会落到 VM 的内存里供收集器刮取。有效载荷复制的 ~/.ssh 密钥,是为那个配置文件铸出的用完即弃的一对。而有效载荷尝试的 C2 上传——这次是送往 85.137.53.71——是经由同一个会记录每个目的地的主机代理 离开,于是一个你从未启动的 Node 进程对一个未知 IP 开启套接字,就是安全日志 里的一行,而不是一次静默的传输。当窗口关闭时,VM 以及有效载荷在其中所做的 一切都消失了。

这正是今年稍早骑进 Red Hat 自家 npm 范围的同一个 Miasma 运行期家族。 收集器已然成熟,并在各场行动间重复使用;不断改变的,是递送机制那一侧。

这一周特别令人不安的教训是:一项绑定在某机制上的防御,其半衰期以天计。生态 系统花了一年把安装脚本变成该盯防的东西、推出了修正,然后眼看着两个各自独立的 攻击者,在发行说明都还没冷却之前就把有效载荷挪走。这场竞赛不能靠点名下一个 机制来赢,因为代码总还有别的地方可以触发。要赢,得靠检查一件攻击者无法廉价 改变的事——版本有多老——并把溜过去的东西,放在一个它什么都带不走的地方运行。 试试看