HexBrain

curl · Report #3598444

Curl_compareheader() fails to match multi-value HTTP headers

Informativestatus
mediumseverity
28upvotes
12 Mar 2026disclosed

How it was found

Summary

Curl_compareheader() in lib/http.c fails to scan the full value of HTTP headers for substring matches. Due to an incorrect loop condition, only the first byte position of the header value is checked. This causes curl to miss connection options like close when they appear as non-first tokens in comma-separated header values such as Connection: upgrade, close.

When a malicious server sends Connection: upgrade, close, curl fails to detect the close token and incorrectly keeps the connection alive in its pool. A subsequent request to the same host:port reuses this connection, sending its credentials (Authorization headers, cookies) to the still-listening attacker server.

Detailed Description

The Connection header field is defined in RFC 9110 §7.6.1 with ABNF grammar Connection = #connection-option, where # denotes a comma-separated list. Therefore Connection: upgrade, close is a valid header containing two connection options. RFC 9112 §9.6 states:

A client that receives a "close" connection option MUST cease sending requests on that connection and close the connection after reading the response message containing the "close" connection option.

The bug is in the for loop at lib/http.c line 1438:

for(len = curlx_strlen(&val); len >= curlx_strlen(&val); len--, p++) {

The loop initializes len to the value length and then checks len >= curlx_strlen(&val) - the same value. After the first iteration and len--, the condition is false and the loop exits. Only position 0 is ever checked.

The same function is used for matching Connection: close, Connection: keep-alive, Transfer-Encoding: chunked, Expect: 100-continue, and proxy Connection / Proxy-Connection headers. All are affected.

No other major HTTP client has this bug. wget, Python requests, Python http.client, and Node.js http all correctly parse multi-value Connection headers.

Impact

Impact

A malicious server can exploit this to steal credentials from subsequent requests via connection pool poisoning:

  1. Attacker's server responds to a victim request with Connection: upgrade, close
  2. curl misses the close token and keeps the connection in its pool
  3. Attacker keeps the TCP socket open (violating its own close declaration)
  4. Victim's next request to the same host:port reuses the poisoned connection
  5. The request - including Authorization headers and cookies - sent to the attacker's still-listening socket

This is exploitable in environments where curl makes sequential requests to the same host:port, such as API gateways, Kubernetes services behind shared Ingress, CI/CD pipelines, and shared hosting.

PoC - Exploitation

The PoC server (server.py) simulates two services behind the same host:port:

  • /public - attacker-controlled endpoint that poisons the connection
  • /private - legitimate endpoint that requires Authorization and returns account data

Test 1 - Direct request to /private (safe baseline):

# Terminal 1
python3 server.py 8000

# Terminal 2
curl -s http://127.0.0.1:8000/private \
     -H "Authorization: Bearer SECRET_TOKEN"

Output:

[private] Auth received: Authorization: Bearer SECRET_TOKEN
[private] Returned account data (normal flow)
{"account": "[email protected]", "balance": 42000}

Credentials go to the legitimate /private handler. No leak. This is the expected behavior.

Test 2 - Visit /public first, then /private (attack):

# Terminal 1
python3 server.py 8000

# Terminal 2
curl -s http://127.0.0.1:8000/public \
     --next \
     -H "Authorization: Bearer SECRET_TOKEN" \
     http://127.0.0.1:8000/private

Output:

[attacker] Request from 127.0.0.1: GET /public HTTP/1.1
[attacker] Sent 'Connection: upgrade, close'
[attacker] Waiting for leaked request on same socket...

[attacker] *** INTERCEPTED REQUEST ***

      GET /private HTTP/1.1
      Host: 127.0.0.1:8000
      User-Agent: curl/8.19.0-DEV
      Accept: */*
  >>> Authorization: Bearer SECRET_TOKEN

[attacker] This request was meant for /private.
[attacker] The credentials were leaked because curl
[attacker] reused the poisoned connection.

Request #1 to /public had NO credentials. Request #2 to /private carried Authorization: Bearer SECRET_TOKEN. But the /private handler never ran - the attacker's /public handler captured the credentials on the reused TCP socket.

PS. The --next flag resets per-request options so Authorization applies only to the second URL.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original