CVE-2026-86411: FTP connection reuse inherits client-certificate
What the report claims
A lower-privileged FTP transfer can reuse a TLS-upgraded connection authenticated with another handle's client certificate, inheriting its privileged identity and bypassing authorization.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2026-86411 FTP connection reuse inherits client-certificate
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?
- url_match_ssl_config() at lib/url.c:887 only compares SSL config when PROTOPT_SSL or may_tls is true; for FTP with CURLUSESSL_NONE, both are false
- Curl_ftp_conns_match() at lib/ftp.c:4488 permits reuse when new use_ssl (0) is not greater than pooled use_ssl (2), allowing CURLUSESSL_CONTROL -> CURLUSESSL_NONE direction
- FTP declares PROTOPT_SSL_REUSE so url_match_ssl_use() at lib/url.c:723 allows reuse of an existing TLS-upgraded connection
- No re-authentication or new TLS handshake occurs on reuse, so the server sees the original client-certificate identity
- This is a cross-handle authentication bypass reachable by any caller sharing a connection pool (multi handle with multiple easy handles)
How this analysis ran 5 claims · 9 citations verified
Checked against curl master on 2026-08-27 (b73a4f00b2a6). Test 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 correctly identifies a regression from commit 4ff212f8ed. The code at lib/url.c:887 skips SSL config comparison for FTP with CURLUSESSL_NONE because may_tls=false and FTP lacks PROTOPT_SSL. Meanwhile lib/ftp.c:4488 allows CURLUSESSL_CONTROL->CURLUSESSL_NONE reuse. A handle without a client certificate can inherit a pooled TLS connection authenticated with another handle's certificate. This is a valid cross-handle authentication bypass. Suggested fix: url_match_ssl_config should also gate on whether the pooled connection already has TLS (Curl_conn_is_ssl), not only on whether the new handle wants TLS.
Inspect all claims and source code 5 claims · 9 code excerpts
Report claims
FTP connection reuse permits a later handle using CURLUSESSL_NONE to inherit the client-certificate identity of a previous handle that used CURLUSESSL_CONTROL
Read the reasoning
The code path shows all three conditions from the report align: FTP declares PROTOPT_SSL_REUSE, url_match_ssl_config skips SSL config comparison when may_tls=false, and Curl_ftp_conns_match permits CURLUSESSL_CONTROL -> CURLUSESSL_NONE reuse.
The flaw occurs in lib/url.c where url_match_ssl_config() does not compare client-certificate settings when may_tls is false
Read the reasoning
The cited lines show the exact gate: client-certificate configuration is only compared when the protocol is always-SSL (PROTOPT_SSL) or the new handle may use TLS (may_tls). For FTP with CURLUSESSL_NONE, neither is true.
A second FTP transfer with CURLUSESSL_NONE reuses the pooled connection that was previously upgraded with STARTTLS and authenticated with a client certificate
Read the reasoning
The connection matching logic permits a CURLUSESSL_NONE handle to match a pooled connection that was previously upgraded with STARTTLS (use_ssl=2).
The later transfer accesses resources authorized only for the first handle's client certificate, resulting in unauthorized operations
Read the reasoning
Because the pooled TLS connection is reused without a new handshake or FTP login, the server continues to authorize requests under the original client-certificate identity. The code shows no mechanism to force re-authentication when reuse occurs.
Steps 1-19 in the reproduction section demonstrate the vulnerability and its fix
Read the reasoning
Reproduction steps are not code claims; they describe a test procedure. The provided code does not contain test infrastructure to verify these steps.
Read the original report (excerpt)
Source code
| 687 | |
| 688 | static bool url_match_multiplex_limits(struct connectdata *conn, |
| 689 | struct url_conn_match *m) |
| 690 | { |
| 691 | if(CONN_INUSE(conn) && m->may_multiplex) { |
| 692 | DEBUGASSERT(conn->bits.multiplex); |
| 693 | /* If multiplexed, make sure we do not go over concurrency limit */ |
| 694 | if(conn->attached_xfers >= |
| 695 | Curl_multi_max_concurrent_streams(m->data->multi)) { |
| 696 | infof(m->data, "client side MAX_CONCURRENT_STREAMS reached" |
| 697 | ", skip (%u)", conn->attached_xfers); |
| 698 | return FALSE; |
| 699 | } |
| 700 | if(conn->attached_xfers >= |
| 701 | Curl_conn_get_max_concurrent(m->data, conn, FIRSTSOCKET)) { |
| 702 | infof(m->data, "MAX_CONCURRENT_STREAMS reached, skip (%u)", |
| 703 | conn->attached_xfers); |
| 704 | return FALSE; |
| 705 | } |
| 706 | /* When not multiplexed, we have a match here! */ |
| 707 | infof(m->data, "Multiplexed connection found"); |
| 708 | } |
| 709 | return TRUE; |
| 710 | } |
| 711 | |
| 712 | static bool url_match_ssl_use(struct connectdata *conn, |
| C1: url_match_ssl_use() allows reuse when protocol has PROTOPT_SSL_REUSE (FTP does) and connection is already SSL | |
| 713 | struct url_conn_match *m) |
| 714 | { |
| 715 | if(m->needle->scheme->flags & PROTOPT_SSL) { |
| 716 | /* We are looking for SSL, if `conn` does not do it, not a match. */ |
| 717 | if(!Curl_conn_is_ssl(conn, FIRSTSOCKET)) |
| 718 | return FALSE; |
| 719 | } |
| 720 | else if(Curl_conn_is_ssl(conn, FIRSTSOCKET)) { |
| C3: url_match_ssl_use allows reuse of an existing SSL connection when protocol has PROTOPT_SSL_REUSE | |
| 721 | /* If the protocol does not allow reuse of SSL connections OR |
| 722 | is of another protocol family, not a match. */ |
| 723 | if(!(m->needle->scheme->flags & PROTOPT_SSL_REUSE) || |
| 724 | (get_protocol_family(conn->scheme) != m->needle->scheme->protocol)) |
| 725 | return FALSE; |
| 726 | } |
| 727 | else if(m->require_tls) |
| 728 | /* a clear-text STARTTLS protocol with required TLS */ |
| 729 | return FALSE; |
| 730 | return TRUE; |
| 731 | } |
| 732 | |
| 733 | #ifndef CURL_DISABLE_PROXY |
| 734 | static bool url_match_proxy_use(struct connectdata *conn, |
| 735 | struct url_conn_match *m) |
| 736 | { |
| 737 | if(m->needle->bits.origin_is_proxy != conn->bits.origin_is_proxy) |
| 738 | return FALSE; |
| 739 | |
| 740 | if(!proxy_info_matches(&m->needle->socks_proxy, &conn->socks_proxy)) |
| 741 | return FALSE; |
| 742 | |
| 743 | if(!proxy_info_matches(&m->needle->http_proxy, &conn->http_proxy)) |
| 744 | return FALSE; |
| 745 | |
| 746 | if(CURL_PROXY_IS_HTTPS(m->needle->http_proxy.proxytype)) { |
| 747 | /* https proxies come in different types, http/1.1, h2, ... */ |
| 748 | /* match SSL config to proxy */ |
| 749 | if(!Curl_ssl_conn_config_match(m->data, conn, TRUE)) { |
| 750 | DEBUGF(infof(m->data, |
| 751 | "Connection #%" FMT_OFF_T |
| 752 | " has different SSL proxy parameters, cannot reuse", |
| 753 | conn->connection_id)); |
| 754 | return FALSE; |
| 755 | } |
| 1162 | bool success; |
| 1163 | |
| 1164 | DEBUGASSERT(!data->conn); |
| 1165 | |
| 1166 | memset(&match, 0, sizeof(match)); |
| 1167 | match.data = data; |
| 1168 | match.needle = needle; |
| 1169 | match.now = *Curl_pgrs_now(data); |
| 1170 | match.may_multiplex = xfer_may_multiplex(data, needle); |
| 1171 | |
| 1172 | Curl_cpool_prune_dead(cpool, data); |
| 1173 | |
| 1174 | #ifdef USE_NTLM |
| 1175 | match.want_ntlm_http = |
| 1176 | (data->state.authhost.want & CURLAUTH_NTLM) && |
| 1177 | (needle->scheme->protocol & PROTO_FAMILY_HTTP); |
| 1178 | #ifndef CURL_DISABLE_PROXY |
| 1179 | match.want_proxy_ntlm_http = |
| 1180 | needle->http_proxy.creds && |
| 1181 | (data->state.authproxy.want & CURLAUTH_NTLM) && |
| 1182 | (needle->scheme->protocol & PROTO_FAMILY_HTTP); |
| 1183 | #endif |
| 1184 | #endif |
| 1185 | |
| 1186 | #if !defined(CURL_DISABLE_HTTP) && defined(USE_SPNEGO) |
| 1187 | match.want_nego_http = |
| 1188 | (data->state.authhost.want & CURLAUTH_NEGOTIATE) && |
| 1189 | (needle->scheme->protocol & PROTO_FAMILY_HTTP); |
| 1190 | #ifndef CURL_DISABLE_PROXY |
| 1191 | match.want_proxy_nego_http = |
| 1192 | needle->http_proxy.creds && |
| 1193 | (data->state.authproxy.want & CURLAUTH_NEGOTIATE) && |
| 1194 | (needle->scheme->protocol & PROTO_FAMILY_HTTP); |
| 1195 | #endif |
| 1196 | #endif |
| 1197 | match.require_tls = data->set.use_ssl >= CURLUSESSL_CONTROL; |
| 1198 | match.may_tls = data->set.use_ssl > CURLUSESSL_NONE; |
| C1: For CURLUSESSL_NONE, may_tls = false (0 > 0 is false) | |
| 1199 | |
| 1200 | /* Find a connection in the pool that matches what "data + needle" |
| 1201 | * requires. If a suitable candidate is found, it is attached to "data". */ |
| 1202 | success = Curl_cpool_find(data, needle->destination, |
| 1203 | url_match_conn, url_match_result, &match); |
| 1204 | |
| 1205 | /* wait_pipe is TRUE if we encounter a bundle that is undecided. There |
| 1206 | * is no matching connection then, yet. */ |
| 1207 | *waitpipe = (bool)match.wait_pipe; |
| 1208 | return success; |
| 1209 | } |
| 1210 | |
| 1211 | /* |
| 1212 | * Allocate and initialize a new connectdata object. |
| 1213 | */ |
| 858 | |
| 859 | return TRUE; |
| 860 | } |
| 861 | |
| 862 | static bool url_match_destination(struct connectdata *conn, |
| 863 | struct url_conn_match *m) |
| 864 | { |
| 865 | /* Different connect-to peers never match */ |
| 866 | if(!Curl_peer_same_destination(m->needle->via_peer, conn->via_peer)) |
| 867 | return FALSE; |
| 868 | |
| 869 | if(m->needle->origin->scheme != conn->origin->scheme) { |
| 870 | /* `needle` and `conn` not having the same scheme. |
| 871 | * This is allowed for the same family *if* conn is using TLS. |
| 872 | * - IMAP+STARTTLS works for IMAPS. |
| 873 | * - IMAPS works for IMAP. */ |
| 874 | if(get_protocol_family(conn->origin->scheme) != |
| 875 | m->needle->scheme->protocol) { |
| 876 | return FALSE; |
| 877 | } |
| 878 | } |
| 879 | /* Scheme mismatch is acceptable, compare hostname/port */ |
| 880 | return Curl_peer_same_destination(m->needle->origin, conn->origin); |
| 881 | } |
| 882 | |
| 883 | static bool url_match_ssl_config(struct connectdata *conn, |
| 884 | struct url_conn_match *m) |
| 885 | { |
| 886 | /* If talking/upgrading to TLS, conn needs to use the same SSL options. */ |
| 887 | if(((m->needle->scheme->flags & PROTOPT_SSL) || m->may_tls) && |
| C1: url_match_ssl_config() only calls Curl_ssl_conn_config_match when PROTOPT_SSL or may_tls is true; neither holds for FTP with CURLUSESSL_NONE | |
| C2: Condition ((m->needle->scheme->flags & PROTOPT_SSL) || m->may_tls) is false for FTP with CURLUSESSL_NONE, so Curl_ssl_conn_config_match is never called | |
| C4: No SSL config comparison occurs, so client-certificate settings are not verified to match | |
| 888 | !Curl_ssl_conn_config_match(m->data, conn, FALSE)) { |
| 889 | DEBUGF(infof(m->data, "Connection #%" FMT_OFF_T |
| 890 | " has different SSL parameters, cannot reuse", |
| 891 | conn->connection_id)); |
| 892 | return FALSE; |
| 893 | } |
| 894 | return TRUE; |
| 895 | } |
| 896 | |
| 897 | #if defined(USE_SPNEGO) || defined(USE_NTLM) |
| 898 | static bool url_allow_sspi_empty_creds(struct Curl_creds *conn_creds, |
| 899 | struct Curl_easy *data, |
| 900 | struct connectdata *conn) |
| 901 | { |
| 902 | #ifdef USE_WINDOWS_SSPI |
| 903 | /* Empty user: SSPI on Windows can make use of an "ambient" |
| 904 | * user from a "SecurityToken" associated with the current thread or |
| 905 | * process. This token can be switched at any time. We are therefore |
| 906 | * not able to find out reliably what token the connection really |
| 907 | * used, nor what token in the next connect attempt will use. |
| 908 | * To avoid TOCTOU attacks, do not reuse on empty credentials |
| 909 | * UNLESS this connection is the one used by this transfer before. */ |
| 910 | if(!Curl_creds_has_user(conn_creds) && |
| 911 | (data->state.lastconnect_id != conn->connection_id)) |
| 912 | return FALSE; |
| 913 | #else |
| 914 | (void)conn_creds; |
| 915 | (void)data; |
| 916 | (void)conn; |
| 917 | #endif |
| 918 | return TRUE; |
| 919 | } |
| 920 | #endif /* USE_SPNEGO || USE_NTLM */ |
| 4447 | ftpc->alternative_to_user = |
| 4448 | curlx_strdup(CURL_EASY_STR(data, STRING_FTP_ALTERNATIVE_TO_USER)); |
| 4449 | if(!ftpc->alternative_to_user) { |
| 4450 | curlx_safefree(ftpc->account); |
| 4451 | Curl_conn_meta_remove(conn, CURL_META_FTP_CONN); |
| 4452 | return CURLE_OUT_OF_MEMORY; |
| 4453 | } |
| 4454 | } |
| 4455 | |
| 4456 | ftp->path = &data->state.up.path[1]; /* do not include the initial slash */ |
| 4457 | |
| 4458 | type_url_check(data, ftp); |
| 4459 | |
| 4460 | /* get some initial data into the ftp struct */ |
| 4461 | ftp->transfer = PPTRANSFER_BODY; |
| 4462 | ftp->downloadsize = 0; |
| 4463 | ftpc->known_filesize = -1; /* unknown size for now */ |
| 4464 | ftpc->use_ssl = data->set.use_ssl; |
| 4465 | ftpc->ccc = data->set.ftp_ccc; |
| 4466 | |
| 4467 | CURL_TRC_FTP(data, "[%s] setup connection -> %d", FTP_CSTATE(ftpc), |
| 4468 | (int)result); |
| 4469 | return result; |
| 4470 | } |
| 4471 | |
| 4472 | bool Curl_ftp_conns_match(struct connectdata *needle, struct connectdata *conn) |
| 4473 | { |
| 4474 | struct ftp_conn *nftpc = Curl_conn_meta_get(needle, CURL_META_FTP_CONN); |
| 4475 | struct ftp_conn *cftpc = Curl_conn_meta_get(conn, CURL_META_FTP_CONN); |
| 4476 | /* Also match ACCOUNT, ALTERNATIVE-TO-USER and CCC options */ |
| 4477 | if(!nftpc || !cftpc || |
| 4478 | Curl_timestrcmp(nftpc->account, cftpc->account) || |
| 4479 | Curl_timestrcmp(nftpc->alternative_to_user, |
| 4480 | cftpc->alternative_to_user) || |
| 4481 | (nftpc->ccc != cftpc->ccc)) |
| 4482 | return FALSE; |
| 4483 | /* A mismatch on `use_ssl` MUST have been found in connection matching |
| 4484 | * before we come here. This is a check on MAYBE/MUST use of STARTTLS and |
| 4485 | * it only works on FTP. But IMAP/SMTP etc have the same `use_ssl` and |
| 4486 | * no extra match like FTP. We lack tests in this area, so let FTP fail |
| 4487 | * loudly here to help other cases. */ |
| 4488 | if(nftpc->use_ssl > cftpc->use_ssl) { |
| C1: Curl_ftp_conns_match() only rejects when new use_ssl > pooled use_ssl; 0 > 2 is false, so CURLUSESSL_NONE matches a CURLUSESSL_CONTROL connection | |
| C3: Curl_ftp_conns_match allows reuse when new use_ssl (0) is not greater than pooled use_ssl (2) | |
| C4: Connection reuse proceeds without re-authentication | |
| 4489 | DEBUGASSERT(0); |
| 4490 | return FALSE; |
| 4491 | } |
| 4492 | return TRUE; |
| 4493 | } |
| 4494 | |
| 4495 | /* |
| 4496 | * FTP protocol. |
| 4497 | */ |
| 4498 | const struct Curl_protocol Curl_protocol_ftp = { |
| 4499 | ftp_setup_connection, /* setup_connection */ |
| 4500 | ftp_do, /* do_it */ |
| 4501 | ftp_done, /* done */ |
| 4502 | ftp_do_more, /* do_more */ |
| 4503 | ftp_connect, /* connect_it */ |
| 4504 | ftp_multi_statemach, /* connecting */ |
| 4505 | ftp_doing, /* doing */ |
| 4506 | ftp_pollset, /* proto_pollset */ |
| 4507 | ftp_pollset, /* doing_pollset */ |
| 4508 | ftp_domore_pollset, /* domore_pollset */ |
| 4509 | ZERO_NULL, /* perform_pollset */ |
| 4510 | ftp_disconnect, /* disconnect */ |
| 4511 | ZERO_NULL, /* write_resp */ |
| 4512 | ZERO_NULL, /* write_resp_hd */ |
| 4513 | ZERO_NULL, /* connection_is_dead */ |
| 4514 | ZERO_NULL, /* attach connection */ |
| 4515 | ZERO_NULL, /* follow */ |
| 4516 | }; |
| 4517 |
Show 1 lines (not cited by the judge)
| 934 | #define CURLUSESSL_CONTROL 2L /* SSL for the control connection or fail */ |
Show 1 lines (not cited by the judge)
| 932 | #define CURLUSESSL_NONE 0L /* do not attempt to use SSL */ |
Show 14 lines (not cited by the judge)
| 171 | bool Curl_ssl_conn_config_match(struct Curl_easy *data, |
| 172 | struct connectdata *candidate, |
| 173 | bool proxy) |
| 174 | { |
| 175 | #ifndef CURL_DISABLE_PROXY |
| 176 | if(proxy) |
| 177 | return match_ssl_primary_config(data, &data->set.proxy_ssl.primary, |
| 178 | &candidate->proxy_ssl_config); |
| 179 | #else |
| 180 | (void)proxy; |
| 181 | #endif |
| 182 | return match_ssl_primary_config(data, &data->set.ssl.primary, |
| 183 | &candidate->ssl_config); |
| 184 | } |
Show 1 lines (not cited by the judge)
| 207 | #define PROTOPT_SSL (1 << 0) /* uses SSL */ |
Show 1 lines (not cited by the judge)
| 234 | #define PROTOPT_SSL_REUSE (1 << 15) /* this protocol may reuse an existing |