简单了解Node.js沙箱环境并分析VM2实现原理

0xGeekCat · 2020-9-14 · 次阅读


动态执行脚本的场景

在一些应用中,会给用户提供插入自定义逻辑的能力

  • Microsoft Office中的 VBA
  • 一些游戏中的 lua 脚本
  • FireFox 的油猴脚本

这些能让用户在可控的范围和权限内发挥想象做一些好玩、有用的事情,扩展能力,满足用户的个性化需求

大多数都是一些客户端程序,但在一些在线的系统和产品中也常常也有类似的需求,事实上在线的应用中也有不少提供了编写自定义脚本的能力,比如Google Docs中的 Apps Script,它可以让用户使用 JavaScript 做一些有用的事情,比如运行代码来响应文档打开事件或单元格更改事件

运行在用户电脑中的客户端应用不同,用户的自定义脚本通常只能影响用户自已,而对于在线的应用或服务来讲,安全就变得更为重要,用户的自定义脚本必须严格受到限制和隔离,即不能影响到宿主程序,也不能影响到其它用户。

而Safeify就是一个针对Node.js应用,用于安全执行用户自定义的非信任脚本的模块

安全的执行动态脚本

👇使用eval()在JavaScript程序中动态执行代码

console.log(eval('1 + 2')) → 3

eval 是全局对象的一个函数属性

screenshot

eval()执行的代码拥有和应用中其它正常代码一样的权限,能访问执行上下文中的局部变量,也能访问所有全局变量

screenshot 1

上一篇文章提到的Function构造函数可以动态创建函数

screenshot 7

screenshot 8

let sum = Function('a', 'b', 'console.log(a + b)')
sum(1, 2) → 3

Function构造的函数只能在全局作用域中运行,并不能调用上下文的局部变量

screenshot 2

结合ES6的新特性Proxy

function evalute(code,sandbox) {
    sandbox = sandbox || Object.create(null);
    const fn = new Function('sandbox', `with(sandbox){return (${code})}`);
    const proxy = new Proxy(sandbox, {
        has(target, key) {
            // 欺骗让动态执行的代码使其认为属性存在
            return true;
        }
    });
    return fn(proxy);
}
console.log(evalute('1+2'))
evalute('console.log(1)')

screenshot 4

无论 eval 还是 function,执行时如果找不到所需的属性,就会把作用域一层一层向上查找,一直到 global;若此时在sandbox中通过has()欺骗程序,告诉它这个不存在的属性是存在的,那么程序就会直接在sandbox中调用属性,进而发生错误

👇没设置has()进行欺骗当前程序

function evalute(code,sandbox) {
    sandbox = sandbox || Object.create(null);
    const fn = new Function('sandbox', `with(sandbox){return (${code})}`);
    const proxy = new Proxy(sandbox, {
    });
    return fn(proxy);
}
console.log(evalute('1+2')) → 3
evalute('console.log(1)') → 1

VM内建模块

VM是Node.js默认提供的内建模块,VM 模块提供了一系列用于在V8虚拟机环境中编译和运行代码的API。JavaScript代码可以被编译并立即运行编译、保存然后再运行

const vm = require('vm');
const script = new vm.Script('m + n');
const sandbox = { m: 1, n: 2 };
const context = new vm.createContext(sandbox);
console.log(script.runInContext(context)); → 3

❗️在Node.js通过vm.runInContext看似隔离了代码执行环境,但实际上却很容易逃逸

const vm = require('vm');
const script = new vm.Script('this.constructor.constructor("return process")().exit()');
script.runInThisContext()

console.log('0xGeekCat')

screenshot 3

❗️在之前文章中研究过this.constructor指的是Object,且Object的构造函数是Function

console.log(this) → {}
console.log(this.constructor) → [Function: Object]
console.log(this.constructor.constructor) → [Function: Function]

👇对this.constructor.constructor("return process")().exit()的理解

this.constructor.constructor("return process")().exit()
↓
Object.constructor("return process")().exit()
↓
Function("return process")().exit()
↓
process.exit()

👇简单处理就能避免通过 this.constructor 拿到 process

//创建无__proto__的空白对象作为sandbox
const sandbox = Object.create(null);

但即便如此也依然有风险,由于JavaScript本身的动态特点,各种黑魔法防不胜防。事实上Node.js的官方文档中也提到不要把 VM 当做一个安全的沙箱,去执行任意非信任的代码

VM2内建模块

在社区中有一些开源的模块用于运行不信任代码,例如sandboxvm2jailed

相较而言vm2对各方面做了更多的安全工作。从vm2的官方README中可以看到,它基于Node.js内建的VM模块建立基础的沙箱环境,同时使用ES6Proxy技术来防止沙箱脚本逃逸

vm API

vm2是在vm的基础上实现的沙箱,所以内部调用的还是vm的API

在vm中运行一个沙箱环境

screenshot 5

👇沙箱代码

const vm = require('vm')

const context = {
    animal: 'cat',
    count: 0
}

// 编译一段代码
const script = new vm.Script(`count += 1; name = "0xGeekCat"`)

