如何崩溃一个 vm

简介

vm 是 nodejs 的一个核心模块,使用 vm 模块可以在 v8 虚拟机上下文中编译运行代码。JavaScript 代码可以被立即编译运行,也可以编译保存稍后运行。利用 vm 模块,我们可以使得 nodejs 动态执行代码,服务的扩展性,动态性更好。但是,官方明确表示 vm 不是安全的机制,不要使用 vm 运行不受信任的代码。那么究竟哪些代码会导致 vm 崩溃呢?

vm 的使用

vm 的使用场景

在介绍 vm 相关的使用方法前,我们先了解下 vm 的使用场景。在一些特定场景下,为用户提供插入自定义逻辑的能力会,可以使应用能够在基本框架不变的情况下,具备更高的可拓展性。比如浏览器中常用的油猴脚本 等。这种方式可以节约应用维护者的开发和维护精力,同时还可以保证应用的活力。

那么在 nodejs 中不是有 child_process 模块了吗?vm 和其对比有什么优缺点呢?child_process 实际上是开启一个子进程执行相关逻辑,由于进程间通信消息序列化,会缺失上下文运行环境,而 vm 则可以将代码在当前上下文执行,进行内存对象级别的参数进行传递。这种能力,使用起来很方便,当然也很危险,一旦执行了危险的代码,可能导致当前进程奔溃。所以使用前我们需要了解些相关的注意事项。下面先看看 vm 有哪些用法吧。

vm.Script 类

vm.Script 类的实例包含可以在特定上下文中执行的预编译脚本。
通过 script 可以编译运行一段代码或者缓存这份代码,常用示例如下

    // 实例化 script
    const code = `const add = (a, b) => a + b; const x = add(1, 2);`;
    const script = new vm.Script(code);
    
    // 获取 script 缓存及使用
    script.runInThisContext();
    const after = script.createCachedData();
    const scriptWithCache = new vm.Script(`add(5, 6);`, {
        cachedData: after,
    });
    const res = scriptWithCache.runInThisContext();
    console.log(res);
    //> 11
    
    // runInContext
    const ctx = {
        animal: 'cat',
        count: 1
    };
    vm.createContext(ctx);
    const runInContextScript = new vm.Script(`animal = 'dog'; count++;`);
    runInContextScript.runInContext(ctx);
    console.log(ctx);
    //> { animal: 'doc', count: 2 }

    // runInNewContext
    const ctxs = [{ count: 0 }, { count: 1 }, { count: 2 }];
    const runInNewContextScript = new vm.Script(`count++;`);
    ctxs.forEach(ctx => {
        runInNewContextScript.runInNewContext(ctx);
    });
    console.log(ctxs);
    //> [ { count: 1 }, { count: 2 }, { count: 3 } ]

    // runInThisContext
    global.count = 0;
    const runInThisContextScript = new vm.Script(`count++;`);
    runInThisContextScript.runInThisContext();
    console.log(global.count);
    //> 1

vm 常用函数

通过 vm.script 可以实现执行 js 代码的功能,vm 也提供了一些可以直接执行代码的方法,无需创建 script 实例,使用示例如下

// compileFunction
    const code = `return a + b;`;
    const func = vm.compileFunction(code, ['a', 'b']);
    const res = func(10, 11);
    console.log(res);
    //> 21

    // createContext
    global.count = 1;
    const ctx = {
        count: 1,
    };
    // 设置此沙盒,从而让它具备在 vm.runInContext() 或者 script.runInContext() 中被使用的能力。 
    // ctx 将会是全局对象,保留其所有现有属性,但还具有任何标准的全局对象具有的内置对象和函数
    vm.createContext(ctx);
    vm.runInContext(`count++;`, ctx);
    console.log(global.count); //> 1
    console.log(ctx.count); //> 2

    // isContext, 判断对象是否是一个 ctx 对象
    const ctxObj = {
        count: 0,
    };
    const normalObj = {
        count: 0,
    };
    vm.createContext(ctxObj);
    let isCtxObj = vm.isContext(ctxObj);
    console.log(isCtxObj); //> true
    isCtxObj = vm.isContext(normalObj);
    console.log(isCtxObj); //> false
    
    // runInContext
    // 和 vm.Script 的 runInContext 类似,可直接由 vm 执行源码
    const context = {
        count: 0,
    } 
    const codeStr = `count++;`;
    vm.createContext(context);
    vm.runInContext(codeStr, context);
    console.log(context.count); //> 1

    // runInNewContext
    const ctxs = [{ count: 0 }, { count: 1 }, { count: 2 }];
    ctxs.forEach(c => {
        vm.runInNewContext(`count++;`, c);
    });
    console.log(ctxs);
    //> [ { count: 1 }, { count: 2 }, { count: 3 } ]

    // runInThisContext
    global.count = 0;
    vm.runInThisContext(`count++;`);
    console.log(global.count); //> 1

