几则NodeJS的安全问题

笔者在2019年为StarCTF(*CTF)出题的时候,发现了MongoDB提供的原生接口存在一些问题。在这之后经过与参赛选手的探讨,发现以上的问题其实比较有趣。接下来我将把产生这个问题的根源进行剖析,并记录一下由此发现的其他几个node modules的问题。

starctf-996game & calcgame (RCTF/0CTF/De1CTF) & MarxJS(RealworldCTF 2019)

996game这道题目我出完最开始觉得并不怎么好,因为说实话他是一个半成品(因为出题时间deadline,不能去深入挖掘)。HTML5游戏近几年也是比较火爆,因为它具有很强的跨平台兼容性。我当时就秉承着这么一个思想,想把游戏安全引入CTF Web题目中(当然,我并不是第一个这么干的人,之前很多CTF比赛都有游戏安全,举个国内的例子HCTF,笔者也是当年有幸拿到一血)。于是我便开始搜索比较热门的HTML5游戏框架或者源代码,最终在github锁定了phaserquest,虽然不是很热门,但因为比较老而且很久没更新,我觉得我很可能会发现其中存在的问题。一开始就发现了很多可以致使游戏崩溃的bug,但是我最后是被MongoDB有关的数据交互吸引了。

ObjectId()结果可控

发现问题的一句是


这里的id是完全可以由client控制的,我就在想会不会经过ObjectId之后结果仍然可控呢? 于是带着这份好奇就跟了进去,发现在ObjectId的函数流程中,有使用Javascript的特定方法取得输入的长度。
https://github.com/mongodb/js-bson/blob/V1.0.4/lib/bson/objectid.js#L28

我们都知道Javascript的变量类型String和Array,具有length这个原生属性,它标志着字符串的长度或数组的大小。那么,如果变量类型是Object呢? Object不存在原生的length属性,但如果开发者没有判断变量的类型,盲目的取objectxxx.length的话,就会取objectxxx对应键length下的内容。

To illustrate

var a = {"length":888, "name":"wupco"};
console.log(a.length);
//888

所以正是因为开发者没有考虑这个问题,可能带来很多逻辑上的问题,比如上面这个ObjectId的函数流程中,如果id.length == 12,就会直接返回true,代表是一个合法的ObjectID的格式,实际上,我们可以传入一个Object {"id":{"length":12}}来绕过这个函数。

然后接下来我们继续看

如果id存在toHexString方法的话,就直接返回id原来的内容。于是经过ObjectId这个函数的变化,我们的任何数据都没有被更改转化。(注意上面一句else if 用的是 id.length===12,所以可以用"12"字符串形式来绕过)

MongoDb的bson序列化问题

MongoDb的用户查询语句是如何进行传递的呢?这中间其实经过了一步bson序列化过程,这也是MongoDb独有的序列化过程。
它的具体代码可以在 https://github.com/mongodb/js-bson/blob/V1.0.4/lib/bson/parser/serializer.js
看到。
这个bson序列化的过程是遵循统一的一个标准来进行的,不止NodeJS存在这样的库,PHP,Python等常见编程语言都提供了这样的库。他们的具体流程都大致为
1. 判断查询语句类型。
2. 根据类型进行封装,同时加上特有的数据头部标识。

其中NodeJs的相应库很有意思

他检测了数据是否有_bsontype这个属性,然后根据这个属性来进行不同的序列化过程。于是我们很容易想到可以利用不同数据的格式特性,来打造不同的Object,序列化成我们想要的结果。例如上面的ObjectId函数处理结果仍然是一个Object,但是我们这样丢到Object对应的序列化函数中,产生的结果再传入MongoDb引擎里进行query解析,结果一定是会出错的,我们就需要换一种思路让它不会报错,就是用_bsontype控制它到其他分支去,利用序列化过程中的信息剔除,将我们欲绕过ObjectId构造的length等信息去掉。

