查看: 91|回复: 0

Node.js vm2 沙箱逃逸防护与 NodeVM 配置实践

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
Node.js 执行第三方或用户提交的代码时,原生 vm 模块常被误当成安全沙箱。实际上 vm.createContext 只隔离了全局对象,攻击者通过 this.constructor.constructor 就能取回主进程的 process。vm2 在 vm 之上叠加了 Proxy 代理层和上下文隔离,对属性访问、函数调用做运行时拦截,是 Node.js 生态中执行不可信代码的常用方案,典型场景包括插件系统、在线代码编辑器和模板引擎。

本文围绕 vm2 3.10.2 展开,覆盖 VM、NodeVM、VMScript 三个核心类的用法、require 权限控制、预编译性能优化,以及生产环境可用的安全配置模板。

一、环境准备与模块导入

当前稳定版为 3.10.2,修复了 CVE-2026-22709 这个 Promise 回调净化缺陷导致的逃逸高危漏洞,兼容 Node.js 12 及以上版本。安装方式:
  1. # 项目内安装
  2. npm install vm2 --save
  3. # 全局安装(含 CLI)
  4. npm install -g vm2
复制代码

vm2 对外暴露三个类,按需引入即可:
  1. // CommonJS
  2. const { VM, NodeVM, VMScript } = require('vm2');
  3. // ES Module
  4. import { VM, NodeVM, VMScript } from 'vm2';
复制代码

三者的定位分别是:VM 是轻量同步沙箱,不能加载模块,安全性和性能最好;NodeVM 是增强版,允许 require 内置或外部模块;VMScript 负责预编译,避免重复解析。

二、VM 类:轻量同步沙箱

VM 不提供模块加载能力,适合执行简单的同步表达式或函数体。常用配置项:

timeout:代码执行超时时间,单位毫秒,用于防死循环;
allowAsync:是否允许异步代码(setTimeout、Promise),默认 false;
sandbox:注入沙箱的全局变量白名单;
console:是否允许使用 console,默认 false;
eval:是否允许 eval 与 Function 构造器,默认 true,生产环境应显式关闭。

示例 1,基础计算与注入变量:
  1. const { VM } = require('vm2');
  2. // 1. 创建沙箱实例
  3. const vm = new VM({
  4.   timeout: 1000, // 1秒超时
  5.   allowAsync: false,
  6.   sandbox: {
  7.     // 注入安全全局变量
  8.     PI: 3.14159,
  9.     add: (a, b) => a + b
  10.   }
  11. });
  12. // 2. 运行代码(同步)
  13. try {
  14.   // 基础计算
  15.   const res1 = vm.run('1 + 2 * 3');
  16.   console.log('计算结果:', res1); // 输出:7
  17.   // 使用注入变量
  18.   const res2 = vm.run('add(PI, 1)');
  19.   console.log('注入变量计算:', res2); // 输出:4.14159
  20.   // 访问被禁止的全局对象(报错)
  21.   vm.run('process.exit()');
  22. } catch (err) {
  23.   console.error('错误:', err.message);
  24.   // 输出:ReferenceError: process is not defined
  25. }
复制代码

示例 2,把沙箱内的 console 重定向到自定义对象,只放行 log:
  1. const { VM } = require('vm2');
  2. const vm = new VM({
  3.   console: 'redirect', // 重定向控制台
  4.   sandbox: {
  5.     // 自定义console,仅允许log
  6.     console: {
  7.       log: (...args) => console.log('[沙箱日志]', ...args)
  8.     }
  9.   }
  10. });
  11. // 沙箱内console会被重定向
  12. vm.run('console.log("Hello vm2!")');
  13. // 输出:[沙箱日志] Hello vm2!
复制代码

原生 vm 与 vm2 的差异可以用同一段逃逸代码验证。原生 vm 下进程会被直接杀死:
  1. // 原生 vm(危险)
  2. const vm = require('vm');
  3. const ctx = {};
  4. vm.createContext(ctx);
  5. // 逃逸代码:获取主进程process并终止
  6. vm.runInContext('this.constructor.constructor("return process")().exit()', ctx);
  7. // 进程直接被杀死!
复制代码

同样的代码在 vm2 中会被拦截并抛出 TypeError:
  1. // vm2(安全)
  2. const { VM } = require('vm2');
  3. const safeVM = new VM();
  4. // 同样代码会被拦截
  5. safeVM.run('this.constructor.constructor("return process")().exit()');
  6. // 抛出:TypeError: constructor is not a function
复制代码

三、NodeVM 类:支持模块加载

NodeVM 的核心风险点集中在 require 配置上,应遵循最小权限原则:

