导语
“这个 npm 包没有安装脚本,沙箱安装时也没有异常网络请求。”
这句话只能证明一件事:在观察到的安装阶段,它没有表现出异常。它不能证明模块加载后、业务连接建立后或应用运行数小时后仍然安全。
OpenSSF 于 2026 年 9 月 3 日记录的恶意包 @modss/baileys,提供了一个很典型的反例。该包是 WhatsApp Web 库 Baileys 的重命名分叉。它不依赖 preinstall 或 postinstall,而是在 WhatsApp 会话成功连接后获取发布者控制的远程频道列表,再用安装者自己的认证会话执行关注操作。
攻击逻辑因此同时避开了三种常见但不完整的判断:
-
没有生命周期脚本,所以 –ignore-scripts 不会触发它;
-
安装时尚未建立业务会话,所以短时沙箱可能看不到最终动作;
-
npm 制品本身不保存固定目标,所以同一哈希在不同时间可能表现不同。
真正的问题不是团队缺少扫描器,而是扫描模型把“依赖风险”错误地压缩成了“安装风险”。
本文将以 MAL-2026-15918 为案例,建立一套面向 JavaScript 依赖的四阶段审计框架:安装期、加载期、业务初始化期和持续运行期。
一、先划清事实边界
已确认事实
OpenSSF、OSV 与 GitHub Advisory Database 记录的信息包括:
-
包名为 @modss/baileys;
-
已确认版本为 1.0.0、1.0.1 和 1.0.2;
-
三个版本均包含相同的相关处理逻辑;
-
代码位于 lib/Socket/newsletter.js;
-
当 connection.update 事件中的连接状态变为 open 时,代码会获取远程 JSON 列表;
-
它会通过当前已认证的 WhatsApp 会话,对列表中的频道执行 FOLLOW;
-
目标列表由包发布者在 npm 发版之外控制,可以在不重新发布包的情况下变化;
-
没有 npm 生命周期脚本参与这条触发链;
-
上游正版 @whiskeysockets/baileys 不受这个分叉包事件影响;
-
OSV 和 GitHub Advisory Database 没有列出修复版本。
没有被证实的能力
现有公开分析没有观察到该包窃取凭据或会话密钥,也没有证据显示它执行任意系统命令。
因此,本文不会把它描述为“远程代码执行木马”或“WhatsApp 密钥窃取工具”。已经确认的是未经用户同意、借用现有登录态执行账户操作。
关于来源
OpenSSF 恶意包记录发布于 2026 年 9 月 3 日,OSV 于 9 月 4 日更新,GitHub Advisory Database 于 9 月 4 日收录并复核。发现方 pkgwarden 表示,其分析采用下载 tarball、解包和逐文件静态阅读的方式,没有安装、导入或执行可疑包,也没有请求包中发现的远程地址。
这点很重要:研究结论来自对可达代码路径的静态验证,而不是运行真实恶意逻辑。
二、传统 npm 安全检查为什么会漏掉它
第一种错觉:没有 postinstall 就没有执行风险
npm 生命周期脚本确实是供应链攻击的常见入口。团队检查 package.json 中的 preinstall、install 和 postinstall,或者统一使用:
npm ci –ignore-scripts
都能消除一部分风险。
但 JavaScript 依赖还可以在以下时刻执行代码:
-
require() 或 import 时的模块顶层;
-
工厂函数和 SDK 初始化时;
-
网络连接成功后的事件回调中;
-
定时器、重连逻辑或后台轮询中;
-
首次调用某个业务方法时;
-
传递依赖被解析或动态加载时。
–ignore-scripts 只控制包管理器执行生命周期脚本,不会禁止应用在正常运行中加载依赖代码。
第二种错觉:沙箱跑一分钟没外连就安全
如果恶意逻辑等待:
-
有效账户完成登录;
-
WebSocket 或长连接进入 open;
-
延迟 30 到 120 秒;
-
某个业务事件第一次出现;
-
生产环境变量、真实令牌或特定域名存在;
短时、无凭据、无业务流量的动态分析就可能得出假阴性。
这不是动态分析没有价值,而是触发条件没有被满足。报告应该写“在这些条件下未观察到”,不能直接写“该包没有恶意行为”。
第三种错觉:锁文件与哈希锁住了行为
锁文件和完整性哈希能够回答:
-
安装了哪个包、哪个版本;
-
下载的 tarball 是否被中途替换;
-
构建是否使用了预期制品。
但如果固定代码会从外部读取目标、策略、模板或命令,同一制品仍然可以在不同时间产生不同结果。
固定 tarball + 可变远程配置 = 可变运行时行为
因此,软件物料清单需要扩展为“代码制品 + 外部控制源 + 权限边界”,而不能停在包名和版本。
三、从分叉包到远程指令源:完整信任链如何形成
第一步:名称与功能继承信任
分叉包往往保留上游项目的 API、文档和大部分代码。开发者看到熟悉的接口、示例和生态名称,容易把对上游的信任迁移到新的 npm scope。
但以下对象彼此独立:
-
上游 Git 仓库;
-
分叉仓库;
-
npm 发布账号;
-
实际上传的 tarball;
-
运行时访问的远程资源。
名称相似不能建立它们之间的可信绑定。
第二步:正常事件成为恶意触发器
connection === "open" 本来是一个合理的业务事件。恶意分叉利用它,是因为此时两个条件已经满足:
宿主应用认为 SDK 初始化成功;
当前进程已经持有可用认证会话。
恶意行为混在正常连接回调中,比安装阶段突然启动一个陌生进程更不显眼。
第三步:远程列表把制品外数据变成动作选择器
远程 JSON 表面上只是数据,但它决定了“对哪个目标执行关注”。如果发布者可以随时改变列表,就拥有了持续影响已部署实例的能力。
远程内容无需包含 JavaScript。只要它能够控制以下任一元素,就具备指令属性:
-
动作类型;
-
动作目标;
-
执行时间;
-
权限范围;
-
要加载的后续模块或地址。
第四步:依赖借用宿主身份完成副作用
恶意代码没有必要先导出会话密钥。它已经位于宿主进程内,可以调用相同 SDK,使用用户当前身份完成动作。
这可以概括为:
发布者控制的外部输入
↓
第三方依赖的事件处理器
↓
宿主应用持有的认证会话
↓
用户未授权的外部副作用
依赖真正获得的不是“一个函数调用权限”,而是进程能够触达的全部能力集合。
四、四阶段审计模型
阶段一:发布与安装期
检查目标:谁发布了什么制品,安装动作会发生什么。
重点包括:
-
npm scope 和发布者账号的创建历史;
-
包名是否模仿或重命名知名项目;
-
仓库、Git Tag 与 npm tarball 是否一致;
-
生命周期脚本、二进制下载和原生扩展;
-
锁文件、完整性哈希和 registry 来源;
-
新增依赖、别名依赖及 Git/URL 依赖。
阶段结论只能是“安装期风险评估”,不能替代后续阶段。
阶段二:模块加载期
检查目标:import 或 require 时是否产生副作用。
需要搜索:
-
模块顶层网络请求和文件写入;
-
自动注册的事件监听器;
-
setTimeout、setInterval 和后台任务;
-
monkey patch、prototype 修改和全局钩子;
-
依赖别名是否在加载时修改另一个合法包;
-
动态 require、import()、eval 和 Function。
测试时要把“只解包”“导入模块”“实例化客户端”分成不同步骤,分别记录行为。
阶段三:业务初始化期
检查目标:获得真实配置、连接和身份后,依赖做了什么。
典型触发条件包括:
-
登录完成;
-
数据库或消息队列连接成功;
-
WebSocket 进入 open;
-
CI 获取仓库令牌;
-
云 SDK 装载默认凭据;
-
AI Agent 完成工具注册。
@modss/baileys 的已确认行为位于这一阶段。没有模拟连接成功事件,就无法覆盖关键路径。
阶段四:持续运行期
检查目标:同一制品的行为是否会被时间和外部状态改变。
重点观察:
-
周期性外连和长连接;
-
远程 JSON、YAML、策略和模板;
-
DNS、重定向和短链变化;
-
运行时下载的插件、模型或脚本;
-
本地标记文件与持久化状态;
-
重连、重试、更新和故障回退路径。
这一阶段要求持续遥测,而不是发布前扫描一次就结束。
五、无害实验:同一哈希为何不等于同一行为
下面的 Node.js 模型不连接网络,不调用 WhatsApp,也不包含真实频道 ID。两个“远程响应”由本地对象代替,用来证明固定代码会因外部数据变化而产生不同动作。
const crypto = require('node:crypto');
function packageHash(source) {
return crypto.createHash('sha256').update(source).digest('hex');
}
function unsafeHandler(remoteList, authenticatedSession) {
if (!authenticatedSession) return [];
return remoteList.map(target => ({ action: 'FOLLOW', target }));
}
function safeHandler(remoteList, context) {
const allowed = new Set(['official-status']);
if (!context.authenticated) return [];
if (!context.userApproved) return [];
if (!context.configSignatureValid) return [];
return remoteList
.filter(target => allowed.has(target))
.map(target => ({ action: 'FOLLOW', target }));
}
const fixedSource = unsafeHandler.toString();
console.log('same package hash:', packageHash(fixedSource));
console.log('day 1:', unsafeHandler(['official-status'], true));
console.log('day 2:', unsafeHandler(['publisher-selected'], true));
console.log('safe day 2:', safeHandler(['publisher-selected'], {
authenticated: true,
userApproved: false,
configSignatureValid: true
}));
安全版本同时要求:
-
配置来源可验证;
-
用户明确同意;
-
目标在服务端或本地允许列表中;
-
所有副作用可审计。
即便签名有效,也不能省略用户授权和目标约束。签名只能证明“内容来自谁”,不能证明“内容允许做什么”。
六、如何避免静态分析的两种误判
误判一:看见可疑字符串,就认定一定被执行
研究者在同类分叉审查中发现过反例:可疑代码只存在于备份文件,Node.js 不会加载;或者写入逻辑存在,但没有任何调用路径。
可靠结论需要证明:
入口 → 注册 → 触发条件 → 可达函数 → 有副作用的 sink
仅凭域名、函数名或代码片段,最多能形成线索,不能直接证明真实行为。
误判二:关键词没命中,就认定没有行为
同一种关注动作可能通过高级封装函数调用,也可能直接调用底层查询接口;可疑代码还可能位于与真实用途不符的文件中。
因此审计不应只搜索 newsletterFollow、postinstall 或某个已知域名。更稳妥的方法是从能力 sink 反向追踪:
-
哪些函数会修改外部账户状态?
-
谁调用了它们?
-
参数来自硬编码、用户输入还是远程响应?
-
调用路径是否在默认配置下可达?
七、开发与安全团队可执行的行动清单
P0:处置本次事件
在 package.json、锁文件、SBOM、构建缓存和容器镜像中搜索精确包名 @modss/baileys。
发现 1.0.0、1.0.1 或 1.0.2 时直接移除;当前没有官方列出的修复版本。
不要随意替换为另一个名称相似的 Baileys 分叉。业务确有需要时,应重新选择并验证可信来源。
重新构建制品,检查旧包是否残留在镜像层、离线缓存、Serverless Layer 或共享 node_modules 中。
由账户所有者检查异常频道关注及相关操作记录。
现有分析没有确认会话密钥泄露,因此是否轮换会话应结合其他异常证据和环境风险决定;不要把“安装过”机械等同于“凭据已经外泄”。
P1:扩展检测范围
-
在代理、DNS 和 eBPF/容器网络遥测中识别依赖的运行时外连;
-
对 GitHub Raw、对象存储、Paste 服务和短链接收紧出站策略;
-
监控 SDK 建立认证会话后的非业务预期动作;
-
标记依赖创建的定时器、后台任务和长连接;
-
对“远程响应 → 敏感 API”建立污点分析规则;
-
保留包 tarball 和哈希,支持事后逐版本比较。
P2:把四阶段审计接入 DevSecOps
CI 准入
-
拒绝已进入 OSV/OpenSSF 恶意包数据集的精确版本;
-
对新 scope、重命名分叉和突然更换发布者的包提高审核等级;
-
比较 tarball 与声明源码仓库,而不是只检查版本号;
-
展开 npm alias、workspace、Git URL 和传递依赖。
隔离测试
-
阶段 1:只下载和解包,记录文件、脚本和哈希;
-
阶段 2:导入模块,记录顶层副作用;
-
阶段 3:模拟登录、连接成功和首次业务调用;
-
阶段 4:延长观察时间,模拟重连并改变远程配置响应。
测试报告必须写清楚触发条件、凭据状态、网络策略和观察时长。没有命中行为时,应写“未观察到”,而不是无条件写“安全”。
运行时约束
-
将第三方插件和机器人库放入最小权限进程;
-
对出站网络采用默认拒绝;
-
对发送、关注、发布、删除等动作设置独立授权;
-
把远程配置纳入签名、版本、模式和允许列表校验;
-
对配置变更保留审计记录,并支持快速撤销。
八、建议建立的供应链测试矩阵
| 安装期 | 有/无生命周期脚本 | 进程、网络、文件写入 | 只覆盖包管理器行为 |
| 加载期 | require / import | 顶层副作用、定时器、补丁 | 测试只解包未加载 |
| 初始化期 | 有效配置与模拟认证 | 事件处理器、SDK 副作用 | 沙箱没有真实触发条件 |
| 运行期 | 长时运行、重连、配置变化 | 周期外连、远程控制源 | 观察窗口过短 |
| 故障期 | 网络失败、签名错误、超时 | 是否安全失败 | 回退逻辑扩大权限 |
矩阵的价值不是让每个依赖都跑一套昂贵测试,而是根据能力分级:普通工具库做轻量审查;持有账户、云、CI/CD、支付或 AI 工具权限的 SDK,必须覆盖全部阶段。
总结
@modss/baileys 的案例表明,恶意依赖完全可以在安装阶段保持安静,等待宿主获得有效会话后再行动;它还可以把目标移到 npm 制品之外,让发布后的外部数据持续改变行为。
这要求供应链防线回答四个不同问题:
安装时执行了什么?
模块加载时注册了什么?
获得业务身份后调用了什么?
运行期间谁还能改变它的行为?
锁文件、哈希、–ignore-scripts、SCA 和短时沙箱各自只能回答其中一部分。真正有效的依赖治理,需要把静态来源、可达代码、业务触发条件、外部控制源和运行时权限放在同一张图上。
代码是否固定,只是供应链可信的一半。另一半是:固定代码运行时还会听谁的。



