✓ProveIt
Examples / Report #3973176 Saved analysis

CVE-2026-86410: redirect to SFTP or SCP bypasses host verification

curl · 2026-08-2713.8s analysis · $0.018 in model calls

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

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?
  • 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.

1 · Grounding
grounded
no model · 14 references checked out, 0 did not
2 · Claims
NVIDIA-Nemotron-3-Nano-30B-A3B
5 claims · 2364 tokens · $0.0002
3 · Evidence
3 code snippets
real curl source at b73a4f00b2a6 · 3 docs via Tavily
4 · Judgment
Nemotron-3-Ultra-550b-a55b
13609 tokens · 8.7s · $0.0182
5 · Receipts
8 of 10 citations verified
every citation must point at code the judge was shown
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

C1flaw supported

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.

C2location supported

src/config2setopts.c, line 205

Read the reasoning

The report cites line 205; the function definition begins at line 205 in the provided source.

C3trigger supported

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().

src/config2setopts.c:1190 src/config2setopts.c:955
C4impact supported

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.

C5reproduction supported

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.

src/config2setopts.c:210 src/config2setopts.c:955
Read the original report (excerpt)

Full report on HackerOne ↗

## Summary `ssh_setopts()` in `src/config2setopts.c` returns immediately when the initial URL is not SCP or SFTP. This means `--knownhosts`, `--hostpubsha256`, and `--hostpubmd5` are never installed on the easy handle when the initial URL is HTTPS. If `--location --proto-redir +sftp` is used and the server returns a `Location: sftp://…` redirect, the SFTP connection proceeds without any of the user's requested SSH host-key verification. Commit `ab3bb8cd8be8f9d4acb97da0418abc279182041e` fixed CVE-2026-12064 for `--proto-default` but did not touch the early-return guard that causes this redirect variant. The vulnerable guard is present in the released `curl-8_21_0` tag and at current HEAD. ## Affected versions Affected versions: curl CLI 7.81.0 through 8.21.0, plus affected 8.22.0 ## Root cause In `src/config2setopts.c`, `ssh_setopts()` (line 205) bails out early: ```c if(use_proto != proto_scp && use_proto != proto_sftp) return CURLE_OK; ``` `use_proto` is derived from the initial command-line URL in `config2setopts()`. When the initial URL is HTTPS, none of `CURLOPT_SSH_KNOWNHOSTS`, `CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256`, or `CURLOPT_SSH_HOST_PUBLIC_KEY_MD5` are set on the easy handle. Redirect handling occurs inside libcurl and does not rerun `config2setopts()`, so the SFTP connection has no host-key verification policy. Verbose output on the redirected connection explicitly reports `SSH: no knownhosts file configured`. ## Security impact An attacker who can impersonate the redirected SFTP endpoint can substitute downloaded content while curl reports success. The user's explicit `--knownhosts` or `--hostpubsha256` policy is silently ignored. Preconditions: 1. SSH-enabled curl build (libssh2 or libssh). 2. `--location --proto-redir +sftp` (user explicitly enables SFTP redirects). 3. A trusted HTTPS origin that redirects to SFTP. 4. Authentication credentials usable at the SFTP endpoint. 5. An attacker able to impersonate the SSH endpoint. I expect Low severity given the narrow attack surface and leave classification to the security team. ## Reproduction 1. Build an affected curl command-line tool, starting with 7.81.0, with SFTP support through libssh2 or libssh and HTTPS support through a TLS backend. 2. Start an SFTP server with a known SSH host key, valid test credentials, and a file containing recognizable test data. 3. Create a `known_hos …

Source code

src/config2setopts.clines 180–258 · lines 205-205 cited by the report; definition of ssh_setopts
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
205static 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}
src/config2setopts.clines 1184–1235 · definition of config2setopts
1184CURLcode 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}
src/config2setopts.c@curl-8_21_0lines 855–1015 · definition of config2setopts as in release curl-8_21_0; differs from master; commits that changed it since: 8aeef462e3 config2setopt.c: refactor config2setopts
Show 161 lines (not cited by the judge)
855CURLcode 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 }