Request Smuggling N day (vibe.d)

When Two Parsers Disagree

This challenge is from INFOBAHN CTF LOGO challenge which exploits HTTP request smuggling 1 day in Vibe.d reverse proxy to achieve XSS on a bot with a flag cookie.

Challenge:

file-archive
47KB
archive

HTTP Request Smuggling - two systems disagree on where one message ends and another begins. And in this CTF challenge, we're going to exploit this confusion to steal a flag from a bot's cookie.

The Setup: What Are We Attacking?

The challenge has three components:

┌─────────┐      ┌──────────────┐      ┌─────────────┐
│   You   │─────▶│ Vibe.d Proxy │─────▶│   Backend   │
└─────────┘      │ (D language) │      │  (Hypercorn)│
                 └──────────────┘      └─────────────┘


                   ┌────┴────┐
                   │   Bot   │
                   │ (has 🚩)│
                   └─────────┘
  1. Proxy: A Vibe.d reverse proxy with Basic Auth

  2. Backend: A Quart/Hypercorn server serving a simple HTML page with a logo

  3. Bot: A Puppeteer bot that visits the proxy with the flag in its cookie

Goal: Steal the bot's cookie by exploiting the proxy. We want proxy to return our xss content when bot visits it. XSS will send cookie to our webhook.

Act 1: Understanding HTTP Request Smuggling

The Basics

HTTP has two ways to specify body length:

  1. Content-Length: "My body is exactly 100 bytes"

  2. Transfer-Encoding: chunked: "My body comes in chunks, I'll tell you when I'm done"

The RFC says: If both headers are present, Transfer-Encoding wins. Ignore Content-Length.

The problem: Not all servers follow this rule!

The Vulnerability

