CVE-2026-86410: redirect to SFTP or SCP bypasses host verification
What the report claims
Curl bypasses user-requested SSH host-key verification when redirecting from HTTPS to SFTP, ignoring --knownhosts and --hostpubsha256 settings.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2026-86410 redirect to SFTP or SCP bypasses host verification
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?
- The early-return guard in ssh_setopts() (src/config2setopts.c:210-211) is present in both the current master and the curl-8_21_0 release, confirming the vulnerability exists in released versions.
- The guard prevents installation of CURLOPT_SSH_KNOWNHOSTS, CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256, and CURLOPT_SSH_HOST_PUBLIC_KEY_MD5 when the initial URL is not SCP/SFTP.
- Redirect handling in libcurl does not re-run config2setopts(), so an HTTPS→SFTP redirect uses an easy handle that lacks the user's requested SSH host verification options.
- The user explicitly enables SFTP redirects (--proto-redir +sftp) and explicitly requests host verification (--knownhosts/--hostpubsha256); the bug silently drops the latter when the former is used.
- This is not API misuse, local attack, or documented behavior — it is an implementation oversight that bypasses a user-configured security policy.
How this analysis ran 5 claims · 8 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.
Thank you for the detailed report and reproduction steps. The early-return guard in ssh_setopts() (src/config2setopts.c:210-211) does prevent SSH host-verification options from being installed when the initial URL is HTTPS, and those options are not re-applied when a redirect switches to SFTP. This silently ignores the user's --knownhosts/--hostpubsha256 policy. We will treat this as a valid security issue and prepare a fix that installs SSH options regardless of the initial protocol, since they are harmless on non-SSH transfers but required for redirected SSH transfers.
Inspect all claims and source code 5 claims · 3 code excerpts
Report claims
ssh_setopts() returns early when the initial URL is not SCP or SFTP, preventing SSH options from being set on the easy handle
Read the reasoning
The function ssh_setopts() at line 205-258 contains the guard `if(use_proto != proto_scp && use_proto != proto_sftp) return CURLE_OK;` at lines 210-211, which prevents SSH host verification options from being installed when the initial URL protocol is not SCP or SFTP.
src/config2setopts.c, line 205
Read the reasoning
The report cites line 205; the function definition begins at line 205 in the provided source.
initial URL is HTTPS, causing use_proto to exclude SCP/SFTP and skip host-key option installation
Read the reasoning
In config2setopts(), use_proto is determined from the initial command-line URL at line 1190 and passed to ssh_setopts() at line 955. When the initial URL is HTTPS, use_proto will be proto_http, causing the early return in ssh_setopts().
When redirected to SFTP via --location --proto-redir, the SFTP connection proceeds without any SSH host-key verification, ignoring user-specified --knownhosts or --hostpubsha256
Read the reasoning
All three SSH host verification options (lines 219, 223-224, 241) are installed only after the early-return guard. When the initial URL is HTTPS, the guard triggers and none of these options are set on the easy handle. A subsequent redirect to SFTP (allowed by --proto-redir +sftp) will use the same easy handle without these options, so host-key verification is silently skipped.
Use HTTPS server that redirects to SFTP, configure mismatching --knownhosts, and observe successful download despite verification policy
Read the reasoning
The reproduction steps are plausible given the code structure: config2setopts() runs once per transfer using the initial URL's protocol; redirects are handled inside libcurl without re-running config2setopts(). The mismatching --knownhosts file would be ignored on the redirected SFTP connection because CURLOPT_SSH_KNOWNHOSTS was never set.
Read the original report (excerpt)
Source code
| 180 | result = ipfs_url_rewrite(uh, schemep, url, config); |
| 181 | /* short-circuit proto_token, we know it is ipfs or ipns */ |
| 182 | if(curl_strequal(schemep, proto_ipfs)) |
| 183 | proto = proto_ipfs; |
| 184 | else if(curl_strequal(schemep, proto_ipns)) |
| 185 | proto = proto_ipns; |
| 186 | if(result) |
| 187 | config->synthetic_error = TRUE; |
| 188 | } |
| 189 | #endif /* !CURL_DISABLE_IPFS */ |
| 190 | if(uc == CURLUE_OUT_OF_MEMORY) |
| 191 | result = CURLE_OUT_OF_MEMORY; |
| 192 | curl_free(schemep); |
| 193 | } |
| 194 | else if(uc == CURLUE_OUT_OF_MEMORY) |
| 195 | result = CURLE_OUT_OF_MEMORY; |
| 196 | curl_url_cleanup(uh); |
| 197 | } |
| 198 | else |
| 199 | result = CURLE_OUT_OF_MEMORY; |
| 200 | |
| 201 | *scheme = proto ? proto : "?"; /* Never match if not found. */ |
| 202 | return result; |
| 203 | } |
| 204 | |
| 205 | static CURLcode ssh_setopts(struct OperationConfig *config, CURL *curl, |
| C2: Function definition of ssh_setopts starts here | |
| 206 | const char *use_proto) |
| 207 | { |
| 208 | CURLcode result; |
| 209 | |
| 210 | if(use_proto != proto_scp && use_proto != proto_sftp) |
| C1: Early return when use_proto is not proto_scp or proto_sftp | |
| C5: Guard prevents SSH options for non-SSH initial URLs | |
| 211 | return CURLE_OK; |
| C1: Returns CURLE_OK without setting any SSH options | |
| 212 | |
| 213 | /* SSH and SSL private key uses same command-line option */ |
| 214 | MY_SETOPT_STR(curl, CURLOPT_SSH_PRIVATE_KEYFILE, config->key); |
| 215 | MY_SETOPT_STR(curl, CURLOPT_SSH_PUBLIC_KEYFILE, config->pubkey); |
| 216 | |
| 217 | /* SSH host key md5 checking allows us to fail if we are not talking to who |
| 218 | we think we should */ |
| 219 | MY_SETOPT_STR(curl, CURLOPT_SSH_HOST_PUBLIC_KEY_MD5, config->hostpubmd5); |
| C4: CURLOPT_SSH_HOST_PUBLIC_KEY_MD5 set only after guard | |
| 220 | |
| 221 | /* SSH host key sha256 checking allows us to fail if we are not talking to |
| 222 | who we think we should */ |
| 223 | MY_SETOPT_STR(curl, CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256, |
| C4: CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256 set only after guard | |
| 224 | config->hostpubsha256); |
| 225 | |
| 226 | if(config->ssh_compression) |
| 227 | my_setopt_long(curl, CURLOPT_SSH_COMPRESSION, 1); |
| 228 | |
| 229 | if(!config->insecure_ok) { |
| 230 | char *known = config->knownhosts; |
| 231 | if(!known) { |
| 232 | char *found = findfile(".ssh/known_hosts", FALSE); |
| 233 | if(found) { |
| 234 | known = curlx_strdup(found); |
| 235 | curl_free(found); |
| 236 | if(!known) |
| 237 | return CURLE_OUT_OF_MEMORY; |
| 238 | } |
| 239 | } |
| 240 | if(known) { |
| 241 | result = my_setopt_str(curl, CURLOPT_SSH_KNOWNHOSTS, known); |
| C4: CURLOPT_SSH_KNOWNHOSTS set only after guard | |
| 242 | if(result) { |
| 243 | config->knownhosts = NULL; |
| 244 | curlx_free(known); |
| 245 | return result; |
| 246 | } |
| 247 | /* store it in global to avoid repeated checks */ |
| 248 | config->knownhosts = known; |
| 249 | } |
| 250 | else if(!config->hostpubmd5 && !config->hostpubsha256) { |
| 251 | errorf("Could not find a known_hosts file"); |
| 252 | return CURLE_FAILED_INIT; |
| 253 | } |
| 254 | else |
| 255 | warnf("Could not find a known_hosts file"); |
| 256 | } |
| 257 | return CURLE_OK; /* ignore if SHA256 did not work */ |
| 258 | } |
| 1184 | CURLcode config2setopts(struct OperationConfig *config, |
| 1185 | struct per_transfer *per, |
| 1186 | CURL *curl, |
| 1187 | CURLSH *share) |
| 1188 | { |
| 1189 | const char *use_proto; |
| 1190 | CURLcode result = url_proto_and_rewrite(&per->url, config, &use_proto); |
| C3: use_proto derived from initial URL via url_proto_and_rewrite | |
| 1191 | |
| 1192 | /* Avoid having this setopt added to the --libcurl source output. */ |
| 1193 | if(!result) |
| 1194 | result = curl_easy_setopt(curl, CURLOPT_SHARE, share); |
| 1195 | if(result) |
| 1196 | return result; |
| 1197 | |
| 1198 | #ifdef DEBUGBUILD |
| 1199 | if(getenv("CURL_QUICK_EXIT")) |
| 1200 | #endif |
| 1201 | { |
| 1202 | /* QUICK_EXIT allows for running threads to be detached and not |
| 1203 | * joined. Preferably in non-debug runs. */ |
| 1204 | result = curl_easy_setopt(curl, CURLOPT_QUICK_EXIT, 1L); |
| 1205 | if(result) |
| 1206 | return result; |
| 1207 | } |
| 1208 | |
| 1209 | gen_trace_setopts(config, curl); |
| 1210 | |
| 1211 | buffersize(config, curl); |
| 1212 | |
| 1213 | MY_SETOPT_STR(curl, CURLOPT_URL, per->url); |
| 1214 | my_setopt_long(curl, CURLOPT_NOPROGRESS, |
| 1215 | global->noprogress || global->silent); |
| 1216 | /* call after the line above. It may override CURLOPT_NOPROGRESS */ |
| 1217 | gen_cb_setopts(config, per, curl); |
| 1218 | |
| 1219 | result = proxy_setopts(config, curl); |
| 1220 | if(setopt_bad(result) || config->synthetic_error) |
| 1221 | return result; |
| 1222 | |
| 1223 | result = credentials_and_headers_setopts(config, curl); |
| 1224 | if(!setopt_bad(result)) |
| 1225 | result = transfer_setopts(config, per, curl); |
| 1226 | if(!setopt_bad(result)) |
| 1227 | result = protocol_setopts(config, per, curl, use_proto); |
| 1228 | if(!setopt_bad(result)) |
| 1229 | result = dns_and_network_setopts(config, per, curl); |
| 1230 | if(!setopt_bad(result)) |
| 1231 | result = mail_and_sasl_setopts(config, curl); |
| 1232 | if(!setopt_bad(result)) |
| 1233 | result = misc_setopts(config, curl); |
| 1234 | return result; |
| 1235 | } |
Show 161 lines (not cited by the judge)
| 855 | CURLcode config2setopts(struct OperationConfig *config, |
| 856 | struct per_transfer *per, |
| 857 | CURL *curl, |
| 858 | CURLSH *share) |
| 859 | { |
| 860 | const char *use_proto; |
| 861 | CURLcode result = url_proto_and_rewrite(&per->url, config, &use_proto); |
| 862 | |
| 863 | /* Avoid having this setopt added to the --libcurl source output. */ |
| 864 | if(!result) |
| 865 | result = curl_easy_setopt(curl, CURLOPT_SHARE, share); |
| 866 | if(result) |
| 867 | return result; |
| 868 | |
| 869 | if(TRUE |
| 870 | #ifdef DEBUGBUILD |
| 871 | && getenv("CURL_QUICK_EXIT") |
| 872 | #endif |
| 873 | ) { |
| 874 | /* QUICK_EXIT allows for running threads to be detached and not |
| 875 | * joined. Preferably in non-debug runs. */ |
| 876 | result = curl_easy_setopt(curl, CURLOPT_QUICK_EXIT, 1L); |
| 877 | if(result) |
| 878 | return result; |
| 879 | } |
| 880 | |
| 881 | gen_trace_setopts(config, curl); |
| 882 | |
| 883 | buffersize(config, curl); |
| 884 | |
| 885 | MY_SETOPT_STR(curl, CURLOPT_URL, per->url); |
| 886 | my_setopt_long(curl, CURLOPT_NOPROGRESS, |
| 887 | global->noprogress || global->silent); |
| 888 | /* call after the line above. It may override CURLOPT_NOPROGRESS */ |
| 889 | gen_cb_setopts(config, per, curl); |
| 890 | |
| 891 | my_setopt_long(curl, CURLOPT_NOBODY, config->no_body); |
| 892 | MY_SETOPT_STR(curl, CURLOPT_XOAUTH2_BEARER, config->oauth_bearer); |
| 893 | result = proxy_setopts(config, curl); |
| 894 | if(setopt_bad(result) || config->synthetic_error) |
| 895 | return result; |
| 896 | |
| 897 | my_setopt_long(curl, CURLOPT_FAILONERROR, config->fail == FAIL_WO_BODY); |
| 898 | MY_SETOPT_STR(curl, CURLOPT_REQUEST_TARGET, config->request_target); |
| 899 | my_setopt_long(curl, CURLOPT_UPLOAD, !!per->uploadfile); |
| 900 | my_setopt_long(curl, CURLOPT_DIRLISTONLY, config->dirlistonly); |
| 901 | my_setopt_long(curl, CURLOPT_APPEND, config->ftp_append); |
| 902 | |
| 903 | if(config->netrc_opt) |
| 904 | my_setopt_enum(curl, CURLOPT_NETRC, CURL_NETRC_OPTIONAL); |
| 905 | else if(config->netrc || config->netrc_file) |
| 906 | my_setopt_enum(curl, CURLOPT_NETRC, CURL_NETRC_REQUIRED); |
| 907 | else |
| 908 | my_setopt_enum(curl, CURLOPT_NETRC, CURL_NETRC_IGNORED); |
| 909 | |
| 910 | MY_SETOPT_STR(curl, CURLOPT_NETRC_FILE, config->netrc_file); |
| 911 | my_setopt_long(curl, CURLOPT_TRANSFERTEXT, config->use_ascii); |
| 912 | MY_SETOPT_STR(curl, CURLOPT_LOGIN_OPTIONS, config->login_options); |
| 913 | MY_SETOPT_STR(curl, CURLOPT_USERPWD, config->userpwd); |
| 914 | MY_SETOPT_STR(curl, CURLOPT_RANGE, config->range); |
| 915 | my_setopt_ptr(curl, CURLOPT_ERRORBUFFER, per->errorbuffer); |
| 916 | my_setopt_long(curl, CURLOPT_TIMEOUT_MS, config->timeout_ms); |
| 917 | |
| 918 | result = setopt_post(config, curl); |
| 919 | if(result) |
| 920 | return result; |
| 921 | |
| 922 | if(config->mime_options) |
| 923 | my_setopt_long(curl, CURLOPT_MIME_OPTIONS, config->mime_options); |
| 924 | |
| 925 | if(config->authtype) |
| 926 | my_setopt_bitmask(curl, CURLOPT_HTTPAUTH, config->authtype); |
| 927 | |
| 928 | my_setopt_slist(curl, CURLOPT_HTTPHEADER, config->headers); |
| 929 | |
| 930 | if(proto_http || proto_rtsp) { |
| 931 | MY_SETOPT_STR(curl, CURLOPT_REFERER, config->referer); |
| 932 | MY_SETOPT_STR(curl, CURLOPT_USERAGENT, config->useragent ? |
| 933 | config->useragent : CURL_NAME "/" CURL_VERSION); |
| 934 | } |
| 935 | |
| 936 | result = http_setopts(config, curl, use_proto); |
| 937 | if(!result) |
| 938 | result = ftp_setopts(config, curl, use_proto); |
| 939 | if(result) |
| 940 | return result; |
| 941 | |
| 942 | my_setopt_long(curl, CURLOPT_LOW_SPEED_LIMIT, config->low_speed_limit); |
| 943 | my_setopt_long(curl, CURLOPT_LOW_SPEED_TIME, config->low_speed_time); |
| 944 | my_setopt_offt(curl, CURLOPT_MAX_SEND_SPEED_LARGE, config->sendpersecond); |
| 945 | my_setopt_offt(curl, CURLOPT_MAX_RECV_SPEED_LARGE, config->recvpersecond); |
| 946 | |
| 947 | if(config->use_resume) |
| 948 | my_setopt_offt(curl, CURLOPT_RESUME_FROM_LARGE, config->resume_from); |
| 949 | else |
| 950 | my_setopt_offt(curl, CURLOPT_RESUME_FROM_LARGE, 0); |
| 951 | |
| 952 | MY_SETOPT_STR(curl, CURLOPT_KEYPASSWD, config->key_passwd); |
| 953 | MY_SETOPT_STR(curl, CURLOPT_PROXY_KEYPASSWD, config->proxy_key_passwd); |
| 954 | |
| 955 | result = ssh_setopts(config, curl, use_proto); |
| 956 | if(setopt_bad(result)) |
| 957 | return result; |
| 958 | |
| 959 | if(feature_ssl) { |
| 960 | result = ssl_ca_setopts(config, curl); |
| 961 | if(!result) |
| 962 | result = ssl_setopts(config, curl); |
| 963 | if(setopt_bad(result)) |
| 964 | return result; |
| 965 | #ifdef DEBUGBUILD |
| 966 | if(!per->urlnum) { |
| 967 | char *env = getenv("CURL_DBG_NO_USE_SSL_ON_FIRST"); |
| 968 | if(env) |
| 969 | my_setopt_enum(curl, CURLOPT_USE_SSL, CURLUSESSL_NONE); |
| 970 | } |
| 971 | #endif |
| 972 | } |
| 973 | |
| 974 | if(config->path_as_is) |
| 975 | my_setopt_long(curl, CURLOPT_PATH_AS_IS, 1); |
| 976 | |
| 977 | if(config->no_body || config->remote_time) |
| 978 | /* no body or use remote time */ |
| 979 | my_setopt_long(curl, CURLOPT_FILETIME, 1); |
| 980 | |
| 981 | my_setopt_long(curl, CURLOPT_CRLF, config->crlf); |
| 982 | my_setopt_slist(curl, CURLOPT_QUOTE, config->quote); |
| 983 | my_setopt_slist(curl, CURLOPT_POSTQUOTE, config->postquote); |
| 984 | my_setopt_slist(curl, CURLOPT_PREQUOTE, config->prequote); |
| 985 | |
| 986 | my_setopt_enum(curl, CURLOPT_TIMECONDITION, config->timecond); |
| 987 | my_setopt_offt(curl, CURLOPT_TIMEVALUE_LARGE, config->condtime); |
| 988 | MY_SETOPT_STR(curl, CURLOPT_CUSTOMREQUEST, config->customrequest); |
| 989 | customrequest_helper(config->httpreq, config->customrequest); |
| 990 | my_setopt_ptr(curl, CURLOPT_STDERR, tool_stderr); |
| 991 | MY_SETOPT_STR(curl, CURLOPT_INTERFACE, config->iface); |
| 992 | progressbarinit(&per->progressbar, config); |
| 993 | MY_SETOPT_STR(curl, CURLOPT_DNS_SERVERS, config->dns_servers); |
| 994 | MY_SETOPT_STR(curl, CURLOPT_DNS_INTERFACE, config->dns_interface); |
| 995 | MY_SETOPT_STR(curl, CURLOPT_DNS_LOCAL_IP4, config->dns_ipv4_addr); |
| 996 | MY_SETOPT_STR(curl, CURLOPT_DNS_LOCAL_IP6, config->dns_ipv6_addr); |
| 997 | my_setopt_slist(curl, CURLOPT_TELNETOPTIONS, config->telnet_options); |
| 998 | my_setopt_long(curl, CURLOPT_CONNECTTIMEOUT_MS, config->connecttimeout_ms); |
| 999 | MY_SETOPT_STR(curl, CURLOPT_DOH_URL, config->doh_url); |
| 1000 | my_setopt_long(curl, CURLOPT_FTP_CREATE_MISSING_DIRS, |
| 1001 | (config->ftp_create_dirs ? |
| 1002 | CURLFTP_CREATE_DIR_RETRY : CURLFTP_CREATE_DIR_NONE)); |
| 1003 | my_setopt_offt(curl, CURLOPT_MAXFILESIZE_LARGE, config->max_filesize); |
| 1004 | my_setopt_long(curl, CURLOPT_IPRESOLVE, config->ip_version); |
| 1005 | if(config->socks5_gssapi_nec) |
| 1006 | my_setopt_long(curl, CURLOPT_SOCKS5_GSSAPI_NEC, 1); |
| 1007 | if(config->socks5_auth) |
| 1008 | my_setopt_bitmask(curl, CURLOPT_SOCKS5_AUTH, config->socks5_auth); |
| 1009 | MY_SETOPT_STR(curl, CURLOPT_SERVICE_NAME, config->service_name); |
| 1010 | my_setopt_long(curl, CURLOPT_IGNORE_CONTENT_LENGTH, config->ignorecl); |
| 1011 | |
| 1012 | if(config->localport) { |
| 1013 | my_setopt_long(curl, CURLOPT_LOCALPORT, config->localport); |
| 1014 | my_setopt_long(curl, CURLOPT_LOCALPORTRANGE, config->localportrange); |
| 1015 | } |