HexBrain

curl · Report #3968729

Unbound cross-peer HTTP Digest challenge state

Informativestatus
mediumseverity
13upvotes
27 Aug 2026disclosed

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_BEARER was 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:

  1. Configure handle with CURLOPT_USERPWD, CURLOPT_HTTPAUTH = CURLAUTH_BASIC | CURLAUTH_BEARER
  2. Perform against HostA — HostA returns 401 with WWW-Authenticate: Digest realm="host-a-realm", nonce="host-a-nonce-0123456789", algorithm=MD5, qop="auth"
  3. HostA receives no Authorization header (Basic/Bearer selected, neither matched)
  4. Reuse handle: change URL to HostB, change auth to CURLAUTH_DIGEST
  5. 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

  1. auth_digest() in lib/http.c calls Curl_input_digest() for any Digest challenge header, even when Digest is not the selected auth method.
  2. Curl_input_digest() decodes directly into state.digest, calling Curl_auth_digest_cleanup() first (clearing any prior binding), then storing the new challenge unbound.
  3. Curl_pretransfer() in lib/transfer.c clears the current origin and reapplies the auth mask, but preserves the Digest challenge object.
  4. digest_flush_stale() in lib/http_digest.c only rejects a destination mismatch when digest->origin is non-NULL. For a parsed-but-never-output challenge, origin is NULL — so the stale state is adopted for the new destination.
  5. 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:

  1. Replay the captured response to HostA while the nonce is live.
  2. Mint fresh tokens by redirecting curl (307) or returning stale=true, producing new nc/cnonce values 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.

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