✓ProveIt
Examples / Report #3793260 Saved analysis

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

curl · 2026-06-1014.4s analysis · $0.021 in model calls

What the report claims

When an application reuses a libcurl easy handle for sequential transfers, Digest authentication state captured from the first origin is silently sent to the next origin, leaking credentials to attacker-controlled servers.

Compare with curl's recorded outcome

Known vulnerability

A real vulnerability: CVE-2026-11856 cross-origin Digest auth state leak

Shown for comparison after analysis. The pipeline did not see this label.

ProveIt's finding

Needs human review

The available evidence does not settle the report. A maintainer should investigate.

Judgment withdrawn. The answer failed the evidence check.
See the withdrawn model reasoning

The following reasoning failed the citation check and does not support the final finding.

  • Curl_pretransfer clears initial_origin and some auth flags but omits Digest state cleanup (nonce, realm, cnonce, nc counter)
  • curl_easy_reset calls Curl_http_auth_cleanup_digest but applications reusing handles don't call it between transfers
  • PoC demonstrates cross-origin leak: same realm/nonce/cnonce/incrementing nc sent to attacker-controlled origin
  • Changing CURLOPT_USERPWD doesn't help — new password hashed with old realm/nonce, still valid for legitimate server
  • Violates RFC 7616 default protection space (web-origin); enables replay and offline password cracking
How this analysis ran 6 claims · 11 citations verified

Evidence check: judge quoted an external source that does not say that (https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-semantics-05)

Checked against curl master on 2026-06-10 (81cdf4d8e5be). Dev evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.

1 · Grounding
grounded
no model · 13 references checked out, 0 did not
2 · Claims
NVIDIA-Nemotron-3-Nano-30B-A3B
6 claims · 3167 tokens · $0.0003
3 · Evidence
4 code snippets
real curl source at 81cdf4d8e5be · 3 docs via Tavily
4 · Judgment
Nemotron-3-Ultra-550b-a55b
15125 tokens · 12.1s · $0.0212
5 · Receipts
11 of 14 citations verified
every citation must point at code the judge was shown
Inspect the withdrawn draft reply

This draft belongs to the withdrawn judgment. It may contain unsupported claims.

Thank you for the detailed report and PoC. The issue is confirmed: Curl_pretransfer() clears initial_origin and resets authhost/authproxy want flags but does not clear Digest authentication state (nonce, realm, cnonce, nc). This causes valid Digest headers for origin A to be sent to origin B when reusing an easy handle. curl_easy_reset() does call Curl_http_auth_cleanup_digest() but applications don't call it between transfers. This violates RFC 7616's default protection space (web-origin) and enables replay and offline password cracking. We'll prepare a fix to clear digest state in Curl_pretransfer when the origin changes.

Inspect all claims and source code 6 claims · 4 code excerpts

This model judgment was withdrawn. The claim assessments below are preserved for inspection and may be wrong.

Report claims

C1flaw supported

Digest authentication state captured from the first origin is silently sent to the next origin when reusing an easy handle

Read the reasoning

Curl_pretransfer (called before each curl_easy_perform) resets initial_origin and some auth flags but does not clear Digest authentication state (nonce, realm, cnonce, nc counter). curl_easy_reset does clean it up via Curl_http_auth_cleanup_digest, but applications reusing handles don't call curl_easy_reset between transfers.

C2location supported

The bug occurs in the pretransfer phase of libcurl's Digest authentication implementation

Read the reasoning

The bug is in Curl_pretransfer which prepares state for each new request. It clears initial_origin (line 495) and resets authhost/authproxy want flags (line 505-506) but omits any call to clear Digest auth state (nonce, realm, cnonce, nc).

C3trigger supported

Reusing the same easy handle for sequential transfers without calling curl_easy_reset()

Read the reasoning

Each curl_easy_perform call invokes Curl_pretransfer which fails to clear digest state. The PoC demonstrates sequential curl_easy_perform calls with different URLs on the same handle.

C4impact supported

Leaked Digest Authorization header allows attacker to replay requests and perform offline password cracking

Read the reasoning

The leaked Authorization header contains valid Digest credentials for the legitimate server's realm/nonce. Per RFC 7616 (E2), the default protection space is the web-origin; sending credentials for origin A to origin B violates the spec. Attacker gains replay capability within nonce lifetime and offline password cracking material (HA1 = MD5(user:realm:password)).

C5reproduction supported