builtin:允许加载的内置模块白名单;
external:是否允许加载外部模块,可取 true、false 或数组;
root:外部模块根目录,用于限制访问范围;
deny:显式禁止加载的模块名单;
mapping:模块别名映射。

示例 1,只放开 fs 和 path:
  1. const { NodeVM } = require('vm2');
  2. const vm = new NodeVM({
  3.   console: 'inherit',
  4.   require: {
  5.     builtin: ['fs', 'path'] // 仅允许fs、path
  6.   }
  7. });
  8. // 沙箱内读取文件(安全)
  9. const code = `
  10. const fs = require('fs');
  11. const path = require('path');
  12. const filePath = path.join(__dirname, 'test.txt');
  13. fs.writeFileSync(filePath, 'vm2 write test');
  14. const content = fs.readFileSync(filePath, 'utf8');
  15. module.exports = content;
  16. `;
  17. const result = vm.run(code);
  18. console.log('文件内容:', result); // 输出:vm2 write test
  19. // 尝试加载禁止模块(报错)
  20. vm.run('require("child_process")');
  21. // 抛出:Error: Access denied to module child_process
复制代码

示例 2,通过 module.exports 导出对象,实现主进程与沙箱之间的双向调用:
  1. const { NodeVM } = require('vm2');
  2. const vm = new NodeVM({
  3.   console: 'inherit',
  4.   require: { external: false }
  5. });
  6. // 沙箱代码导出函数
  7. const pluginCode = `
  8. module.exports = {
  9.   greet: (name) => {
  10.     console.log('Greet from sandbox');
  11.     return `Hello, ${name}`;
  12.   },
  13.   calculate: (a, b) => a * b
  14. };
  15. `;
  16. // 运行并获取导出对象
  17. const plugin = vm.run(pluginCode);
  18. // 调用沙箱函数(主进程 ←→ 沙箱通信)
  19. console.log(plugin.greet('Node.js'));   // 输出:Hello, Node.js
  20. console.log(plugin.calculate(5, 10));   // 输出:50
复制代码

四、VMScript 预编译优化

同一段代码需要反复执行时,每次 run 都要重新解析,开销较大。VMScript 把编译结果缓存下来,执行阶段只做运行。
  1. const { VM, VMScript } = require('vm2');
  2. // 1. 预编译脚本(编译一次)
  3. const script = new VMScript(`
  4. let sum = 0;
  5. for(let i=0; i<10000; i++) sum += i;
  6. sum;
  7. `, {
  8.   filename: 'loop-script.js' // 调试文件名
  9. });
  10. // 2. 创建沙箱
  11. const vm = new VM({ timeout: 500 });
  12. // 3. 多次运行预编译脚本(高效)
  13. console.time('执行10次');
  14. for(let i=0; i<10; i++) {
  15.   const res = vm.run(script);
  16.   console.log(`第${i+1}次结果:`, res);
  17. }
  18. console.timeEnd('执行10次');
复制代码

预编译同样可以用于 NodeVM 模块。下面的例子把 MD5 计算封装成沙箱模块,只开放 crypto:
  1. const { NodeVM, VMScript } = require('vm2');
  2. // 预编译模块代码
  3. const moduleScript = new VMScript(`
  4. const crypto = require('crypto');
  5. module.exports = (str) => crypto.createHash('md5').update(str).digest('hex');
  6. `);
  7. const vm = new NodeVM({
  8.   require: { builtin: ['crypto'] }
  9. });
  10. // 运行预编译模块
  11. const md5 = vm.run(moduleScript);
  12. console.log('MD5:', md5('vm2-secure'));
复制代码

五、安全机制与防护要点

vm2 的防护分三层:上下文隔离基于 vm.createContext 建立独立内存空间;Proxy 代理拦截所有对象访问、属性操作与函数调用;运行时监控负责超时控制、错误捕获和危险 API 拦截。

版本层面,CVE-2026-22709 影响 3.10.1 及以下版本,成因是 Promise 回调净化不完整,应升级到 3.10.2 以上。此前还出现过原型链污染、错误对象逃逸等历史漏洞,结论是始终使用最新版本。

落地时的几条原则:只开放必要的模块和 API,禁止 child_process 与 process;同步代码必须设置 timeout,异步代码额外做超时监控;关闭 eval 与 wasm;外部模块设置 root 限制目录;锁定版本并定期跟进补丁;在沙箱之外再叠加进程隔离(如 worker_threads)做深度防御。