// 创建一个上下文隔离对象(沙箱)
vm.createContext(context)

// 在沙箱中运行代码
for (let i = 0; i < 10; i ++) {
    script.runInContext(context)
}

// 打印运行结果
console.log(context) → { animal: 'cat', count: 10, name: '0xGeekCat' }

当然也可以直接在沙箱中运行代码;如果不提供上下文变量,vm会自己创建一个隔离的上下文

const vm = require('vm')
console.log(vm.runInNewContext(`let a = 2; a`)) → 2

🔔vm中最关键的就是上下文context ,vm能逃逸的原理就是因为context并没有拦截针对外部的constructor__proto__等属性的访问

vm2 API

vm的代码包中主要有四个文件

  • cli.js 实现vm2的命令行调用
  • contextify.js 封装了对象,❗️并针对global的Buffer类进行代理
  • main.js vm2执行的入口,导出NodeVM, VM这两个沙箱环境,还有一个VMScript实际上是封装了vm.Script
  • sandbox.js针对global的一些函数和变量进行hook

vm2相比vm做了很大的改进,其中之一就是利用了ES6新增的proxy特性,从而拦截对诸如constructor__proto__等属性的访问

在vm2中运行👇代码

const {VM, VMScript} = require('vm2')

const script = new VMScript(`let a = 2; a`)
console.log(new VM().run(script)) → 2

VM是vm2在vm的基础上封装的虚拟机,只需要将其实例化之后调用run方法即可运行脚本

vm2运行原理

将👆代码拆分开方便分析运行原理

const {VM, VMScript} = require("vm2");

const script = new VMScript("let a = 2;a");

let vm = new VM();

console.log(vm.run(script));

screenshot 6

vm2最核心的操作就在于针对context的封装

vm2封装上下文

👇将vm.createContext创建的上下文作为参数传入。其中引入contextify.js的代码比较独特,是调用vm的API将contextify.js封装为一个匿名函数

screenshot 7

host传入一些需要用的对象

screenshot 8

❓vm2中的contextify.js究竟做了什么

最开始定义了一些常量,并且在global和this上添加了相应属性

screenshot 9

ContextifyDecontextify都是WeakMap

screenshot 10

WeakMapES6新增的语法,只接受对象作为键名,并且为了防止内存泄漏,这些对象是不会被计入垃圾回收机制

Contextify.readonly 做了些什么

screenshot 11

👇函数调用图

screenshot 12

❓为什么需要调用这么多层方法,最后返回的又是什么

从最后一个调用的方法Contextify.object可以很清楚地看到最后返回了一个代理对象,并且其中还有一个Object.assign的操作

screenshot 13

Object.assign方法用于将所有可枚举属性的值从一个或多个source对象复制到target对象,若属性相同则会对target已有属性进行覆盖;最终返回target对象

screenshot 14

Line: 407}, traps, deepTraps));可以得知deepTraps [cover] traps [cover] {get:..., set: ..., ...}

👇查看deepTraps属性

screenshot 15

set, setPrototypeOf ..等方法返回值都是false,也就是说调用Buffer.a = 1时,会被Proxyset trap拦截

👇查看traps属性

screenshot 16

这些方法并不会返回false,但是也会在合并时覆盖掉前一个对象的get和getPrototypeOf

最终得到一个经过Proxy包装的Buffer对象

screenshot 17

代码举例

main.js代码

const {VM, VMScript} = require('vm2');
const {readFileSync} = require('fs');
const file = `${__dirname}/sandbox.js`;

// By providing a file name as second argument you enable breakpoints
const script = new VMScript(readFileSync(file), file);

console.log(new VM().run(script));

sandbox.js代码

let a = Buffer.from(""); // 访问Buffer的from属性并调用
a.i = () => {}; // 给对象添加属性
console.log(a.i); // 访问对象的属性

sandbox.jsLine: 1 & 2下断点

前文提到在VM2Buffer被封装为Proxy对象,访问其所有属性都会被拦截

screenshot 18

👇VM2内部函数调用流程

screenshot 19

访问Proxy对象Bufferfrom属性时会被get trap(用于处理对对象属性读取的操作)拦截,经过层层的调用后最终返回一个Proxy对象

在沙箱中调用Buffer.from会被apply捕获

screenshot 20

👇from函数调用流程

screenshot 21

根据Proxy规范,target是未代理之前的函数,context是函数当前运行的上下文,当前为Buffer的代理,args是函数的参数,当前是""

实际上 Decontextify 的实现和 Contextify 是对称的,只是略微有一点细节上的区别。Decontextify.value 首先会检查 Contextified 中是否有这个对象,如果有直接返回,否则也会针对其进行一层代理

从函数调用过程中看到虽然vm2针对很多对象做了代理,但是当实际要发生一次函数调用的时候,必须要将代理的外壳给剥除掉,并且必须依靠nodejs提供的API来完成,而如果能够捕获到这个被剔除代理的对象,那么就能完成vm2的逃逸,这是vm2沙箱逃逸的核心原理

❌之后部分并未完全理解原文作者意图

reference

为 Node.js 应用建立一个更安全的沙箱环境

vm2实现原理分析