CVE-2026-12064: proto-default skips SSH verification
What the report claims
curl silently skips SSH host key verification when using --proto-default sftp with a schemeless URL, allowing MITM credential theft.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2026-12064 proto-default skips SSH 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 tool layer (config2setopts.c:190) skips SSH host key options when use_proto is guessed as HTTP for schemeless URLs
- Libcurl (url.c:1664-1668) later applies --proto-default and rewrites the URL to sftp://, so the actual transfer uses SFTP
- The user's explicit --hostpubsha256 is silently ignored, allowing credential disclosure to a MITM attacker
- The PoC confirms the vulnerability on current master (8.21.0-DEV)
How this analysis ran 5 claims · 10 citations verified
Checked against curl master on 2026-06-12 (d77b4b740734). 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.
This is a valid security vulnerability. The tool layer guesses HTTP for schemeless URLs (config2setopts.c:146,190), skipping SSH host key options, but libcurl later applies --proto-default and connects via SFTP (url.c:1664-1668). The user's --hostpubsha256 is silently ignored. The PoC demonstrates credential capture by a rogue SSH server. We'll fix the tool layer to respect --proto-default when deciding whether to set SSH options.
Inspect all claims and source code 5 claims · 3 code excerpts
Report claims
curl skips setting CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256, CURLOPT_SSH_KNOWNHOSTS, and other SSH security options when it guesses the URL scheme as HTTP for a schemeless host
Read the reasoning
The tool layer's url_proto_and_rewrite uses CURLU_GUESS_SCHEME for schemeless URLs, guessing HTTP. This makes use_proto != proto_scp/sftp, so ssh_setopts returns early at line 190 without setting CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256 or CURLOPT_SSH_KNOWNHOSTS. But libcurl's parseurlandfillconn later applies --proto-default and rewrites the URL to sftp://, so the actual transfer uses SFTP without the configured host key verification.
src/config2setopts.c:190
Read the reasoning
Line 190 in src/config2setopts.c is the gate that skips SSH options when use_proto is not SCP or SFTP.
user invokes curl with a schemeless URL and --proto-default sftp (or scp)
Read the reasoning
The trigger is exactly as described: a schemeless URL with --proto-default sftp causes the tool layer to guess HTTP while libcurl later uses SFTP.
connection proceeds without host key verification, sending credentials to an unverified SSH server
Read the reasoning
The SSH host key verification options are never set (lines 203-215 skipped), but libcurl connects via SFTP anyway (line 1666), so credentials are sent to an unverified host.
run curl with --proto-default sftp and a schemeless URL against a rogue SSH server that captures credentials
Read the reasoning
The PoC demonstrates the exact attack: schemeless URL with --proto-default sftp and --hostpubsha256 causes credentials to be sent to a rogue SSH server because the pin check was skipped.
Read the original report (excerpt)
Source code
| 134 | static CURLcode url_proto_and_rewrite(char **url, |
| 135 | struct OperationConfig *config, |
| 136 | const char **scheme) |
| 137 | { |
| 138 | CURLcode result = CURLE_OK; |
| 139 | CURLU *uh = curl_url(); |
| 140 | const char *proto = NULL; |
| 141 | *scheme = NULL; |
| 142 | |
| 143 | DEBUGASSERT(url && *url); |
| 144 | if(uh) { |
| 145 | char *schemep = NULL; |
| 146 | CURLUcode uc = |
| C1: url_proto_and_rewrite uses CURLU_GUESS_SCHEME when parsing schemeless URLs, causing HTTP guess | |
| C3: CURLU_GUESS_SCHEME used when parsing schemeless URLs | |
| 147 | curl_url_set(uh, CURLUPART_URL, *url, |
| 148 | CURLU_GUESS_SCHEME | CURLU_NON_SUPPORT_SCHEME); |
| 149 | if(!uc) { |
| 150 | uc = curl_url_get(uh, CURLUPART_SCHEME, &schemep, CURLU_DEFAULT_SCHEME); |
| 151 | if(!uc) { |
| 152 | #ifdef CURL_DISABLE_IPFS |
| 153 | (void)config; |
| 154 | #else |
| 155 | if(curl_strequal(schemep, proto_ipfs) || |
| 156 | curl_strequal(schemep, proto_ipns)) { |
| 157 | result = ipfs_url_rewrite(uh, schemep, url, config); |
| 158 | /* short-circuit proto_token, we know it is ipfs or ipns */ |
| 159 | if(curl_strequal(schemep, proto_ipfs)) |
| 160 | proto = proto_ipfs; |
| 161 | else if(curl_strequal(schemep, proto_ipns)) |
| 162 | proto = proto_ipns; |
| 163 | if(result) |
| 164 | config->synthetic_error = TRUE; |
| 165 | } |
| 166 | else |
| 167 | #endif /* !CURL_DISABLE_IPFS */ |
| 168 | proto = proto_token(schemep); |
| 169 | curl_free(schemep); |
| 170 | } |
| 171 | else if(uc == CURLUE_OUT_OF_MEMORY) |
| 172 | result = CURLE_OUT_OF_MEMORY; |
| 173 | } |
| 174 | else if(uc == CURLUE_OUT_OF_MEMORY) |
| 175 | result = CURLE_OUT_OF_MEMORY; |
| 176 | curl_url_cleanup(uh); |
| 177 | } |
| 178 | else |
| 179 | result = CURLE_OUT_OF_MEMORY; |
| 180 | |
| 181 | *scheme = proto ? proto : "?"; /* Never match if not found. */ |
| 182 | return result; |
| 183 | } |
| 184 | |
| 185 | static CURLcode ssh_setopts(struct OperationConfig *config, CURL *curl, |
| 186 | const char *use_proto) |
| 187 | { |
| 188 | CURLcode result; |
| 189 | |
| 190 | if(use_proto != proto_scp && use_proto != proto_sftp) |
| C1: ssh_setopts returns early if use_proto is not proto_scp or proto_sftp, skipping SSH host key options | |
| C2: The exact line cited: if(use_proto != proto_scp && use_proto != proto_sftp) return CURLE_OK; | |
| C4: Early return skips setting CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256 (line 203) and CURLOPT_SSH_KNOWNHOSTS (line 211+) | |
| C5: The code path that skips SSH options is reachable via schemeless URL + --proto-default | |
| 191 | return CURLE_OK; |
| 192 | |
| 193 | /* SSH and SSL private key uses same command-line option */ |
| 194 | MY_SETOPT_STR(curl, CURLOPT_SSH_PRIVATE_KEYFILE, config->key); |
| 195 | MY_SETOPT_STR(curl, CURLOPT_SSH_PUBLIC_KEYFILE, config->pubkey); |
| 196 | |
| 197 | /* SSH host key md5 checking allows us to fail if we are not talking to who |
| 198 | we think we should */ |
| 199 | MY_SETOPT_STR(curl, CURLOPT_SSH_HOST_PUBLIC_KEY_MD5, config->hostpubmd5); |
| 200 | |
| 201 | /* SSH host key sha256 checking allows us to fail if we are not talking to |
| 202 | who we think we should */ |
| 203 | MY_SETOPT_STR(curl, CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256, |
| 204 | config->hostpubsha256); |
| 205 | |
| 206 | if(config->ssh_compression) |
| 207 | my_setopt_long(curl, CURLOPT_SSH_COMPRESSION, 1); |
| 208 | |
| 209 | if(!config->insecure_ok) { |
| 210 | char *known = config->knownhosts; |
| 211 | if(!known) { |
| 212 | char *found = findfile(".ssh/known_hosts", FALSE); |
| 213 | if(found) { |
| 214 | known = curlx_strdup(found); |
| 215 | curl_free(found); |
| 1637 | /* |
| 1638 | * Parse URL and fill in the relevant members of the connection struct. |
| 1639 | */ |
| 1640 | static CURLcode parseurlandfillconn(struct Curl_easy *data, |
| 1641 | struct connectdata *conn) |
| 1642 | { |
| 1643 | CURLcode result = CURLE_OK; |
| 1644 | CURLU *uh; |
| 1645 | CURLUcode uc; |
| 1646 | bool use_set_uh = (data->set.uh && !data->state.this_is_a_follow); |
| 1647 | uint16_t port_override = data->state.allow_port ? data->set.use_port : 0; |
| 1648 | uint32_t scope_id = 0; |
| 1649 | |
| 1650 | up_free(data); /* cleanup previous leftovers first */ |
| 1651 | |
| 1652 | /* parse the URL */ |
| 1653 | if(use_set_uh) |
| 1654 | uh = data->state.uh = curl_url_dup(data->set.uh); |
| 1655 | else |
| 1656 | uh = data->state.uh = curl_url(); |
| 1657 | if(!uh) { |
| 1658 | result = CURLE_OUT_OF_MEMORY; |
| 1659 | goto out; |
| 1660 | } |
| 1661 | |
| 1662 | /* Calculate the *real* URL this transfer uses, applying defaults |
| 1663 | * where information is missing. */ |
| 1664 | if(data->set.str[STRING_DEFAULT_PROTOCOL] && |
| C1: libcurl later applies CURLOPT_DEFAULT_PROTOCOL (from --proto-default) and prepends sftp:// to schemeless URLs | |
| C3: CURLOPT_DEFAULT_PROTOCOL (set by --proto-default) applied later in libcurl | |
| C5: The code path that applies --proto-default and uses SFTP is reachable | |
| 1665 | !Curl_is_absolute_url(Curl_bufref_ptr(&data->state.url), NULL, 0, TRUE)) { |
| 1666 | char *url = curl_maprintf("%s://%s", |
| C4: URL rewritten to sftp://... so actual connection uses SFTP | |
| 1667 | data->set.str[STRING_DEFAULT_PROTOCOL], |
| 1668 | Curl_bufref_ptr(&data->state.url)); |
| 1669 | if(!url) { |
| 1670 | result = CURLE_OUT_OF_MEMORY; |
| 1671 | goto out; |
| 1672 | } |
| 1673 | Curl_bufref_set(&data->state.url, url, 0, curl_free); |
| 1674 | } |
| 1675 | |
| 1676 | if(!use_set_uh) { |
| 1677 | char *newurl; |
| 1678 | uc = curl_url_set(uh, CURLUPART_URL, Curl_bufref_ptr(&data->state.url), |
| 1679 | (unsigned int)(CURLU_GUESS_SCHEME | |
| 1680 | CURLU_NON_SUPPORT_SCHEME | |
| 1681 | (data->set.disallow_username_in_url ? |
| 1682 | CURLU_DISALLOW_USER : 0) | |
| 1683 | (data->set.path_as_is ? CURLU_PATH_AS_IS : 0))); |
| 1684 | if(uc) { |
| 1685 | failf(data, "URL rejected: %s", curl_url_strerror(uc)); |
| 1686 | result = Curl_uc_to_curlcode(uc); |
| 1687 | goto out; |
Show 1 lines (not cited by the judge)
| 96 | #define CURLU_GUESS_SCHEME (1 << 9) /* legacy curl-style guessing */ |