CVE-2026-7168: cross-proxy Digest auth state leak
What the report claims
Proxy digest authentication state learned from proxyA persists across proxy identity boundaries and is preemptively sent to proxyB on a reused easy handle, causing cross-proxy credential leakage.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2026-7168 cross-proxy Digest auth state leak
Shown for comparison after analysis. The pipeline did not see this label.
ProveIt's finding
The code supports the reported flaw. A maintainer should review its impact and decide what to do next.
Why this finding?
- proxydigest state (nonce, realm) persists on the easy handle across transfers (urldata.h:963, transfer.c:500-501 does not clear it)
- First CONNECT to a new proxy reads the stale proxydigest and treats its leftover nonce as a valid challenge (http_digest.c:93-97, 120)
- The emitted Proxy-Authorization header is computed with proxyA's nonce/realm, making it a valid authentication response for proxyA — confirmed by PoC replay (200 vs 407)
- curl_easy_reset() clears the state (url.c:1113-1118 calls Curl_http_auth_cleanup_digest), but normal transfer reuse does not
- Attacker controls proxyB (remote), receives proxyA's auth material — not a local-only issue
How this analysis ran 6 claims · 19 citations verified
Checked against curl release curl-8_19_0 (fits the report's line numbers and quotes better than master on 2026-04-27) (8c908d2d0a6d). Dev evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.
Review a draft reply to the reporter
Edit and verify before sending.
The report is credible. The code shows that `data->state.proxydigest` (nonce, realm) survives `Curl_pretransfer` (transfer.c:500-501) and is used on the next proxy CONNECT via `Curl_http_output_auth` → `Curl_output_auth_headers` → `Curl_http_digest_auth` (http_digest.c:93-97, 120). A fresh handle or `curl_easy_reset()` clears it (url.c:1113-1118), but normal handle reuse does not. This leaks proxyA's Digest challenge to proxyB. Please confirm the fix clears `proxydigest` when the proxy identity changes or at transfer start.
Inspect all claims and source code 6 claims · 7 code excerpts
Report claims
Proxy digest authentication state learned from proxyA survives an independent transfer boundary on a reused easy handle and is emitted preemptively to proxyB when the proxy is changed.
Read the reasoning
The proxydigest struct lives on the easy handle (urldata.h:963). Starting a new transfer (transfer.c:500-501) resets the wanted auth methods but leaves proxydigest.nonce and realm intact. When a CONNECT to a new proxy is made, http_digest.c:93-97 selects the stale proxydigest and line 120 treats its leftover nonce as a challenge, causing a preemptive Digest response.
The issue occurs in libcurl's proxy digest handling during CONNECT requests to a new proxy.
Read the reasoning
The CONNECT path in http_proxy.c:226 invokes the auth output machinery, which for proxy Digest goes through http_digest.c and picks up the uncleared proxydigest state.
A reused easy handle that previously communicated with proxyA causes libcurl to send a Proxy-Authorization header built from proxyA's challenge to proxyB on the first CONNECT to proxyB.
Read the reasoning
Same code path as C1/C2: reused handle retains proxydigest; new transfer doesn't clear it; first CONNECT to proxyB reads the stale nonce/realm and emits a Proxy-Authorization header built from them.
The leaked Proxy-Authorization header is replayable to proxyA for the same CONNECT authority and is accepted with HTTP 200, demonstrating that the header retains real authentication value.
Read the reasoning
The leaked header contains the actual nonce and realm from proxyA's challenge. Per RFC 7616, a Digest response computed with those values is valid for the same authority on proxyA. The PoC's 200/407 results confirm the header carries real authentication value.
The attached C PoC shows that the first CONNECT to proxyB carries a Proxy-Authorization header built from stale proxyArealm and proxyAnonce, and that replay of this header to proxyA succeeds with status 200.
Read the reasoning
The PoC output matches the code behavior: first CONNECT to proxyB carries a Digest header with proxyArealm/proxyAnonce, and replay to proxyA succeeds because the header was computed with proxyA's challenge parameters.
Changing proxy credentials before the second transfer still causes libcurl to build the first proxyB CONNECT from stale proxyA Digest challenge state, and a proxyB that only advertises Basic still receives stale Digest on the first CONNECT.
Read the reasoning
Changing credentials updates the user/password pointers (aptr.proxyuser, aptr.proxypasswd) but not the challenge state in proxydigest. If the previous proxy picked Digest, authproxy->picked remains Digest (http.c:803), so the new proxy gets a Digest header even if it only advertised Basic.
What the report says about other projects
confirmed Proxy-Authorization headers are only sent after receiving a 407 challenge from the target proxy.
“The client MAY repeat the request with a new or replaced Proxy-Authorization header field (Section 4.4).” source
Read the original report (excerpt)
Source code
| 938 | |
| 939 | curl_off_t lastconnect_id; /* The last connection, -1 if undefined */ |
| 940 | curl_off_t recent_conn_id; /* The most recent connection used, might no |
| 941 | * longer exist */ |
| 942 | struct dynbuf headerb; /* buffer to store headers in */ |
| 943 | struct curl_slist *hstslist; /* list of HSTS files set by |
| 944 | curl_easy_setopt(HSTS) calls */ |
| 945 | curl_off_t current_speed; /* the ProgressShow() function sets this, |
| 946 | bytes / second */ |
| 947 | |
| 948 | /* hostname, port number and protocol of the first (not followed) request. |
| 949 | if set, this should be the hostname that we will sent authorization to, |
| 950 | no else. Used to make Location: following not keep sending user+password. |
| 951 | This is strdup()ed data. */ |
| 952 | char *first_host; |
| 953 | int first_remote_port; |
| 954 | curl_prot_t first_remote_protocol; |
| 955 | int os_errno; /* filled in with errno whenever an error occurs */ |
| 956 | int requests; /* request counter: redirects + authentication retakes */ |
| 957 | #ifdef HAVE_SIGNAL |
| 958 | /* storage for the previous bag^H^H^HSIGPIPE signal handler :-) */ |
| 959 | void (*prev_signal)(int sig); |
| 960 | #endif |
| 961 | #ifndef CURL_DISABLE_DIGEST_AUTH |
| 962 | struct digestdata digest; /* state data for host Digest auth */ |
| 963 | struct digestdata proxydigest; /* state data for proxy Digest auth */ |
| C1: proxydigest stored on easy-handle state, persists across transfers | |
| C3: proxydigest persists on easy handle | |
| C6: proxydigest separate from proxyuserpwd | |
| 964 | #endif |
| 965 | struct auth authhost; /* auth details for host */ |
| 966 | struct auth authproxy; /* auth details for proxy */ |
| 967 | |
| 968 | struct Curl_dns_entry *dns[2]; /* DNS to connect FIRST/SECONDARY */ |
| 969 | #ifdef USE_CURL_ASYNC |
| 970 | struct Curl_async async; /* asynchronous name resolver data */ |
| 971 | #endif |
| 972 | |
| 973 | #ifdef USE_OPENSSL |
| 974 | /* void instead of ENGINE to avoid bleeding OpenSSL into this header */ |
| 975 | void *engine; |
| 976 | /* void instead of OSSL_PROVIDER */ |
| 977 | void *provider; |
| 978 | void *baseprov; |
| 979 | void *libctx; |
| 980 | char *propq; /* for a provider */ |
| 981 | |
| 982 | BIT(provider_loaded); |
| 983 | #endif /* USE_OPENSSL */ |
| 984 | struct curltime expiretime; /* set this with Curl_expire() only */ |
| 985 | struct Curl_tree timenode; /* for the splay stuff */ |
| 986 | struct Curl_llist timeoutlist; /* list of pending timeouts */ |
| 987 | struct time_node expires[EXPIRE_LAST]; /* nodes for each expire type */ |
| 988 | |
| 989 | /* a place to store the most recently set (S)FTP entrypath */ |
| 990 | char *most_recent_ftp_entrypath; |
| 991 | char *range; /* range, if used. See README for detailed specification on |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | Curl_bufref_set(&data->state.url, data->set.str[STRING_SET_URL], 0, NULL); |
| 479 | |
| 480 | if(data->set.postfields && data->set.set_resume_from) { |
| 481 | /* we cannot */ |
| 482 | failf(data, "cannot mix POSTFIELDS with RESUME_FROM"); |
| 483 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 484 | } |
| 485 | |
| 486 | data->state.prefer_ascii = data->set.prefer_ascii; |
| 487 | #ifdef CURL_LIST_ONLY_PROTOCOL |
| 488 | data->state.list_only = data->set.list_only; |
| 489 | #endif |
| 490 | data->state.httpreq = data->set.method; |
| 491 | |
| 492 | data->state.requests = 0; |
| 493 | data->state.followlocation = 0; /* reset the location-follow counter */ |
| 494 | data->state.this_is_a_follow = FALSE; /* reset this */ |
| 495 | data->state.errorbuf = FALSE; /* no error has occurred */ |
| 496 | #ifndef CURL_DISABLE_HTTP |
| 497 | Curl_http_neg_init(data, &data->state.http_neg); |
| 498 | #endif |
| 499 | data->state.authproblem = FALSE; |
| 500 | data->state.authhost.want = data->set.httpauth; |
| C1: new transfer resets authproxy->want but does not clear proxydigest challenge state (nonce, realm) | |
| C3: transfer init does not clear proxydigest | |
| C6: transfer init does not clear proxydigest | |
| 501 | data->state.authproxy.want = data->set.proxyauth; |
| 502 | Curl_safefree(data->info.wouldredirect); |
| 503 | Curl_data_priority_clear_state(data); |
| 504 | |
| 505 | if(data->state.httpreq == HTTPREQ_PUT) |
| 506 | data->state.infilesize = data->set.filesize; |
| 507 | else if((data->state.httpreq != HTTPREQ_GET) && |
| 508 | (data->state.httpreq != HTTPREQ_HEAD)) { |
| 509 | data->state.infilesize = data->set.postfieldsize; |
| 510 | if(data->set.postfields && (data->state.infilesize == -1)) |
| 511 | data->state.infilesize = (curl_off_t)strlen(data->set.postfields); |
| 512 | } |
| 513 | else |
| 514 | data->state.infilesize = 0; |
| 515 | |
| 516 | /* If there is a list of cookie files to read, do it now! */ |
| 517 | result = Curl_cookie_loadfiles(data); |
| 518 | if(!result) |
| 519 | Curl_cookie_run(data); /* activate */ |
| 520 | |
| 521 | /* If there is a list of host pairs to deal with */ |
| 522 | if(!result && data->state.resolve) |
| 523 | result = Curl_loadhostpairs(data); |
| 524 | |
| 525 | if(!result) |
| 526 | /* If there is a list of hsts files to read */ |
| 68 | const unsigned char *uripath) |
| 69 | { |
| 70 | CURLcode result; |
| 71 | unsigned char *path = NULL; |
| 72 | const char *tmp = NULL; |
| 73 | char *response; |
| 74 | size_t len; |
| 75 | bool have_chlg; |
| 76 | |
| 77 | /* Point to the address of the pointer that holds the string to send to the |
| 78 | server, which is for a plain host or for an HTTP proxy */ |
| 79 | char **allocuserpwd; |
| 80 | |
| 81 | /* Point to the name and password for this */ |
| 82 | const char *userp; |
| 83 | const char *passwdp; |
| 84 | |
| 85 | /* Point to the correct struct with this */ |
| 86 | struct digestdata *digest; |
| 87 | struct auth *authp; |
| 88 | |
| 89 | if(proxy) { |
| 90 | #ifdef CURL_DISABLE_PROXY |
| 91 | return CURLE_NOT_BUILT_IN; |
| 92 | #else |
| 93 | digest = &data->state.proxydigest; |
| C1: when proxy=true, uses data->state.proxydigest which retains prior proxy's challenge | |
| C2: proxy path reads stale proxydigest | |
| C3: proxy path uses data->state.proxydigest | |
| C4: uses stale proxydigest.nonce and .realm to build response | |
| C5: stale proxydigest used for proxyB CONNECT | |
| C6: proxydigest used regardless of credential pointers | |
| 94 | allocuserpwd = &data->state.aptr.proxyuserpwd; |
| 95 | userp = data->state.aptr.proxyuser; |
| 96 | passwdp = data->state.aptr.proxypasswd; |
| 97 | authp = &data->state.authproxy; |
| 98 | #endif |
| 99 | } |
| 100 | else { |
| 101 | digest = &data->state.digest; |
| 102 | allocuserpwd = &data->state.aptr.userpwd; |
| 103 | userp = data->state.aptr.user; |
| 104 | passwdp = data->state.aptr.passwd; |
| 105 | authp = &data->state.authhost; |
| 106 | } |
| 107 | |
| 108 | Curl_safefree(*allocuserpwd); |
| 109 | |
| 110 | /* not set means empty */ |
| 111 | if(!userp) |
| 112 | userp = ""; |
| 113 | |
| 114 | if(!passwdp) |
| 115 | passwdp = ""; |
| 116 | |
| 117 | #ifdef USE_WINDOWS_SSPI |
| 118 | have_chlg = !!digest->input_token; |
| 119 | #else |
| 120 | have_chlg = !!digest->nonce; |
| C1: have_chlg = !!digest->nonce; treats stale nonce as a valid challenge | |
| C2: stale nonce treated as challenge | |
| C3: stale nonce causes have_chlg=true | |
| C4: nonce presence triggers response generation | |
| C5: stale nonce triggers Digest response | |
| 121 | #endif |
| 122 |
| 784 | #endif |
| 785 | data->set.str[STRING_BEARER]) |
| 786 | /* continue please */; |
| 787 | else { |
| 788 | authhost->done = TRUE; |
| 789 | authproxy->done = TRUE; |
| 790 | return CURLE_OK; /* no authentication with no user or password */ |
| 791 | } |
| 792 | |
| 793 | if(authhost->want && !authhost->picked) |
| 794 | /* The app has selected one or more methods, but none has been picked |
| 795 | so far by a server round-trip. Then we set the picked one to the |
| 796 | want one, and if this is one single bit it will be used instantly. */ |
| 797 | authhost->picked = authhost->want; |
| 798 | |
| 799 | if(authproxy->want && !authproxy->picked) |
| 800 | /* The app has selected one or more methods, but none has been picked so |
| 801 | far by a proxy round-trip. Then we set the picked one to the want one, |
| 802 | and if this is one single bit it will be used instantly. */ |
| 803 | authproxy->picked = authproxy->want; |
| C6: authproxy->picked = authproxy->want; if want includes Digest, it stays picked | |
| 804 | |
| 805 | #ifndef CURL_DISABLE_PROXY |
| 806 | /* Send proxy authentication header if needed */ |
| 807 | if(conn->bits.httpproxy && |
| 808 | (conn->bits.tunnel_proxy == (curl_bit)proxytunnel)) { |
| 809 | result = output_auth_headers(data, conn, authproxy, request, path, TRUE); |
| 810 | if(result) |
| 811 | return result; |
| 812 | } |
| 813 | else |
| 814 | #else |
| 815 | (void)proxytunnel; |
| 816 | #endif /* CURL_DISABLE_PROXY */ |
| 817 | /* we have no proxy so let's pretend we are done authenticating |
| 818 | with it */ |
| 819 | authproxy->done = TRUE; |
| 820 | |
| 821 | /* To prevent the user+password to get sent to other than the original host |
| 822 | due to a location-follow */ |
| 823 | if(Curl_auth_allowed_to_host(data) |
| 824 | #ifndef CURL_DISABLE_NETRC |
| 825 | || conn->bits.netrc |
| 826 | #endif |
| 827 | ) |
| 828 | result = output_auth_headers(data, conn, authhost, request, path, FALSE); |
| 829 | else |
| 830 | authhost->done = TRUE; |
| 831 | |
| 832 | if(((authhost->multipass && !authhost->done) || |
| 833 | (authproxy->multipass && !authproxy->done)) && |
| 834 | (httpreq != HTTPREQ_GET) && |
| 200 | int http_version_major) |
| 201 | { |
| 202 | struct cf_proxy_ctx *ctx = cf->ctx; |
| 203 | const char *hostname = NULL; |
| 204 | char *authority = NULL; |
| 205 | int port; |
| 206 | bool ipv6_ip; |
| 207 | CURLcode result; |
| 208 | struct httpreq *req = NULL; |
| 209 | |
| 210 | Curl_http_proxy_get_destination(cf, &hostname, &port, &ipv6_ip); |
| 211 | |
| 212 | authority = curl_maprintf("%s%s%s:%d", ipv6_ip ? "[" : "", hostname, |
| 213 | ipv6_ip ? "]" : "", port); |
| 214 | if(!authority) { |
| 215 | result = CURLE_OUT_OF_MEMORY; |
| 216 | goto out; |
| 217 | } |
| 218 | |
| 219 | result = Curl_http_req_make(&req, "CONNECT", sizeof("CONNECT") - 1, |
| 220 | NULL, 0, authority, strlen(authority), |
| 221 | NULL, 0); |
| 222 | if(result) |
| 223 | goto out; |
| 224 | |
| 225 | /* Setup the proxy-authorization header, if any */ |
| 226 | result = Curl_http_output_auth(data, cf->conn, req->method, HTTPREQ_GET, |
| C2: Curl_http_output_auth called for CONNECT request with authproxy | |
| 227 | req->authority, TRUE); |
| 228 | if(result) |
| 229 | goto out; |
| 230 | |
| 231 | /* If user is not overriding Host: header, we add for HTTP/1.x */ |
| 232 | if(http_version_major == 1 && |
| 233 | !Curl_checkProxyheaders(data, cf->conn, STRCONST("Host"))) { |
| 234 | result = Curl_dynhds_cadd(&req->headers, "Host", authority); |
| 235 | if(result) |
| 236 | goto out; |
| 237 | } |
| 238 | |
| 239 | if(data->state.aptr.proxyuserpwd) { |
| 240 | result = Curl_dynhds_h1_cadd_line(&req->headers, |
| 241 | data->state.aptr.proxyuserpwd); |
| 242 | if(result) |
| 243 | goto out; |
| 244 | } |
| 245 | |
| 246 | if(!Curl_checkProxyheaders(data, cf->conn, STRCONST("User-Agent")) && |
| 247 | data->set.str[STRING_USERAGENT] && *data->set.str[STRING_USERAGENT]) { |
| 248 | result = Curl_dynhds_cadd(&req->headers, "User-Agent", |
| 249 | data->set.str[STRING_USERAGENT]); |
| 250 | if(result) |
| 251 | goto out; |
| 252 | } |
| 253 | |
| 254 | if(http_version_major == 1 && |
| 255 | !Curl_checkProxyheaders(data, cf->conn, STRCONST("Proxy-Connection"))) { |
| 256 | result = Curl_dynhds_cadd(&req->headers, "Proxy-Connection", "Keep-Alive"); |
| 257 | if(result) |
| 258 | goto out; |
| 259 | } |
| 260 | |
| 261 | result = dynhds_add_custom(data, TRUE, ctx->httpversion, &req->headers); |
| 262 | |
| 263 | out: |
Show 58 lines (not cited by the judge)
| 563 | * |
| 564 | * Return a TRUE, iff the transfer can be done over an (appropriate) |
| 565 | * multiplexed connection. |
| 566 | */ |
| 567 | static bool xfer_may_multiplex(const struct Curl_easy *data, |
| 568 | const struct connectdata *conn) |
| 569 | { |
| 570 | #ifndef CURL_DISABLE_HTTP |
| 571 | /* If an HTTP protocol and multiplexing is enabled */ |
| 572 | if((conn->scheme->protocol & PROTO_FAMILY_HTTP) && |
| 573 | (!conn->bits.protoconnstart || !conn->bits.close)) { |
| 574 | |
| 575 | if(Curl_multiplex_wanted(data->multi) && |
| 576 | (data->state.http_neg.allowed & (CURL_HTTP_V2x | CURL_HTTP_V3x))) |
| 577 | /* allows HTTP/2 or newer */ |
| 578 | return TRUE; |
| 579 | } |
| 580 | #else |
| 581 | (void)data; |
| 582 | (void)conn; |
| 583 | #endif |
| 584 | return FALSE; |
| 585 | } |
| 586 | |
| 587 | #ifndef CURL_DISABLE_PROXY |
| 588 | static bool proxy_info_matches(const struct proxy_info *data, |
| 589 | const struct proxy_info *needle) |
| 590 | { |
| 591 | if((data->proxytype == needle->proxytype) && |
| 592 | (data->port == needle->port) && |
| 593 | curl_strequal(data->host.name, needle->host.name)) { |
| 594 | |
| 595 | if(Curl_timestrcmp(data->user, needle->user) || |
| 596 | Curl_timestrcmp(data->passwd, needle->passwd)) |
| 597 | return FALSE; |
| 598 | return TRUE; |
| 599 | } |
| 600 | return FALSE; |
| 601 | } |
| 602 | #endif |
| 603 | |
| 604 | /* A connection has to have been idle for less than 'conn_max_idle_ms' |
| 605 | (the success rate is too low after this), or created less than |
| 606 | 'conn_max_age_ms' ago, to be subject for reuse. */ |
| 607 | static bool conn_maxage(struct Curl_easy *data, |
| 608 | struct connectdata *conn, |
| 609 | struct curltime now) |
| 610 | { |
| 611 | timediff_t age_ms; |
| 612 | |
| 613 | if(data->set.conn_max_idle_ms) { |
| 614 | age_ms = curlx_ptimediff_ms(&now, &conn->lastused); |
| 615 | if(age_ms > data->set.conn_max_idle_ms) { |
| 616 | infof(data, "Too old connection (%" FMT_TIMEDIFF_T |
| 617 | " ms idle, max idle is %" FMT_TIMEDIFF_T " ms), disconnect it", |
| 618 | age_ms, data->set.conn_max_idle_ms); |
| 619 | return TRUE; |
| 620 | } |
Show 39 lines (not cited by the judge)
| 1083 | void curl_easy_reset(CURL *d) |
| 1084 | { |
| 1085 | struct Curl_easy *data = d; |
| 1086 | if(!GOOD_EASY_HANDLE(data)) |
| 1087 | return; |
| 1088 | |
| 1089 | Curl_req_hard_reset(&data->req, data); |
| 1090 | Curl_hash_clean(&data->meta_hash); |
| 1091 | |
| 1092 | /* clear all meta data */ |
| 1093 | Curl_meta_reset(data); |
| 1094 | /* clear any resolve data */ |
| 1095 | Curl_async_shutdown(data); |
| 1096 | Curl_resolv_unlink(data, &data->state.dns[0]); |
| 1097 | Curl_resolv_unlink(data, &data->state.dns[1]); |
| 1098 | /* zero out UserDefined data: */ |
| 1099 | Curl_freeset(data); |
| 1100 | memset(&data->set, 0, sizeof(struct UserDefined)); |
| 1101 | Curl_init_userdefined(data); |
| 1102 | |
| 1103 | /* zero out Progress data: */ |
| 1104 | memset(&data->progress, 0, sizeof(struct Progress)); |
| 1105 | |
| 1106 | /* zero out PureInfo data: */ |
| 1107 | Curl_initinfo(data); |
| 1108 | |
| 1109 | data->progress.hide = TRUE; |
| 1110 | data->state.current_speed = -1; /* init to negative == impossible */ |
| 1111 | data->state.recent_conn_id = -1; /* clear remembered connection id */ |
| 1112 | |
| 1113 | /* zero out authentication data: */ |
| 1114 | memset(&data->state.authhost, 0, sizeof(struct auth)); |
| 1115 | memset(&data->state.authproxy, 0, sizeof(struct auth)); |
| 1116 | |
| 1117 | #if !defined(CURL_DISABLE_HTTP) && !defined(CURL_DISABLE_DIGEST_AUTH) |
| 1118 | Curl_http_auth_cleanup_digest(data); |
| 1119 | #endif |
| 1120 | data->master_mid = UINT32_MAX; |
| 1121 | } |