✓ProveIt
Examples / Report #3697719 Saved analysis

CVE-2026-7168: cross-proxy Digest auth state leak

curl · 2026-04-2716.4s analysis · $0.026 in model calls

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

Code supports the claim

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.

1 · Grounding
grounded
no model · 16 references checked out, 0 did not
2 · Claims
NVIDIA-Nemotron-3-Nano-30B-A3B
6 claims · 2905 tokens · $0.0004
3 · Evidence
7 code snippets
real curl source at 8c908d2d0a6d · 6 docs via Tavily
4 · Judgment
Nemotron-3-Ultra-550b-a55b
18367 tokens · 12.0s · $0.0253
5 · Receipts
19 of 19 citations verified
every citation must point at code the judge was shown
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

C1flaw supported

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.

C2location supported

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.

C3trigger supported

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.

C4impact supported

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.

C5reproduction supported

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.

C6impact supported

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)

Full report on HackerOne ↗

## Summary: On libcurl 8.19.0, Proxy Digest 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. In the attached C PoC, the first CONNECT to proxyB carries `Proxy-Authorization: Digest ...` built from `proxyArealm` / `proxyAnonce` instead of starting unauthenticated and waiting for a challenge from proxyB. The leaked header is replayable to proxyA for the same CONNECT authority and is accepted with `200`, while replay to a different authority is rejected with `407`. A fresh easy handle and `curl_easy_reset()` both suppress the leak. I also confirmed that changing proxy credentials before the second transfer still causes libcurl to build the first proxyB CONNECT from stale proxyA Digest challenge state, and that a proxyB which only advertises Basic still receives stale Digest on the first CONNECT. ## Affected version Reproduced on Linux x86_64 with: `curl 8.19.0 (x86_64-pc-linux-gnu) libcurl/8.19.0 OpenSSL/3.5.5 zlib/1.3.1 brotli/1.2.0 zstd/1.5.7 libidn2/2.3.8 libpsl/0.21.5 libssh2/1.11.1 nghttp2/1.68.1 ngtcp2/1.21.0 nghttp3/1.15.0 librtmp/2.3 mit-krb5/1.22.1 OpenLDAP/2.6.10` The attached C PoC also prints the runtime version string: `curl_version=libcurl/8.19.0 ...` ## Steps To Reproduce: 1. Extract the attached `curl_proxy_connect_bundle.zip`. 2. Start the local proxy harness: `python3 proxy_digest_connect_servers.py proxy_events.jsonl` 3. Compile the attached C PoC against the libcurl 8.19.0 build you want to test. On my system I used: `cc -I/home/nobcoder/curl/curl/include poc_proxy_connect_digest_reuse.c /usr/lib/x86_64-linux-gnu/libcurl.so.4 -o poc_proxy_connect_digest_reuse_c` 4. Run the PoC: `./poc_proxy_connect_digest_reuse_c` 5. Observe the base case output: - `base1_res=0 base1_code=200` - `base2_res=56 base2_code=0` - `leaked_proxy_b=Digest username="proxyuser", realm="proxyArealm", nonce="proxyAnonce", uri="victim.test:18099", ...` - `raw_first_connect_proxy_b=CONNECT victim.test:18099 HTTP/1.1 ... Proxy-Authorization: Digest username="proxyuser", realm="proxyArealm", nonce="proxyAnonce", ...` - `replay_status=200` - `replay_used_password=false` - `mismatch_status=407` 6. Observe the controls: - fresh easy handle: `fresh_first_b=(null)` - `curl_easy_reset()`: `reset_first_b=(null)` - changed credentials …

Source code

lib/urldata.hlines 938–991 · lines 963-966 cited by the report
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
lib/transfer.clines 475–526 · lines 500-501 cited by the report
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 */
lib/http_digest.clines 68–122 · lines 93-97 cited by the report
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
lib/http.clines 784–834 · lines 809-809 cited by the report
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) &&
lib/http_proxy.clines 200–263 · lines 225-238 cited by the report
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
263out:
lib/url.clines 563–620 · lines 588-595 cited by the report
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 */
567static 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
588static 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. */
607static 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 }
lib/easy.clines 1083–1121 · definition of curl_easy_reset
Show 39 lines (not cited by the judge)
1083void 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}