CVE-2026-11856: cross-origin Digest auth state leak
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
The available evidence does not settle the report. A maintainer should investigate.
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.
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
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.
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).
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.
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)).
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.
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.
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)
Source code
| 446 | CURLcode 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 | } |
| 818 | CURLcode curl_easy_perform(CURL *curl) |
| C3: curl_easy_perform entry point | |
| 819 | { |
| 820 | return easy_perform(curl, FALSE); |
| 821 | } |
| 1089 | void 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 | } |
Show 157 lines (not cited by the judge)
| 480 | CURLcode 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 | } |