The provided PoC demonstrates leakage by showing the same nonce and realm used across different origins

Read the reasoning

The PoC output shows identical realm, nonce, cnonce, and incrementing nc across requests to different origins (ports 19001 and 19002), proving digest state persists across origin boundaries.

digest_poc.c:0 digest_servers.py:0
C6impact supported

Explicitly changing CURLOPT_USERPWD between calls does not prevent the leak because the new user's password hash is still sent under the previous server's realm and nonce

Read the reasoning

Changing CURLOPT_USERPWD updates credentials but the pre-existing digest state (nonce, realm, cnonce, nc) remains. The new password is hashed with the old server's realm/nonce, so the Authorization header is valid for the legitimate server but sent to the attacker.

digest_poc.c:0 lib/transfer.c:505

What the report says about other projects

confirmed The default protection space for Digest authentication is the web-origin (scheme, host, port); credentials for one origin must not be sent to a different origin.

“If this parameter is omitted or its value is empty, the client SHOULD assume that the protection space consists of all URIs on the web-origin.” source

confirmed The 'realm' parameter is reserved for defining protection spaces.

“RFC 7616, respectively. The authentication parameter 'realm' is reserved for defining protection spaces as described in Section 8.5.2.” source

Read the original report (excerpt)

Full report on HackerOne ↗

## 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`) ```python #!/usr/bin/env python3 import http.server, threading, sys class Legitimate(http.server.BaseHTTPRequestHandler): challenge = ('Digest realm="legit-api@example.com",' ' 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.g …

Source code

