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:
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 🚩)│
└─────────┘Proxy: A Vibe.d reverse proxy with Basic Auth
Backend: A Quart/Hypercorn server serving a simple HTML page with a logo
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:
Content-Length: "My body is exactly 100 bytes"
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
Transfer-Encoding: chunkedYou 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 code:
It checks for EXACTLY "chunked" (case-sensitive)!
So with lowercase chunked:
Vibe.d proxy reads your body using Content-Length ✓
Vibe.d client sees
Transfer-Encoding: chunked(exact match) → Sends to backend using chunked encoding ✗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 code:
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 code:
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:
Vibe.d proxy (request parsing):
Sees both
Content-LengthandTransfer-Encoding: ChunkedUses Content-Length due to the bug
Reads your entire smuggled request as the body
Vibe.d client (forwarding to backend):
Checks:
"Chunked" ≠ "chunked"Sends to backend in NORMAL mode (raw bytes, not chunked encoding! perfect)
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!
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:
Vibe.d reads 53 bytes (including the smuggled request) and forwards to backend
Hypercorn sees
0\r\n\r\n(end of chunks), then seesGET /SMUGGLEDas a NEW requestBackend processes 2 requests, responds to the first
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:
We smuggle a request → Backend has a queued response
Bot visits → Proxy reuses the same backend connection
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:
We smuggle a HEAD request (not GET)
Backend responds with
Content-Length: Xbut no bodyBot sends GET → reuses connection → gets the HEAD response
Proxy sees
Content-Length: Xand thinks "I need to read X bytes of body"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:
We smuggle HEAD + HTTP/2 frames
Backend processes HEAD → responds with headers only
Backend sees HTTP/2 magic → switches to HTTP/2
Backend sees PING frames → echoes them back
Bot visits → gets HEAD response (no body) + PING frames (as fake body)
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