HexBrain

curl · Report #3972323

10: CURLOPT_MAXLIFETIME_CONN bypass on active HTTP/2 connections

Informativestatus
3upvotes
14 Sept 2026disclosed

How it was found

Summary

CURLOPT_MAXLIFETIME_CONN is not enforced when a new transfer is attached to an active multiplexed HTTP/2 connection. The connection-age eligibility check in url_match_conn() is gated behind !CONN_INUSE(conn), so an in-use HTTP/2 connection with spare stream capacity is accepted without any age check. A new transfer silently reuses an over-age connection, bypassing certificate revalidation.

Since 8.17.0 this option defaults to 24 hours and libcurl-security.md recommends it as the mechanism to bound how long a validated connection remains eligible: "Further transfers require a new connection with validation performed again." The bypass defeats that guarantee for any multiplexed connection that still has an active stream.

Root cause

In lib/url.c, url_match_conn() calls Curl_cpool_conn_seems_healthy() only for idle connections:

if(!CONN_INUSE(conn) &&
   !Curl_cpool_conn_seems_healthy(conn, m->data, &m->now)) {

An active multiplexed connection passes url_match_multiplex_limits() (stream capacity check) and then falls through directly to m->found = conn without any health or age check.

Inside Curl_cpool_conn_seems_healthy() in lib/conncache.c, the age check is also guarded:

if(!CONN_INUSE(conn) && cpool_conn_maxage(data, conn, pnow))
  return FALSE;

So the creation-age check (conn_max_age_ms) never evaluates for an in-use multiplexed connection.

Reproduction

Tested at commit c2676bf9e639e31e44b12a2cc82cf2046231b056 with OpenSSL and nghttp2. All files below are self-contained in a Docker build.

docker build -t curl-maxlifetime-h2-poc .
docker run --rm curl-maxlifetime-h2-poc

The PoC does:

  1. Generate CA A, unrelated CA B, and a localhost server cert signed by CA A.
  2. Start an HTTP/2 TLS server on port 8443.
  3. Transfer A opens an HTTP/2 stream using CA A. The server holds this stream open (sends headers but no end-of-stream), keeping the connection active.
  4. Wait 2.2 seconds (CURLOPT_MAXLIFETIME_CONN is set to 1 second).
  5. Replace the CA file on disk with CA B (same path). TLS session cache and CA cache are disabled.
  6. Transfer B is added normally. It succeeds on the same connection (conn_id=0, num_connects=0) — the age check was skipped.
  7. Transfer C is added with CURLOPT_FRESH_CONNECT=1 as a control. It opens a new connection and fails with CURLE_PEER_FAILED_VERIFICATION (60), proving the CA replacement is effective.

Suggested fix

Apply the creation-age eligibility check before attaching any matching connection, including active multiplexed ones. An over-age active connection should be rejected for the new transfer and marked non-reusable, while allowing its existing streams to finish normally.

Affected versions

7.80.0 through 8.21.0+ (current master). The severity increased in 8.17.0 when the option became enabled by default (24-hour lifetime).

Impact

Summary:

A new transfer can silently reuse an HTTP/2 connection that has exceeded its configured maximum lifetime, bypassing the certificate revalidation that would occur on a fresh connection.

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