Vibe.d has a vulnerability where it prioritizes Content-Length over Transfer-Encoding - the opposite of what the RFC says! (https://github.com/vibe-d/vibe.d/security/advisories/GHSA-hm69-r6ch-92wx)

This creates a classic CL.TE (Content-Length vs Transfer-Encoding) request smuggling scenario:

First let try: Transfer-Encoding: chunked

You might think: "The bug is that Vibe.d prioritizes Content-Length over Transfer-Encoding, so just send both headers with lowercase chunked!"

If we send Transfer-Encoding: chunked (lowercase):

  • Vibe.d proxy sees both headers → Uses Content-Length of body parsing (the bug!)

  • Hypercorn sees both headers → Uses Transfer-Encoding (correct per RFC)

  • Result: They disagree! Smuggling should work... right?

Wrong! There's a second issue: How Vibe.d's HTTP client forwards the request.

When Vibe.d forwards your request to the backend, it checks the Transfer-Encoding header to decide how to send the body. Looking at the source codearrow-up-right:

It checks for EXACTLY "chunked" (case-sensitive)!

So with lowercase chunked:

  1. Vibe.d proxy reads your body using Content-Length ✓

  2. Vibe.d client sees Transfer-Encoding: chunked (exact match) → Sends to backend using chunked encoding ✗

  3. Backend receives properly chunked data → No smuggling! ❌

The Case Sensitivity Solution

The classic smuggling payload from the Vibe.d advisory uses Transfer-Encoding: x, chunked, but Hypercorn rejects this with a 501 error.

The trick: Exploit case sensitivity differences!

How Hypercorn (h11) Handles Transfer-Encoding

Looking at h11's header validation codearrow-up-right:

Key insight: Hypercorn converts the header value to lowercase before checking! So "Chunked", "CHUNKED", or "ChUnKeD" all become "chunked" and are accepted.

How Vibe.d Client Handles Transfer-Encoding

Looking at Vibe.d's HTTP client codearrow-up-right:

Key insight: Vibe.d checks for EXACTLY "chunked" (case-sensitive)! If it's "Chunked", the comparison fails and it uses normal mode.

The Exploit

Why this works:

  1. Vibe.d proxy (request parsing):

    • Sees both Content-Length and Transfer-Encoding: Chunked

    • Uses Content-Length due to the bug

    • Reads your entire smuggled request as the body

  2. Vibe.d client (forwarding to backend):

    • Checks: "Chunked" ≠ "chunked"

    • Sends to backend in NORMAL mode (raw bytes, not chunked encoding! perfect)

  3. Hypercorn (backend parsing):

    • Converts "Chunked" to "chunked" → Matches!

    • Parses the incoming data as chunked encoding

    • Sees 0\r\n\r\n (end of chunks)

    • Treats everything after as a NEW request!

  4. Result:

    • Vibe.d sends: [raw bytes including smuggled request]

    • Hypercorn interprets: [chunked data ending at 0\r\n\r\n] + [NEW REQUEST]

    • Smuggling works! ✅

Visual representation:

Summary:

  • chunked (lowercase) = Vibe.d client uses chunked mode = No smuggling ❌

  • Chunked (capital C) = Vibe.d client uses normal mode = Smuggling works ✅

  • x, chunked = Would work but Hypercorn rejects it with 501 ❌

Validating the Smuggling

Let's test if smuggling works:

What happens:

  1. Vibe.d reads 53 bytes (including the smuggled request) and forwards to backend

  2. Hypercorn sees 0\r\n\r\n (end of chunks), then sees GET /SMUGGLED as a NEW request

  3. Backend processes 2 requests, responds to the first

  4. The second request (GET /SMUGGLED) is now "queued" in the backend!

Validation: Check backend logs - you should see a request to /SMUGGLED! ✅

Act 2: The Connection Reuse Problem

Great! We can smuggle requests. But there's a problem:

The backend only returns static HTML. We can't inject XSS into that!

The Insight: Connection Reuse

From the Vibe.d advisory:

"vibe.http.proxy attempts to reuse the same backend connection when possible, even if the original request was made by a different client."

This means:

  1. We smuggle a request → Backend has a queued response

  2. Bot visits → Proxy reuses the same backend connection

  3. Bot gets OUR smuggled response instead of the real one!

But we still can't control the response content... or can we?

Act 3: The HEAD Request Trick

Understanding HEAD Requests

A HEAD request is like GET, but the server only returns headers, no body:

Notice: HEAD response has Content-Length but NO body!

The Exploit

Here's the brilliant part:

  1. We smuggle a HEAD request (not GET)

  2. Backend responds with Content-Length: X but no body

  3. Bot sends GET → reuses connection → gets the HEAD response

  4. Proxy sees Content-Length: X and thinks "I need to read X bytes of body"

  5. But there's no body! So proxy reads the NEXT thing in the TCP stream as the body! <- MAGIC, so any other request in the stream becomes the "fake body"!

If we send more data after the HEAD request, it becomes the "fake body"!

But wait... we can't just send <script> tags. The backend needs to echo them back somehow.

Act 4: Enter HTTP/2

HTTP/2 with Prior Knowledge

Hypercorn supports HTTP/2, and it has a feature called "HTTP/2 with Prior Knowledge" where you can switch to HTTP/2 mid-connection by sending:

This is the HTTP/2 "magic" string!

PING Frames: The Echo Chamber

HTTP/2 has a PING frame that's designed for keepalive/latency testing:

RFC 9113 says:

"Receivers of a PING frame that does not include an ACK flag MUST send a PING frame with the ACK flag set in response, with an identical frame payload."

Translation: Whatever you send in a PING frame, the server echoes back!

PING frame structure:

Server responds:

The Full Attack Chain

What happens:

  1. We smuggle HEAD + HTTP/2 frames

  2. Backend processes HEAD → responds with headers only

  3. Backend sees HTTP/2 magic → switches to HTTP/2

  4. Backend sees PING frames → echoes them back

  5. Bot visits → gets HEAD response (no body) + PING frames (as fake body)

  6. Browser parses PING frames as HTML → XSS!

Act 5: The XSS Puzzle

We can only send 8 bytes per PING frame, and there's junk between each frame:

We need an XSS payload that:

  • Works with 8-byte chunks

  • Handles junk bytes between chunks

  • Doesn't break on incomplete tags

The Solution: SVG + JavaScript Comments

The junk bytes (\x00\x00\x08...) are hidden inside JavaScript comments /*...*/!

The onend event fires after 1 second, executing our payload:

The Final Exploit

Appendix: Running Vibe.d on Apple Silicon (ARM) Macs

If you're testing this challenge locally on an Apple Silicon Mac, you'll need to run the D language compiler under Rosetta 2 since DMD doesn't have native ARM support yet.

Setup Steps

Note: All subsequent commands for this project should be run in the x86_64 shell to ensure compatibility.

References

Last updated