脚本进了沙箱,模块却在宿主执行:vm2 CLI 漏洞与默认配置审计
一、先把时间线和影响范围说清
GitHub 已审核记录列出:CVE-2026-92950影响 npm 包vm2 <=3.11.6,最低修复版本为3.11.7。项目于2026-08-24发布相关公告,记录于2026-10-01进入 GitHub Advisory Database 并完成审核。因此本篇是新收录带来的工程复盘,不是十月刚发生的攻击通报。
3.11.7 发布说明与修复提交相互印证:CLI 增加模块根目录限制,并将允许加载的模块置于 sandbox 上下文。发布页显示该版本于8 月 24 日发布。
这里的特定入口是运行外部脚本的 vm2 CLI。不能仅凭依赖树出现 vm2,就断言业务已经被攻击;也不能因为业务没有调用 CLI,就忽略自己构造 NodeVM 时是否采用了相同宽松配置。
二、技术原理:导出值的包装发生得太晚
1. 脚本入口和模块入口是两扇门
从工程角度看,脚本平台至少需要约束两个入口:最初脚本的执行器,以及脚本请求依赖时使用的加载器。前者把代码放进受限环境,不代表后者也继承限制。
公告描述的默认组合中,外部加载被打开,但没有设置模块根目录,执行上下文使用宿主。模块加载不仅返回一个对象,还会运行模块初始化代码。因此,在返回值外面增加只读包装,不能撤销初始化阶段已经发生的副作用。来源:漏洞记录
这是一类很有迁移价值的审计模式:不要只问“返回给不可信代码的对象是否只读”,还要问“为了获得这个对象,系统先执行了什么”。同样的分析方法可以用于模板扩展、插件发现和动态配置加载。
2. 路径限制和执行上下文不能互相替代
路径边界回答“能加载哪些文件”;上下文边界回答“被加载代码拥有什么权限”。允许的目录中也可能放着不可信文件,只限制目录未必安全。反过来,即便执行上下文受到限制,也不应无理由开放整个宿主文件树。
| 设计状态 | 仍需回答的问题 |
|---|---|
| 路径受限、宿主执行 | 目录里的代码是否全部值得授予宿主能力? |
| 路径开放、受限执行 | 不应读取的代码或数据是否仍可被发现? |
| 路径和上下文同时限制 | 导入能力、资源上限与跨任务状态是否受控? |
表格是设计分析,不是对每一种 vm2 配置的完整漏洞判定。
3. CLI 修复不等于替应用重新设计权限
官方发布说明保留了一个重要边界:对于嵌入者使用外部加载、未设根目录、且采用宿主上下文的情形,本次版本采用警告提示,并非全面改成构造失败。兼容性选择不应被理解成安全保证。来源:3.11.7 升级说明
因此升级后仍要检查应用自己的new NodeVM(...)和封装工具。把安全责任完全交给包版本号,会漏掉由应用主动授予的高权限。
三、无害实验:只计算策略,不运行模块
下面模型使用虚拟路径字符串,不读取磁盘,不导入 JavaScript,不启动宿主进程。它说明两项检查为何需要同时存在,不是生产沙箱实现。
frompathlibimportPurePosixPathdefpolicy_allows(module,root,context):ifrootisNoneorcontext!="sandbox":returnFalsepath=PurePosixPath(module)base=PurePosixPath(root)ifnotpath.is_absolute()ornotbase.is_absolute():returnFalseif".."inpath.partsor".."inbase.parts:returnFalsereturnpath==baseorbaseinpath.parents cases=[("/job/helper.js","/job","sandbox",True),("/job/helper.js","/job","host",False),("/job/helper.js",None,"sandbox",False),("/other/helper.js","/job","sandbox",False),("/job2/helper.js","/job","sandbox",False),("/job/../other.js","/job","sandbox",False),]formodule,root,context,expectedincases:assertpolicy_allows(module,root,context)isexpectedprint("6 policy checks passed; no module executed")这个模型故意拒绝父目录片段,并按路径段比较,避免字符串前缀把相邻目录当成子目录。但它没有处理真实文件系统中的符号链接、竞态、硬链接及平台差异。不能直接复制为生产路径授权组件。
实验价值在于测试结构:保持模块路径不变,只切换上下文;保持上下文不变,只切换根目录。这样失败原因可定位,避免一组大而杂的攻击样本掩盖真正的安全不变量。
四、检测思路:检查部署入口,不只扫描 package.json
建议先建立运行入口清单:全局安装的 CLI、项目本地依赖、自研包装命令、长驻脚本服务和临时 CI 容器应分别盘点。锁文件只能说明一个构建输入,未必覆盖全局工具或旧镜像。
随后审查调用点:是否允许外部依赖,模块根目录由谁指定,上下文来自默认值还是显式配置,配置是否能被脚本作者或仓库内容覆盖。对于封装层,检查最终合并后的配置,而不是某个看起来安全的默认对象。
最后检查环境权限。即使脚本平台只用于“格式转换”,它运行的账号可能持有发布凭据或服务访问权。这属于部署风险推断,需要用挂载、环境变量和网络策略证据验证,不能直接写成该漏洞已经导致密钥泄露。
五、修复与防护建议
立即处理
升级 CLI 至包含此修复的版本,最低为3.11.7。同时检查全局安装和运行镜像,避免只升级开发依赖。对外部脚本,在验证隔离之前暂停高权限执行。
回归验证
为 CLI 和嵌入 API 分别建立测试。CLI 应验证模块加载限制;API 应验证应用声明的允许目录、上下文和能力列表。测试结果中应记录入口、版本、Node.js 版本及最终配置,使“已验证安全”的结论有清晰适用范围。
不要把一次正常脚本成功作为全部回归。至少需要验证相邻目录、目录外模块、拒绝后的清理行为以及并发任务之间的状态隔离。真实安全测试应在一次性、无生产凭据的环境执行。
平台长期治理
对真正不可信的代码,建立独立进程或更强系统隔离,限制网络、文件挂载和资源额度。语言级限制与操作系统限制承担不同职责;进程超时也需要由外部监督者实施,不能只依赖被执行代码所在环境自行结束。
这里不承诺某种容器配置绝对安全。目标是让单个库的失误不再直接获得整个宿主身份的能力,并能在失败后销毁任务环境。
六、总结
最容易被忽略的沙箱入口,往往不是最初执行脚本的那一行,而是后来加载模块的那一行。审计应该沿着“代码由谁加载、在哪里初始化、继承哪些权限”继续追踪。版本升级关闭了已知缺陷,应用仍需对自己授予的能力负责。d确认在野利用,未把公开验证等同于现实入侵。