笔者在2019年为StarCTF(*CTF)出题的时候,发现了MongoDB提供的原生接口存在一些问题。在这之后经过与参赛选手的探讨,发现以上的问题其实比较有趣。接下来我将把产生这个问题的根源进行剖析,并记录一下由此发现的其他几个node modules的问题。
996game这道题目我出完最开始觉得并不怎么好,因为说实话他是一个半成品(因为出题时间deadline,不能去深入挖掘)。HTML5游戏近几年也是比较火爆,因为它具有很强的跨平台兼容性。我当时就秉承着这么一个思想,想把游戏安全引入CTF Web题目中(当然,我并不是第一个这么干的人,之前很多CTF比赛都有游戏安全,举个国内的例子HCTF,笔者也是当年有幸拿到一血)。于是我便开始搜索比较热门的HTML5游戏框架或者源代码,最终在github锁定了phaserquest,虽然不是很热门,但因为比较老而且很久没更新,我觉得我很可能会发现其中存在的问题。一开始就发现了很多可以致使游戏崩溃的bug,但是我最后是被MongoDB有关的数据交互吸引了。
发现问题的一句是
这里的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独有的序列化过程。
它的具体代码可以在 https://github.com/mongodb/js-bson/blob/V1.0.4/lib/bson/parser/serializer.js
看到。
这个bson序列化的过程是遵循统一的一个标准来进行的,不止NodeJS存在这样的库,PHP,Python等常见编程语言都提供了这样的库。他们的具体流程都大致为
其中NodeJs的相应库很有意思
他检测了数据是否有_bsontype这个属性,然后根据这个属性来进行不同的序列化过程。于是我们很容易想到可以利用不同数据的格式特性,来打造不同的Object,序列化成我们想要的结果。例如上面的ObjectId函数处理结果仍然是一个Object,但是我们这样丢到Object对应的序列化函数中,产生的结果再传入MongoDb引擎里进行query解析,结果一定是会出错的,我们就需要换一种思路让它不会报错,就是用_bsontype控制它到其他分支去,利用序列化过程中的信息剔除,将我们欲绕过ObjectId构造的length等信息去掉。
在RCTF,0CTF等一系列的calc题目中,我们可以看到它所利用的点就是这里的问题。
https://github.com/zsxsoft/my-ctf-challenges
StarCTF之后,在和选手的讨论中得知选手可以在996game中任意登陆第一个用户的账号,我们一起对原因进行了检查。
发现是由于在bson序列化的过程中,他传入的是一个不存在的_bsontype,然后在那些分支都走过之后,因为没有对应的bsontype,所以最终没有序列化任何query,于是造成了findone({})这种查询条件为空的情况,成功选中第一个用户
这个Bug十分有意义。例如在一个找回密码的场景,需要输入一个比较特别的id的时候findone({"userid":userinput)
这时候我们就可以利用这个技巧,让查询语句变成findone({})
,从而更改第一个用户的密码,而第一个用户大多是admin用户。
有了上面这个case之后,我又在和别人的一个合作项目中负责看了下别的库的相似问题。接下来我会讲讲我发现的nodemailer这个库。
我是在寻找使用MongoDb的一些上层框架,在一个Web框架中发现它使用了nodemailer搭配MongoDb来实现找回密码的功能。
大致的情况与我在RealWorld CTF2019出的题目相似。
题目附件
https://github.com/5lipper/ctf/tree/master/rwctf19-final/marxjs
相关代码如下
```javascript
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: '[