curl · Report #3598444
Curl_compareheader() fails to match multi-value HTTP headers
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:
- Attacker's server responds to a victim request with
Connection: upgrade, close - curl misses the
closetoken and keeps the connection in its pool - Attacker keeps the TCP socket open (violating its own
closedeclaration) - Victim's next request to the same host:port reuses the poisoned connection
- The request - including
Authorizationheaders 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 requiresAuthorizationand 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.