一个可直接用于生产的配置模板:
  1. const { NodeVM } = require('vm2');
  2. const secureVM = new NodeVM({
  3.   // 基础安全
  4.   timeout: 3000,
  5.   eval: false,
  6.   wasm: false,
  7.   allowAsync: true,
  8.   // 控制台
  9.   console: 'redirect',
  10.   sandbox: {
  11.     console: {
  12.       log: (...args) => console.log('[SANDBOX]', ...args),
  13.       warn: (...args) => console.warn('[SANDBOX]', ...args)
  14.     }
  15.   },
  16.   // 严格模块控制
  17.   require: {
  18.     builtin: ['path', 'crypto', 'buffer'],      // 最小内置模块
  19.     external: false,                            // 禁止外部模块
  20.     deny: ['fs', 'os', 'process', 'child_process'], // 黑名单
  21.     root: './'                                  // 限制根目录
  22.   },
  23.   // 错误捕获
  24.   onCancel: () => console.error('沙箱代码超时被终止')
  25. });
复制代码

六、实战:安全插件系统

需求是主程序加载第三方插件,插件只能访问指定 API,且不能逃逸破坏主进程。实现上把沙箱创建、插件加载拆成两个函数:
  1. const { NodeVM } = require('vm2');
  2. const fs = require('fs');
  3. const path = require('path');
  4. // 1. 安全沙箱工厂
  5. function createPluginSandbox(pluginName) {
  6.   return new NodeVM({
  7.     console: 'inherit',
  8.     timeout: 2000,
  9.     eval: false,
  10.     require: {
  11.       builtin: ['path'],
  12.       external: false
  13.     },
  14.     // 注入插件可用API(白名单)
  15.     sandbox: {
  16.       pluginName,
  17.       // 安全工具函数
  18.       utils: {
  19.         formatDate: (date) => new Date(date).toLocaleString(),
  20.         random: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min
  21.       },
  22.       // 主程序提供的服务
  23.       pluginService: {
  24.         log: (msg) => console.log(`[插件${pluginName}]`, msg),
  25.         getData: () => ({ user: 'admin', role: 'guest' })
  26.       }
  27.     }
  28.   });
  29. }
  30. // 2. 加载并执行插件
  31. function loadPlugin(pluginPath) {
  32.   try {
  33.     const pluginCode = fs.readFileSync(pluginPath, 'utf8');
  34.     const pluginName = path.basename(pluginPath, '.js');
  35.     const vm = createPluginSandbox(pluginName);
  36.     const plugin = vm.run(pluginCode);
  37.     // 初始化插件
  38.     if (typeof plugin.init === 'function') {
  39.       plugin.init();
  40.     }
  41.     console.log(`插件${pluginName}加载成功`);
  42.     return plugin;
  43.   } catch (err) {
  44.     console.error('插件加载失败:', err.message);
  45.     return null;
  46.   }
  47. }
  48. // 3. 插件代码(sandbox_plugins/demo-plugin.js)
  49. /*
  50. module.exports = {
  51.   init() {
  52.     pluginService.log('插件初始化');
  53.     const data = pluginService.getData();
  54.     pluginService.log(`用户数据: ${JSON.stringify(data)}`);
  55.     const date = utils.formatDate(Date.now());
  56.     pluginService.log(`当前时间: ${date}`);
  57.     const num = utils.random(1, 100);
  58.     pluginService.log(`随机数: ${num}`);
  59.   },
  60.   execute: (params) => {
  61.     pluginService.log(`执行任务: ${params}`);
  62.     return `插件处理结果: ${params.toUpperCase()}`;
  63.   }
  64. };
  65. */
  66. // 4. 运行
  67. const plugin = loadPlugin(path.join(__dirname, 'sandbox_plugins/demo-plugin.js'));
  68. if (plugin) {
  69.   const result = plugin.execute('vm2-plugin-test');
  70.   console.log('主程序接收:', result);
  71. }
复制代码

七、常见问题排查

沙箱内访问不到主进程变量:这是设计使然,默认完全隔离,需要把对象通过 sandbox 白名单显式注入,且只传必要字段。

异步代码超时不生效:timeout 只约束同步执行,异步任务要在外部用 Promise.race 自行兜底:
  1. const vm = new VM({ allowAsync: true });
  2. const code = 'new Promise(resolve => setTimeout(() => resolve("done"), 5000))';
  3. // 手动超时控制
  4. Promise.race([
  5.   vm.run(code),
  6.   new Promise((_, reject) => setTimeout(() => reject(new Error('异步超时')), 2000))
  7. ]);
复制代码

怀疑存在逃逸风险:先确认 vm2 版本是否已升级到最新,再逐项检查 require 白名单和 deny 名单,关闭 eval 与 wasm,并在生产环境叠加 worker_threads 等进程级隔离。

八、小结

VM 适合轻量同步场景,NodeVM 满足需要模块加载的复杂业务,VMScript 解决重复执行的性能问题。三者组合使用时,安全边界仍然取决于配置:最小权限、最新版本、深度防御缺一不可。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-26 16:27 , Processed in 0.022427 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部