VM沙盒逃逸
什么是沙箱
就只针对于node.js而言,沙箱和docker容器其实是差不多的,都是将程序与程序之间,程序与主机之间互相分隔开,但是沙箱是为了隔离有害程序的,避免影响到主机环境。为什么node.js语言要引入沙箱,这就要说说js语言中的作用域(也叫上下文)。
1 | 在JavaScript中,上下文(context)是指代码执行时的环境。每段JavaScript代码都运行在一个特定的上下文中,其中包含了当前代码的变量、函数、对象等信息。 |
我们来看一个例子
1 | const a = require("./a") |
若没有exports将需要的属性暴露出来,我们是访问不到另一个包内的属性的。包与包之间是互不相通的,也就是说每一个包都有自己的作用域。我们知道JavaScript的全局变量是window。其中所有的属性都是挂载到这个window下的,当然,node也有全局变量,是global。全局变量能在包间访问,换句话说,所有的包都挂载在全局变量下。node执行rce需要引入process对象进而导入child_process模块来执行命令。然而,process是挂载到global上的。为了防止恶意代码影响主机环境,所以就引入沙箱,开辟一个新的作用域来运行不信任的代码。相较于其他作用域,它阻止我们从内部直接访问global全局变量。此后的逃逸也是在这个点做文章。
VM模块的作用
引入vm模块就是为了创建一个沙箱运行环境。先看一段代码:
1 | const util = require('util'); |
vm.createContext函数,创建一个沙箱对象,在全局变量global外又创建一个作用域。此时sandbox对象就是此作用域的全局变量。vm.runInContext函数,第一个参数是沙箱内要执行的代码,第二个是沙箱对象。还有一个函数,vm.runInNewContext,是creatContext和runInContext的结合版,传入要执行的代码和沙箱对象。根据代码输出,我们可以看出沙箱内是不能访问到global。
如何逃逸
上文也说明了node要执行命令的前提是访问到process对象。那么逃逸的主要思路就是怎么从外面的global全局变量中拿到process。vm模块是非常不严谨的,基于node原型链继承的特性,我们很容易就能拿到外部全局变量。看一段简单的逃逸代码:
1 | ; |
很明显是逃逸出去了。如何做到的?这里的this是指向传递到runInNewContext函数的一个对象,他是不属于沙箱内部环境的,访问当前对象的构造器的构造器,也就是Function的构造器,由于继承关系,它的作用域是全局变量,执行代码,获取外部global。拿到process对象就可以执行命令了。
再利用这个代码执行命令
1 | const vm = require("vm"); |
console.log会执行node代码,从而调用构造器函数返回process对象导致rce。vm模块的隔离作用可以说非常的差了。所以开发者在此基础上加以完善,推出了vm2模块。那么vm2模块能否逃逸。
VM2沙盒逃逸
vm2基于vm开发,使用官方的vm库构建沙箱环境。然后使用JavaScript的Proxy技术来防止沙箱脚本逃逸。
vm2 特性:
- 运行不受信任的JS脚本
- 沙箱的终端输出信息完全可控
- 沙箱内可以受限地加载modules
- 可以安全地向沙箱间传递callback
- 死循环攻击免疫 while (true)
vm2相比vm做了很大的改进,其中之一就是利用了es6新增的 proxy 特性,从而拦截对诸如 constructor 和 proto 这些属性的访问
首先需要补充一点es6 proxy的知识 https://es6.ruanyifeng.com/?search=weakmap&x=0&y=0#docs/proxy
实例1
1 | ; |
先看一段代码:
1 | var handler = { |
在对象 target
上定义了 get
操作,会拦截对象属性的读取,所以当访问 proxy.a
时,会打印出 get
但是当执行 "" in proxy
时,也会被 has
方法拦截,此时,我们虽然没有直接在 target
对象上定义 has
拦截操作,即代理的方法是可以被继承的。
回到vm2逃逸的代码,vm2中实际运行的代码如下:
1 | ; |
Buffer.from
是一个代理对象,vm2的作者一开始并没有给vm2内部的Object 加上 has方法,所以我们可以自己给 Object
对象的原型上添加 has
方法,这时候运行
1 | "" in Buffer.from; |
就会去执行我们定义好的has方法,由于 proxy
的机制,参数 t
是 function Buffer.from
,这个function是在外部的,其上下文是 nodejs 的global下,所以访问其 constructor
属性就获取到了外部的 Function
,从而拿到外部的 process
官方给的修复方法:给Buffer.form添加has方法,这样has方法就直接会调用自己的has方法,而不是去调用原型的has方法
实例2
1 | ; |
同样地,需要补充一点js的知识:
js的对象中,存在三种不同的属性:数据属性,访问器属性和内部属性。我们只看数据属性和访问器属性
数据属性和访问器属性都存在 [[Enumerable]]
和 [[Configurable]]
特性
不同点:以下特性属于数据属性:
[[Value]]
:该属性的属性值,默认为undefined
。[[Writable]]
:是一个布尔值,表示属性值(value
)是否可改变(即是否可写),默认为true
。
以下特性属于访问器属性
[[Get]]
:是一个函数,表示该属性的取值函数(getter),默认为undefined
[[Set]]
:是一个函数,表示该属性的存值函数(setter),默认为undefined
1 | var obj = { |
我们也可以通过 Object.defineProperty
来设置对象的访问器属性
1 | let obj = {}; |
我们还可以这样写
1 | let obj = {}; |
在这种情况下,会先执行 get()
函数,打印 get1
,返回一个函数,作为 prop
属性的 getter,之后访问 obj.prop
时,就会打印 get2
1 | get(){ |
同理:
1 | let obj = {}; |
此时会先执行一次 set()
函数打印出 set1
,同时设置 prop
属性的 setter 为 (val)=>{console.log("set2")}
之后执行 obj.prop = 1
时,就会打印 set2
;
那么回过头来看vm2逃逸的代码
1 | var process; |
执行的过程如下:
参考前文 vm2 实现原理分析,此时得到的a是一个代理对象,当我们在a上定义新属性的时候,被代理的 defineProperty
拦截