lib/transfer.clines 446–602 · definition of Curl_pretransfer
446CURLcode Curl_pretransfer(struct Curl_easy *data)
C2: Curl_pretransfer definition starts here
C3: Curl_pretransfer called at start of each perform
447{
448 CURLcode result = CURLE_OK;
449
450 /* Reset the retry count at the start of each request.
451 * If the retry count is not reset, when the connection drops,
452 * it will not enter the retry mechanism on CONN_MAX_RETRIES + 1 attempts
453 * and will immediately throw
454 * "Connection died, tried CONN_MAX_RETRIES times before giving up".
455 * By resetting it here, we ensure each new request starts fresh. */
456 data->state.retrycount = 0;
457
458 if(!data->set.str[STRING_SET_URL] && !data->set.uh) {
459 /* we cannot do anything without URL */
460 failf(data, "No URL set");
461 return CURLE_URL_MALFORMAT;
462 }
463
464 /* CURLOPT_CURLU overrides CURLOPT_URL and the contents of the CURLU handle
465 is allowed to be changed by the user between transfers */
466 if(data->set.uh) {
467 CURLUcode uc;
468 curlx_free(data->set.str[STRING_SET_URL]);
469 uc = curl_url_get(data->set.uh,
470 CURLUPART_URL, &data->set.str[STRING_SET_URL], 0);
471 if(uc) {
472 /* clear the pointer to not point to freed memory anymore */
473 Curl_bufref_set(&data->state.url, NULL, 0, NULL);
474 failf(data, "No URL set");
475 return CURLE_URL_MALFORMAT;
476 }
477 }
478
479 Curl_bufref_set(&data->state.url, data->set.str[STRING_SET_URL], 0, NULL);
480
481 if(data->set.postfields && data->set.set_resume_from) {
482 /* we cannot */
483 failf(data, "cannot mix POSTFIELDS with RESUME_FROM");
484 return CURLE_BAD_FUNCTION_ARGUMENT;
485 }
486
487 data->state.prefer_ascii = data->set.prefer_ascii;
488#ifdef CURL_LIST_ONLY_PROTOCOL
489 data->state.list_only = data->set.list_only;
490#endif
491 data->state.httpreq = data->set.method;
492
493 /* initial transfer request coming up, forget the initial origin
494 * from a previous perform() on this handle. */
495 Curl_peer_unlink(&data->state.initial_origin);
C1: Curl_pretransfer drops initial_origin via Curl_peer_unlink but no digest state cleanup
C2: Clears initial_origin but no corresponding digest cleanup
C4: Origin change detected (initial_origin cleared) but digest state not cleared
496 data->state.requests = 0;
497 data->state.followlocation = 0; /* reset the location-follow counter */
498 data->state.this_is_a_follow = FALSE; /* reset this */
499 data->state.http_ignorecustom = FALSE; /* use custom HTTP method */
500 data->state.errorbuf = FALSE; /* no error has occurred */
501#ifndef CURL_DISABLE_HTTP
502 Curl_http_neg_init(data, &data->state.http_neg);
503#endif
504 data->state.authproblem = FALSE;
C1: Resets authproblem and authhost/authproxy want flags but no digest state reset
505 data->state.authhost.want = data->set.httpauth;
C6: authhost.want updated from set.httpauth but digest state (nonce/realm) not reset
506 data->state.authproxy.want = data->set.proxyauth;
507 curlx_safefree(data->info.wouldredirect);
508 Curl_data_priority_clear_state(data);
509 if(data->set.http_auto_referer)
510 Curl_bufref_free(&data->state.referer);
511 if(data->set.str[STRING_SET_REFERER])
512 Curl_bufref_set(&data->state.referer, data->set.str[STRING_SET_REFERER],
513 0, NULL);
514 else
515 Curl_bufref_free(&data->state.referer);
516
517 if(data->state.httpreq == HTTPREQ_PUT)
518 data->state.infilesize = data->set.filesize;
519 else if((data->state.httpreq != HTTPREQ_GET) &&
520 (data->state.httpreq != HTTPREQ_HEAD)) {
521 data->state.infilesize = data->set.postfieldsize;
522 if(data->set.postfields && (data->state.infilesize == -1))
523 data->state.infilesize = (curl_off_t)strlen(data->set.postfields);
524 }
525 else
526 data->state.infilesize = 0;
527
528 /* If there is a list of cookie files to read, do it now! */
529 result = Curl_cookie_loadfiles(data);
530 if(!result)
531 Curl_cookie_run(data); /* activate */
532
533 /* If there is a list of host pairs to deal with */
534 if(!result && data->state.resolve)
535 result = Curl_loadhostpairs(data);
536
537 if(!result)
538 /* If there is a list of hsts files to read */
539 result = Curl_hsts_loadfiles(data);
540
541 if(!result) {
542 /* Allow data->set.use_port to set which port to use. This needs to be
543 * disabled for example when we follow Location: headers to URLs using
544 * different ports! */
545 data->state.allow_port = TRUE;
546
547#if defined(HAVE_SIGNAL) && defined(SIGPIPE) && !defined(MSG_NOSIGNAL)
548 /*************************************************************
549 * Tell signal handler to ignore SIGPIPE
550 *************************************************************/
551 if(!data->set.no_signal)
552 data->state.prev_signal = signal(SIGPIPE, SIG_IGN);
553#endif
554
555 Curl_initinfo(data); /* reset session-specific information "variables" */
556 Curl_pgrsResetTransferSizes(data);
557 Curl_pgrsStartNow(data);
558
559 /* In case the handle is reused and an authentication method was picked
560 in the session we need to make sure we only use the one(s) we now
561 consider to be fine */
562 data->state.authhost.picked &= data->state.authhost.want;
563 data->state.authproxy.picked &= data->state.authproxy.want;
564
565#ifndef CURL_DISABLE_FTP
566 data->state.wildcardmatch = data->set.wildcard_enabled;
567 if(data->state.wildcardmatch) {
568 struct WildcardData *wc;
569 if(!data->wildcard) {
570 data->wildcard = curlx_calloc(1, sizeof(struct WildcardData));
571 if(!data->wildcard)
572 return CURLE_OUT_OF_MEMORY;
573 }
574 wc = data->wildcard;
575 if(wc->state < CURLWC_INIT) {
576 if(wc->ftpwc)
577 wc->dtor(wc->ftpwc);
578 curlx_safefree(wc->pattern);
579 curlx_safefree(wc->path);
580 Curl_wildcard_init(wc); /* init wildcard structures */
581 }
582 }
583#endif
584 result = Curl_hsts_loadcb(data, data->hsts);
585 }
586
587 /*
588 * Set user-agent. Used for HTTP, but since we can attempt to tunnel
589 * anything through an HTTP proxy we cannot limit this based on protocol.
590 */
591 if(!result && data->set.str[STRING_USERAGENT]) {
592 curlx_free(data->state.aptr.uagent);
593 data->state.aptr.uagent =
594 curl_maprintf("User-Agent: %s\r\n", data->set.str[STRING_USERAGENT]);
595 if(!data->state.aptr.uagent)
596 return CURLE_OUT_OF_MEMORY;
597 }
598
599 data->req.headerbytecount = 0;
600 Curl_headers_cleanup(data);
601 return result;
602}
lib/easy.clines 818–821 · definition of curl_easy_perform
818CURLcode curl_easy_perform(CURL *curl)
C3: curl_easy_perform entry point
819{
820 return easy_perform(curl, FALSE);
821}
lib/easy.clines 1089–1123 · definition of curl_easy_reset
1089void curl_easy_reset(CURL *curl)
1090{
1091 struct Curl_easy *data = curl;
1092 if(!GOOD_EASY_HANDLE(data))
1093 return;
1094
1095 Curl_req_hard_reset(&data->req, data);
1096 Curl_hash_clean(&data->meta_hash);
1097
1098 /* clear all meta data */
1099 Curl_meta_reset(data);
1100 /* zero out UserDefined data: */
1101 Curl_freeset(data);
1102 memset(&data->set, 0, sizeof(struct UserDefined));
1103 Curl_init_userdefined(data);
1104
1105 /* zero out Progress data: */
1106 memset(&data->progress, 0, sizeof(struct Progress));
1107
1108 /* zero out PureInfo data: */
1109 Curl_initinfo(data);
1110
1111 data->progress.hide = TRUE;
1112 data->state.current_speed = -1; /* init to negative == impossible */
1113 data->state.recent_conn_id = -1; /* clear remembered connection id */
1114
1115 /* zero out authentication data: */
1116 memset(&data->state.authhost, 0, sizeof(struct auth));
1117 memset(&data->state.authproxy, 0, sizeof(struct auth));
1118
1119#if !defined(CURL_DISABLE_HTTP) && !defined(CURL_DISABLE_DIGEST_AUTH)
C1: curl_easy_reset calls Curl_http_auth_cleanup_digest, but Curl_pretransfer does not
C2: Shows the cleanup function that exists but isn't called in pretransfer
1120 Curl_http_auth_cleanup_digest(data);
1121#endif
1122 data->master_mid = UINT32_MAX;
1123}
lib/transfer.c@curl-8_20_0lines 480–636 · definition of Curl_pretransfer as in release curl-8_20_0; differs from master; commits that changed it since: 862e8a74a8 transfer: clear referer when set to NULL | 8f71d0fde5 creds: hold credentials
Show 157 lines (not cited by the judge)
480CURLcode Curl_pretransfer(struct Curl_easy *data)
481{
482 CURLcode result = CURLE_OK;
483
484 /* Reset the retry count at the start of each request.
485 * If the retry count is not reset, when the connection drops,
486 * it will not enter the retry mechanism on CONN_MAX_RETRIES + 1 attempts
487 * and will immediately throw
488 * "Connection died, tried CONN_MAX_RETRIES times before giving up".
489 * By resetting it here, we ensure each new request starts fresh. */
490 data->state.retrycount = 0;
491
492 if(!data->set.str[STRING_SET_URL] && !data->set.uh) {
493 /* we cannot do anything without URL */
494 failf(data, "No URL set");
495 return CURLE_URL_MALFORMAT;
496 }
497
498 /* CURLOPT_CURLU overrides CURLOPT_URL and the contents of the CURLU handle
499 is allowed to be changed by the user between transfers */
500 if(data->set.uh) {
501 CURLUcode uc;
502 curlx_free(data->set.str[STRING_SET_URL]);
503 uc = curl_url_get(data->set.uh,
504 CURLUPART_URL, &data->set.str[STRING_SET_URL], 0);
505 if(uc) {
506 /* clear the pointer to not point to freed memory anymore */
507 Curl_bufref_set(&data->state.url, NULL, 0, NULL);
508 failf(data, "No URL set");
509 return CURLE_URL_MALFORMAT;
510 }
511 }
512
513 Curl_bufref_set(&data->state.url, data->set.str[STRING_SET_URL], 0, NULL);
514
515 if(data->set.postfields && data->set.set_resume_from) {
516 /* we cannot */
517 failf(data, "cannot mix POSTFIELDS with RESUME_FROM");
518 return CURLE_BAD_FUNCTION_ARGUMENT;
519 }
520
521 data->state.prefer_ascii = data->set.prefer_ascii;
522#ifdef CURL_LIST_ONLY_PROTOCOL
523 data->state.list_only = data->set.list_only;
524#endif
525 data->state.httpreq = data->set.method;
526
527 data->state.requests = 0;
528 data->state.followlocation = 0; /* reset the location-follow counter */
529 data->state.this_is_a_follow = FALSE; /* reset this */
530 data->state.http_ignorecustom = FALSE; /* use custom HTTP method */
531 data->state.errorbuf = FALSE; /* no error has occurred */
532#ifndef CURL_DISABLE_HTTP
533 Curl_http_neg_init(data, &data->state.http_neg);
534#endif
535 data->state.authproblem = FALSE;
536 data->state.authhost.want = data->set.httpauth;
537 data->state.authproxy.want = data->set.proxyauth;
538 curlx_safefree(data->info.wouldredirect);
539 Curl_data_priority_clear_state(data);
540 if(data->set.http_auto_referer)
541 Curl_bufref_free(&data->state.referer);
542 if(data->set.str[STRING_SET_REFERER])
543 Curl_bufref_set(&data->state.referer, data->set.str[STRING_SET_REFERER],
544 0, NULL);
545
546 if(data->state.httpreq == HTTPREQ_PUT)
547 data->state.infilesize = data->set.filesize;
548 else if((data->state.httpreq != HTTPREQ_GET) &&
549 (data->state.httpreq != HTTPREQ_HEAD)) {
550 data->state.infilesize = data->set.postfieldsize;
551 if(data->set.postfields && (data->state.infilesize == -1))
552 data->state.infilesize = (curl_off_t)strlen(data->set.postfields);
553 }
554 else
555 data->state.infilesize = 0;
556
557 /* If there is a list of cookie files to read, do it now! */
558 result = Curl_cookie_loadfiles(data);
559 if(!result)
560 Curl_cookie_run(data); /* activate */
561
562 /* If there is a list of host pairs to deal with */
563 if(!result && data->state.resolve)
564 result = Curl_loadhostpairs(data);
565
566 if(!result)
567 /* If there is a list of hsts files to read */
568 result = Curl_hsts_loadfiles(data);
569
570 if(!result) {
571 /* Allow data->set.use_port to set which port to use. This needs to be
572 * disabled for example when we follow Location: headers to URLs using
573 * different ports! */
574 data->state.allow_port = TRUE;
575
576#if defined(HAVE_SIGNAL) && defined(SIGPIPE) && !defined(MSG_NOSIGNAL)
577 /*************************************************************
578 * Tell signal handler to ignore SIGPIPE
579 *************************************************************/
580 if(!data->set.no_signal)
581 data->state.prev_signal = signal(SIGPIPE, SIG_IGN);
582#endif
583
584 Curl_initinfo(data); /* reset session-specific information "variables" */
585 Curl_pgrsResetTransferSizes(data);
586 Curl_pgrsStartNow(data);
587
588 /* In case the handle is reused and an authentication method was picked
589 in the session we need to make sure we only use the one(s) we now
590 consider to be fine */
591 data->state.authhost.picked &= data->state.authhost.want;
592 data->state.authproxy.picked &= data->state.authproxy.want;
593
594#ifndef CURL_DISABLE_FTP
595 data->state.wildcardmatch = data->set.wildcard_enabled;
596 if(data->state.wildcardmatch) {
597 struct WildcardData *wc;
598 if(!data->wildcard) {
599 data->wildcard = curlx_calloc(1, sizeof(struct WildcardData));
600 if(!data->wildcard)
601 return CURLE_OUT_OF_MEMORY;
602 }
603 wc = data->wildcard;
604 if(wc->state < CURLWC_INIT) {
605 if(wc->ftpwc)
606 wc->dtor(wc->ftpwc);
607 curlx_safefree(wc->pattern);
608 curlx_safefree(wc->path);
609 Curl_wildcard_init(wc); /* init wildcard structures */
610 }
611 }
612#endif
613 result = Curl_hsts_loadcb(data, data->hsts);
614 }
615
616 /*
617 * Set user-agent. Used for HTTP, but since we can attempt to tunnel
618 * anything through an HTTP proxy we cannot limit this based on protocol.
619 */
620 if(!result && data->set.str[STRING_USERAGENT]) {
621 curlx_free(data->state.aptr.uagent);
622 data->state.aptr.uagent =
623 curl_maprintf("User-Agent: %s\r\n", data->set.str[STRING_USERAGENT]);
624 if(!data->state.aptr.uagent)
625 return CURLE_OUT_OF_MEMORY;
626 }
627
628 if(!result)
629 result = Curl_reset_userpwd(data);
630 if(!result)
631 result = Curl_reset_proxypwd(data);
632
633 data->req.headerbytecount = 0;
634 Curl_headers_cleanup(data);
635 return result;
636}