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 及以上版本。安装方式:
- # 项目内安装
- npm install vm2 --save
- # 全局安装(含 CLI)
- npm install -g vm2
复制代码
vm2 对外暴露三个类,按需引入即可:
- // CommonJS
- const { VM, NodeVM, VMScript } = require('vm2');
- // ES Module
- 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,基础计算与注入变量:
- const { VM } = require('vm2');
- // 1. 创建沙箱实例
- const vm = new VM({
- timeout: 1000, // 1秒超时
- allowAsync: false,
- sandbox: {
- // 注入安全全局变量
- PI: 3.14159,
- add: (a, b) => a + b
- }
- });
- // 2. 运行代码(同步)
- try {
- // 基础计算
- const res1 = vm.run('1 + 2 * 3');
- console.log('计算结果:', res1); // 输出:7
- // 使用注入变量
- const res2 = vm.run('add(PI, 1)');
- console.log('注入变量计算:', res2); // 输出:4.14159
- // 访问被禁止的全局对象(报错)
- vm.run('process.exit()');
- } catch (err) {
- console.error('错误:', err.message);
- // 输出:ReferenceError: process is not defined
- }
复制代码
示例 2,把沙箱内的 console 重定向到自定义对象,只放行 log:
- const { VM } = require('vm2');
- const vm = new VM({
- console: 'redirect', // 重定向控制台
- sandbox: {
- // 自定义console,仅允许log
- console: {
- log: (...args) => console.log('[沙箱日志]', ...args)
- }
- }
- });
- // 沙箱内console会被重定向
- vm.run('console.log("Hello vm2!")');
- // 输出:[沙箱日志] Hello vm2!
复制代码
原生 vm 与 vm2 的差异可以用同一段逃逸代码验证。原生 vm 下进程会被直接杀死:
- // 原生 vm(危险)
- const vm = require('vm');
- const ctx = {};
- vm.createContext(ctx);
- // 逃逸代码:获取主进程process并终止
- vm.runInContext('this.constructor.constructor("return process")().exit()', ctx);
- // 进程直接被杀死!
复制代码
同样的代码在 vm2 中会被拦截并抛出 TypeError:
- // vm2(安全)
- const { VM } = require('vm2');
- const safeVM = new VM();
- // 同样代码会被拦截
- safeVM.run('this.constructor.constructor("return process")().exit()');
- // 抛出:TypeError: constructor is not a function
复制代码
三、NodeVM 类:支持模块加载
NodeVM 的核心风险点集中在 require 配置上,应遵循最小权限原则:
builtin:允许加载的内置模块白名单;
external:是否允许加载外部模块,可取 true、false 或数组;
root:外部模块根目录,用于限制访问范围;
deny:显式禁止加载的模块名单;
mapping:模块别名映射。
示例 1,只放开 fs 和 path:
- const { NodeVM } = require('vm2');
- const vm = new NodeVM({
- console: 'inherit',
- require: {
- builtin: ['fs', 'path'] // 仅允许fs、path
- }
- });
- // 沙箱内读取文件(安全)
- const code = `
- const fs = require('fs');
- const path = require('path');
- const filePath = path.join(__dirname, 'test.txt');
- fs.writeFileSync(filePath, 'vm2 write test');
- const content = fs.readFileSync(filePath, 'utf8');
- module.exports = content;
- `;
- const result = vm.run(code);
- console.log('文件内容:', result); // 输出:vm2 write test
- // 尝试加载禁止模块(报错)
- vm.run('require("child_process")');
- // 抛出:Error: Access denied to module child_process
复制代码
示例 2,通过 module.exports 导出对象,实现主进程与沙箱之间的双向调用:
- const { NodeVM } = require('vm2');
- const vm = new NodeVM({
- console: 'inherit',
- require: { external: false }
- });
- // 沙箱代码导出函数
- const pluginCode = `
- module.exports = {
- greet: (name) => {
- console.log('Greet from sandbox');
- return `Hello, ${name}`;
- },
- calculate: (a, b) => a * b
- };
- `;
- // 运行并获取导出对象
- const plugin = vm.run(pluginCode);
- // 调用沙箱函数(主进程 ←→ 沙箱通信)
- console.log(plugin.greet('Node.js')); // 输出:Hello, Node.js
- console.log(plugin.calculate(5, 10)); // 输出:50
复制代码
四、VMScript 预编译优化
同一段代码需要反复执行时,每次 run 都要重新解析,开销较大。VMScript 把编译结果缓存下来,执行阶段只做运行。
- const { VM, VMScript } = require('vm2');
- // 1. 预编译脚本(编译一次)
- const script = new VMScript(`
- let sum = 0;
- for(let i=0; i<10000; i++) sum += i;
- sum;
- `, {
- filename: 'loop-script.js' // 调试文件名
- });
- // 2. 创建沙箱
- const vm = new VM({ timeout: 500 });
- // 3. 多次运行预编译脚本(高效)
- console.time('执行10次');
- for(let i=0; i<10; i++) {
- const res = vm.run(script);
- console.log(`第${i+1}次结果:`, res);
- }
- console.timeEnd('执行10次');
复制代码
预编译同样可以用于 NodeVM 模块。下面的例子把 MD5 计算封装成沙箱模块,只开放 crypto:
- const { NodeVM, VMScript } = require('vm2');
- // 预编译模块代码
- const moduleScript = new VMScript(`
- const crypto = require('crypto');
- module.exports = (str) => crypto.createHash('md5').update(str).digest('hex');
- `);
- const vm = new NodeVM({
- require: { builtin: ['crypto'] }
- });
- // 运行预编译模块
- const md5 = vm.run(moduleScript);
- 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)做深度防御。
一个可直接用于生产的配置模板:
- const { NodeVM } = require('vm2');
- const secureVM = new NodeVM({
- // 基础安全
- timeout: 3000,
- eval: false,
- wasm: false,
- allowAsync: true,
- // 控制台
- console: 'redirect',
- sandbox: {
- console: {
- log: (...args) => console.log('[SANDBOX]', ...args),
- warn: (...args) => console.warn('[SANDBOX]', ...args)
- }
- },
- // 严格模块控制
- require: {
- builtin: ['path', 'crypto', 'buffer'], // 最小内置模块
- external: false, // 禁止外部模块
- deny: ['fs', 'os', 'process', 'child_process'], // 黑名单
- root: './' // 限制根目录
- },
- // 错误捕获
- onCancel: () => console.error('沙箱代码超时被终止')
- });
复制代码
六、实战:安全插件系统
需求是主程序加载第三方插件,插件只能访问指定 API,且不能逃逸破坏主进程。实现上把沙箱创建、插件加载拆成两个函数:
- const { NodeVM } = require('vm2');
- const fs = require('fs');
- const path = require('path');
- // 1. 安全沙箱工厂
- function createPluginSandbox(pluginName) {
- return new NodeVM({
- console: 'inherit',
- timeout: 2000,
- eval: false,
- require: {
- builtin: ['path'],
- external: false
- },
- // 注入插件可用API(白名单)
- sandbox: {
- pluginName,
- // 安全工具函数
- utils: {
- formatDate: (date) => new Date(date).toLocaleString(),
- random: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min
- },
- // 主程序提供的服务
- pluginService: {
- log: (msg) => console.log(`[插件${pluginName}]`, msg),
- getData: () => ({ user: 'admin', role: 'guest' })
- }
- }
- });
- }
- // 2. 加载并执行插件
- function loadPlugin(pluginPath) {
- try {
- const pluginCode = fs.readFileSync(pluginPath, 'utf8');
- const pluginName = path.basename(pluginPath, '.js');
- const vm = createPluginSandbox(pluginName);
- const plugin = vm.run(pluginCode);
- // 初始化插件
- if (typeof plugin.init === 'function') {
- plugin.init();
- }
- console.log(`插件${pluginName}加载成功`);
- return plugin;
- } catch (err) {
- console.error('插件加载失败:', err.message);
- return null;
- }
- }
- // 3. 插件代码(sandbox_plugins/demo-plugin.js)
- /*
- module.exports = {
- init() {
- pluginService.log('插件初始化');
- const data = pluginService.getData();
- pluginService.log(`用户数据: ${JSON.stringify(data)}`);
- const date = utils.formatDate(Date.now());
- pluginService.log(`当前时间: ${date}`);
- const num = utils.random(1, 100);
- pluginService.log(`随机数: ${num}`);
- },
- execute: (params) => {
- pluginService.log(`执行任务: ${params}`);
- return `插件处理结果: ${params.toUpperCase()}`;
- }
- };
- */
- // 4. 运行
- const plugin = loadPlugin(path.join(__dirname, 'sandbox_plugins/demo-plugin.js'));
- if (plugin) {
- const result = plugin.execute('vm2-plugin-test');
- console.log('主程序接收:', result);
- }
复制代码
七、常见问题排查
沙箱内访问不到主进程变量:这是设计使然,默认完全隔离,需要把对象通过 sandbox 白名单显式注入,且只传必要字段。
异步代码超时不生效:timeout 只约束同步执行,异步任务要在外部用 Promise.race 自行兜底:
- const vm = new VM({ allowAsync: true });
- const code = 'new Promise(resolve => setTimeout(() => resolve("done"), 5000))';
- // 手动超时控制
- Promise.race([
- vm.run(code),
- new Promise((_, reject) => setTimeout(() => reject(new Error('异步超时')), 2000))
- ]);
复制代码
怀疑存在逃逸风险:先确认 vm2 版本是否已升级到最新,再逐项检查 require 白名单和 deny 名单,关闭 eval 与 wasm,并在生产环境叠加 worker_threads 等进程级隔离。
八、小结
VM 适合轻量同步场景,NodeVM 满足需要模块加载的复杂业务,VMScript 解决重复执行的性能问题。三者组合使用时,安全边界仍然取决于配置:最小权限、最新版本、深度防御缺一不可。 |