timeout 限制

vm 提供了 timeout 来控制代码执行总时长,但是由于所有上下文共享相同的微任务和 nextTick 队列,导致此参数有时候并不能限制执行时长

    // 可以对代码执行时长进行一定的限制
    try {
        vm.runInNewContext(
            `let i = 0;
            const loop = () => {while (1) i++;};
            loop()`,
            {},
            { timeout: 5 }
        );
    } catch (e) {
        console.log(e); //> Error: Script execution timed out.
    }

    // 但是由于所有上下文共享相同的微任务和 nextTick 队列。导致使用微任务或 nexttick 无法限定执行时间
    vm.runInNewContext(
        `
        let i = 0;
        const loop = () => {while (1) console.log(i++);};
        Promise.resolve().then(loop);`,
        { console },
        { timeout: 5 }
    ); //> always loop

    vm.runInNewContext(
        `let i=0;
        setInterval(() => {console.log(i++)});`,
        { console, setInterval },
        { timeout: 5 }
    ); //> always loop

如何崩溃一个 vm

看了基本的 vm 使用案例,那么怎么才能崩溃一个 vm 呢?

异步逃脱 timeout 限制

vm 的 timeout 只对同步执行有效,由于所有上下文共享相同的微任务和 nextTick 队列,导致无法限定此类的执行时间

    vm.runInNewContext(
        `let i = 0;
        const loop = () => {while (1) console.log(i++);};
        Promise.resolve().then(loop);`,
        { console },
        { timeout: 5 }
    ); //> always loop
    console.log(`never output`); //> 没有机会执行

获取 process 对象

可以通过 this.constructor 拿到上下文的构造函数,这是一个 function,根据 function.constructor 可以构造一个函数,由于通过 function.constructor 动态创建的函数会在全局范围内执行,如此一来即可拿到全局的 process 对象,进行危险操作。不过这种类型比较好防范,只需要限定上下文即可。

    vm.runInNewContext(`
        this.constructor.constructor("return process")().exit()
    `); // process exit
    console.log(`never executed`);
    // 获取 require
    vm.runInNewContext(`
        const process = this.constructor.constructor("return process")();
        const require = process.mainModule.require;
        const fs = require('fs');
        console.log(fs);
    `, { console });
    try {
        vm.runInNewContext(`
                this.constructor.constructor("return process")().exit()
            `, 
            Object.create(null)
        );
    } catch(e) {
        console.log(e);
        // ReferenceError: process is not defined
    }
    console.log(`will output`); //> will output

vm2 能防护哪些?

异步 timeout

相比于原生 vm 模块,vm 不会无限循环,后续代码可继续执行,但是异步的任务也不会结束,仍会继续运行

	// 异步逃脱 timeout 限制
    const vm = new VM({
        timeout: 5,
        sandbox: {
            // console,
        }
    });
    vm.run(`
        let i = 0;
        const loop = () => {while (1) console.log(i++);};
        Promise.resolve().then(loop);
    `);
    console.log('will output'); //> will out put 

process 获取

process 确实无法获取,无需特殊操作

    try {
        const vm = new VM();
        vm.run(`
            this.constructor.constructor("return process")().exit()
        `);
    } catch (e) {
        console.log(e); // ReferenceError: process is not defined
    }
    console.log('will output'); //> will out put 

vm2

除了已知的攻击防护外,vm 还做了很多的是对执行不受信任代码的优化,如对 require 功能的拆分,mock 等功能,但是由于 js 单线程的原因,导致有些问题确实无解。

目前看来对于动态执行代码,如果是受信任代码,可使用 vm2 执行,对于不受信任代码,最好还是 fork 一个子进程使用 vm2 运行,还可限定执行使用的资源,避免其与主进程争抢资源

nodejs 代码执行

正常我们执行 node xx.js 又是怎么执行的呢?
忽略一系列的启动步骤不详细说明,最终我们的代码是被 require 至主模块,从内置 module 模块查起更方便

查看 nodejs 源码 Module 模块相关的代码处于 internal/modules/cjs/loader.js 文件,根据执行逻辑,我们顺着 require 函数往下查询

在 wrapSafe 我找到了想看的的代码

function wrapSafe(filename, content, cjsModuleInstance) {
  if (patched) {
    const wrapper = Module.wrap(content);
    return vm.runInThisContext(wrapper, {
      filename,
      lineOffset: 0,
      displayErrors: true,
      importModuleDynamically: async (specifier) => {
        const loader = asyncESM.ESMLoader;
        return loader.import(specifier, normalizeReferrerURL(filename));
      },
    });
  }
....

由此可见,node 运行我们的正常代码,实际上用的也是 vm 模块,对于常使用的 require 等,实际上也是由默认模块所注入的

let wrap = function(script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};

const wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

参考资料