curl · Report #3968729
Unbound cross-peer HTTP Digest challenge state
How it was found
Summary
When libcurl parses an HTTP Digest WWW-Authenticate challenge, it stores the realm, nonce, algorithm, and qop into the easy handle's state.digest — but does not bind that state to the peer that sent it. If the challenge is parsed but never used for output (e.g. because Basic or Bearer was the selected auth method), digest->origin stays NULL.
On handle reuse for a different origin, digest_flush_stale() checks whether digest->origin matches the new destination. Since it's NULL, the function adopts the stale state instead of flushing it. Curl_output_digest() then generates a valid Authorization: Digest header using the original peer's realm and nonce — and sends it to the new, unrelated peer.
Preconditions
- libcurl built with HTTP Digest support
- Easy handle reused across two different origins without
curl_easy_reset() - HostA's Digest challenge parsed but not selected for output (e.g.
CURLAUTH_BASIC | CURLAUTH_BEARERwas active) - Digest becomes selected when the handle is reused for HostB (e.g. auth mask changed to
CURLAUTH_DIGEST)
Reproduction
The PoC (poc.py) implements Reproduction 1 from the original report:
- Configure handle with
CURLOPT_USERPWD,CURLOPT_HTTPAUTH = CURLAUTH_BASIC | CURLAUTH_BEARER - Perform against HostA — HostA returns
401withWWW-Authenticate: Digest realm="host-a-realm", nonce="host-a-nonce-0123456789", algorithm=MD5, qop="auth" - HostA receives no
Authorizationheader (Basic/Bearer selected, neither matched) - Reuse handle: change URL to HostB, change auth to
CURLAUTH_DIGEST - HostB sends no challenge — yet receives:
Authorization: Digest username="alice", realm="host-a-realm", nonce="host-a-nonce-0123456789", uri="/same-resource", cnonce="...", nc=00000001, qop=auth, response="...", algorithm=MD5
Root cause trace
auth_digest()inlib/http.ccallsCurl_input_digest()for any Digest challenge header, even when Digest is not the selected auth method.Curl_input_digest()decodes directly intostate.digest, callingCurl_auth_digest_cleanup()first (clearing any prior binding), then storing the new challenge unbound.Curl_pretransfer()inlib/transfer.cclears the current origin and reapplies the auth mask, but preserves the Digest challenge object.digest_flush_stale()inlib/http_digest.conly rejects a destination mismatch whendigest->originis non-NULL. For a parsed-but-never-output challenge, origin is NULL — so the stale state is adopted for the new destination.Curl_output_digest()generates the header from HostA's retained challenge, bound to HostB.
Remediation
Bind the Digest challenge to the originating peer at parse time, not at first output. On Curl_pretransfer() for a new origin, flush any Digest state whose binding does not match. At output, require a non-NULL binding matching the current peer.
Impact
An attacker running a malicious server (HostB) receives a valid Digest response computed with the victim's password, bound to a legitimate server's (HostA) realm and nonce. The attacker can:
- Replay the captured response to HostA while the nonce is live.
- Mint fresh tokens by redirecting curl (307) or returning
stale=true, producing newnc/cnoncevalues that bypass strict replay caches. This makes the attacker a signing oracle for HostA's nonce for its entire lifetime.
Digest binds responses to method and URI, so the attacker cannot forge arbitrary requests — only those matching what curl actually sent. No plaintext password is disclosed.
This is not an application-misuse scenario. libcurl deliberately enforces per-origin Digest isolation via digest_flush_stale(), hardened in PRs #21944 and #21951. That guard rejects stale state only when digest->origin is non-NULL — but origin is linked at output time, not at parse time. A challenge that was parsed but never used for output retains a NULL origin, causing the guard to fail open and adopt HostA's challenge as HostB's state. Because libcurl itself asserts this isolation boundary, emitting HostA's challenge to HostB is a violation of an invariant the library maintains, independent of application behavior.