在RCTF,0CTF等一系列的calc题目中,我们可以看到它所利用的点就是这里的问题。
https://github.com/zsxsoft/my-ctf-challenges

一个比较有趣的新问题 (应用在realworldCTF2019线下赛MarxJS)

StarCTF之后,在和选手的讨论中得知选手可以在996game中任意登陆第一个用户的账号,我们一起对原因进行了检查。

发现是由于在bson序列化的过程中,他传入的是一个不存在的_bsontype,然后在那些分支都走过之后,因为没有对应的bsontype,所以最终没有序列化任何query,于是造成了findone({})这种查询条件为空的情况,成功选中第一个用户

这个Bug十分有意义。例如在一个找回密码的场景,需要输入一个比较特别的id的时候findone({"userid":userinput)
这时候我们就可以利用这个技巧,让查询语句变成findone({}),从而更改第一个用户的密码,而第一个用户大多是admin用户。

nodemailer

有了上面这个case之后,我又在和别人的一个合作项目中负责看了下别的库的相似问题。接下来我会讲讲我发现的nodemailer这个库。

我是在寻找使用MongoDb的一些上层框架,在一个Web框架中发现它使用了nodemailer搭配MongoDb来实现找回密码的功能。

大致的情况与我在RealWorld CTF2019出的题目相似。
题目附件
https://github.com/5lipper/ctf/tree/master/rwctf19-final/marxjs
相关代码如下

const user = await getMongoRepository(User).findOne({ email: ctx.request.body.email });
      if (!user) {
          return new HttpResponseBadRequest('user not found');
      }
      const newpass = await generateToken();
      const passhash = await hashPassword(newpass);
      const res = await getMongoRepository(User).updateOne(
        { email: ctx.request.body.email }, { $set: { password: passhash}});
      if (!res) {
          return new HttpResponseInternalServerError('something error.');
      }
      const transporter = createTransport(
        Config.get('mailserver')
     );
      const message = {
      from: Config.get('mailfrom'),
      to: ctx.request.body.email,
      subject: '[😁] New password',
      text: 'Your new password: ' + newpass

    };

      const info = await transporter.sendMail(message);
      return new HttpResponseRedirect('/signin');

我们已经知道MongoDb可以通过传入不存在的bsontype来选中第一个用户,但是这里ctx.request.body.email如果是一个Object,怎么让它把邮件发送到你可以控制的邮箱里呢?

通过审计nodemailer(https://github.com/nodemailer/nodemailer)的代码,我发现如果to这个object存在address这个属性,那么就会向address对应的地址发送邮件。

然而这个用法并没有在官方文档提及到,所以开发者自然不会意识到有这种问题。

从而这个利用payload就可以为{"email":{"address":"your email address","_bsontype":"a"},...}
任意修改第一个用户密码

class-validator

这个库的问题其实很有趣。早在0CTF2019 Final,我们就可以看到RR师傅出了一个有关nestjs的漏洞的题。
https://github.com/zsxsoft/my-ctf-challenges/blob/master/calcalcalc-family/2.md

https://github.com/nestjs/nest/commit/b3f51cc9c110017ebc8f6ec12da6b92d96f37caa#diff-11537d189f48488671d5be4d1c5daffeR97

但实际上,造成这个问题的本身原因是class-validator. 我们也可以看到nestjs的patch仅仅为

class-validator这个数据类型检测器被广泛使用在各个Web框架里,通常与Body解析器构成触发式验证。但是如果Body中存在proto这个键的话,就会直接跳过验证。

这里有个我之前针对calcgame的分析
https://hackmd.io/@ZzDmROodQUynQsF9je3Q5Q/S1kUedPdS

这一点我也应用到RealworldCTF 2019中了

request

https://github.com/request/request
request库存在参数har可以覆盖请求方式等参数。

RealWorldCTF 2019 Final中

async checkstatus(ctx: Context) {
    await rp.head(ctx.request.body.url).then(() => { this.status = true; },
        () => { this.status = false; } );
    return new HttpResponseRedirect(this.status ? '/admin?alive=true' : '/admin?error=true');
  }

可以通过传入 {"url":{"har":"POST"}} 更改head请求方式为POST

pomelo

pomelo是一个非常老的网易的HTML5游戏框架
https://github.com/NetEase/pomelo
但是其handler存在比较明显的问题,可导致服务器崩溃或其他问题。
https://github.com/NetEase/pomelo/blob/5f7bec1c9be5fa2f88a366b15085cf64d4e786d7/lib/common/service/handlerService.js#L59
它根据客户端传过来的route a.b.c以及msg d,用来调用任意一个a.b.c(d)。于是存在很多可以down掉服务端的办法,甚至可能是RCE。
我当时寻找到的例子是
https://github.com/NextZeus/lordofpomelo

通过pomelo.request('connector.entryHandler.constructor', { get:{} }
调用到下面这段app初始化代码

var Handler = function(app) {
    this.app = app;

    if(!this.app)
        logger.error(app);
};

this.app赋值为{"get":{}}
这样服务器每当调用get方法的时候,就会报错。因为这里的get覆盖掉了app原生的get方法。

json-sql

json-sql是一个database前置中间件,它为开发者提供了可以形成SQL查询语句的接口
https://github.com/2do2go/json-sql
但是通过审计代码笔者发现这个库存在着问题,如果用户输入是一个Object,那么将有机会形成可以SQL注入的语句。(注意这个库正常是将动态查询参数作为预编译进行处理的)

下面是例子,注意name和id同为查询的condition,正常来说,应该是name所对应的情况,也就是应该做预编译处理。
但是如果我对id输入一个Object,同时指定了两个hidden parameters (cast 或 alias),就形成了可以SQL注入的机会(直接改变原始SQL语句)

var jsonSql = require('json-sql')();

function testselect(query) {
    var sql = jsonSql.build({
        type: 'select',
        table: 'users',
        fields: ['name', 'age'],
        condition: query
    });
    console.log(sql);
}

testselect({"name":"wupco","id":{"cast":"aaa'\"bbb"}});
console.log("\n[*] result of hipar : alias\n");
testselect({"name":"wupco","id":{"alias":"aaa'\"bbb"}});
/*
[*] result of hipar : cast
{
  query: `select "name", "age" from "users" where "name" = $p1 and cast("id" as aaa'"bbb);`,
  values: { p1: 'wupco' },
  prefixValues: [Function: prefixValues],
  getValuesArray: [Function: getValuesArray],
  getValuesObject: [Function: getValuesObject]
}
[*] result of hipar : alias
{
  query: `select "name", "age" from "users" where "name" = $p1 and "id" as "aaa'"bbb";`,
  values: { p1: 'wupco' },
  prefixValues: [Function: prefixValues],
  getValuesArray: [Function: getValuesArray],
  getValuesObject: [Function: getValuesObject]
}
*/

分析总结

上面几个问题都是Javascript语言特性带来的问题。当然大部分和当代Node Web框架脱离不了干系,可以很轻松的转换数据传输方式(大部分Web框架接受多种数据传输方式,特别是只需要更改request头部的content-type为json就可以使用json来传输数据)。这样存在的问题是大部分Web框架其实是通过Object来处理信息的(因为都是将json解析成Object)。

不合理使用node modules,或者各个modules官方文档没有给出足够的说明,就会导致漏洞的产生,这种漏洞大多是dos漏洞或者逻辑漏洞。

在使用nodejs进行开发的时候,要注意时刻检查输入类型,不能轻信第三方validator或check,这些说不定本身就存在问题,例如class-validator,是使用最多的数据检查器,但却被轻而易举的绕过,并且还不修复。

其他

其实上面很多bug都很简单,简单到不能算作漏洞,但是组合起来可能就造成严重漏洞。

  • 用支付宝打我
  • 用微信打我

发表评论

电子邮件地址不会被公开。 必填项已用*标注