curl · Report #3751712
CVE-2026-9547: SSH improper host validation
How it was found
Hi all,
We've found an issue in lib/vssh/libssh.c where the libssh backend maps SSH_KNOWN_HOSTS_OTHER to CURLKHMATCH_MISSING instead of CURLKHMATCH_MISMATCH.
libssh documents SSH_KNOWN_HOSTS_OTHER as "The server gave us a key of a type while we had another type recorded. It is a possible attack." (/usr/include/libssh/libssh.h:254)
curl's switch at lib/vssh/libssh.c:275-288 groups it with the "unknown / first contact" cases:
case SSH_KNOWN_HOSTS_OK:
keymatch = CURLKHMATCH_OK; break;
case SSH_KNOWN_HOSTS_OTHER: /* downgraded to MISSING */
case SSH_KNOWN_HOSTS_NOT_FOUND:
case SSH_KNOWN_HOSTS_UNKNOWN:
case SSH_KNOWN_HOSTS_ERROR:
keymatch = CURLKHMATCH_MISSING; break;
default: /* SSH_KNOWN_HOSTS_CHANGED */
keymatch = CURLKHMATCH_MISMATCH; break;
Applications using a CURLOPT_SSH_KEYFUNCTION that follows a TOFU pattern (MISSING -> CURLKHSTAT_FINE_ADD_TO_FILE; MISMATCH -> CURLKHSTAT_REJECT) will silently accept a server that presents a host key whose base key type has no matching entry for that host, even though another key type is already recorded in known_hosts. libssh classifies that condition as SSH_KNOWN_HOSTS_OTHER and describes it as a possible attack, but curl reports it to the application as an "unknown new host" event.
The libssh2 backend has no equivalent state and is unaffected.
This dates to commit 507cf6a13d (2020-02-21, "libssh: improve known hosts handling"), which migrated host-key verification to the newer ssh_session_is_known_server() API and grouped SSH_KNOWN_HOSTS_OTHER with the "unknown / first contact" cases in the switch, instead of letting it fall through to the default CURLKHMATCH_MISMATCH arm.
How SSH_KNOWN_HOSTS_OTHER is reached in libssh
In libssh, SSH_KNOWN_HOSTS_OTHER is returned when the host is present in known_hosts, but the server key negotiated for the current session has a different base key type from the recorded key or keys, and no same-type mismatch was found.
The relevant libssh logic is in ssh_known_hosts_check_server_key() in src/knownhosts.c:
Reference:
https://git.libssh.org/projects/libssh.git/tree/src/knownhosts.c
The matching logic is effectively:
found = SSH_KNOWN_HOSTS_UNKNOWN;
for each known_hosts entry matching this host:
if(server_key == entry_key)
return SSH_KNOWN_HOSTS_OK;
if(ssh_key_type(server_key) == ssh_key_type(entry_key)) {
found = SSH_KNOWN_HOSTS_CHANGED;
continue;
}
if(found != SSH_KNOWN_HOSTS_CHANGED)
found = SSH_KNOWN_HOSTS_OTHER;
return found;
This means:
- known_hosts has only an RSA entry for the host, and the server presents the same RSA key:
SSH_KNOWN_HOSTS_OK - known_hosts has only an RSA entry for the host, and the server presents a different RSA key:
SSH_KNOWN_HOSTS_CHANGED - known_hosts has only an RSA entry for the host, and the server presents an Ed25519 key:
SSH_KNOWN_HOSTS_OTHER - known_hosts has RSA and Ed25519 entries for the host, and the server presents a different Ed25519 key:
SSH_KNOWN_HOSTS_CHANGED - known_hosts has RSA and Ed25519 entries for the host, and the server presents an ECDSA key:
SSH_KNOWN_HOSTS_OTHER, unless a same-type ECDSA mismatch is also present
So SSH_KNOWN_HOSTS_CHANGED wins if any matching-host entry has the same base key type as the server key but different key material. SSH_KNOWN_HOSTS_OTHER is returned when the host is known, but only under other key type or types.
One subtlety is that RSA signature algorithms are not distinct base key types for this logic. For example, ssh-rsa, rsa-sha2-256, and rsa-sha2-512 are all RSA keys. A different RSA key is therefore classified as SSH_KNOWN_HOSTS_CHANGED, not SSH_KNOWN_HOSTS_OTHER.
The curl-side issue is independent of how often a given test setup reaches this state. Any time libssh returns the documented SSH_KNOWN_HOSTS_OTHER state, curl maps it to CURLKHMATCH_MISSING.
Reproducer
Triggering OTHER reliably depends on libssh's classification of type-mismatch vs key-data mismatch and on the host-key algorithm negotiated for the session. In libssh, OTHER requires a host entry to exist in known_hosts, but only with different base key type or types from the server key selected for the current connection. If a same-type entry exists and differs, libssh returns CHANGED instead.
Setup:
- sshd serving Ed25519 host key only, or otherwise configured so the negotiated server host key is Ed25519
- known_hosts containing only an
ssh-rsaentry for the same host:port - no additional user or global known_hosts entry for that host with an Ed25519 key
Save as poc.c:
#include <stdio.h>
#include <curl/curl.h>
static int keyfunc(CURL *e, const struct curl_khkey *k,
const struct curl_khkey *f,
enum curl_khmatch m, void *p) {
(void)e; (void)k; (void)f; (void)p;
switch(m) {
case CURLKHMATCH_OK: fprintf(stderr, "OK\n"); return CURLKHSTAT_FINE;
case CURLKHMATCH_MISMATCH: fprintf(stderr, "MISMATCH\n"); return CURLKHSTAT_REJECT;
case CURLKHMATCH_MISSING: fprintf(stderr, "MISSING\n"); return CURLKHSTAT_FINE_ADD_TO_FILE;
default: fprintf(stderr, "?\n"); return CURLKHSTAT_REJECT;
}
}
int main(int argc, char **argv) {
curl_global_init(CURL_GLOBAL_DEFAULT);
CURL *e = curl_easy_init();
curl_easy_setopt(e, CURLOPT_URL, "sftp://127.0.0.1:2222/x");
curl_easy_setopt(e, CURLOPT_USERPWD, "u:p");
curl_easy_setopt(e, CURLOPT_SSH_KNOWNHOSTS, argv[1]);
curl_easy_setopt(e, CURLOPT_SSH_KEYFUNCTION, keyfunc);
curl_easy_setopt(e, CURLOPT_VERBOSE, 1L);
CURLcode rc = curl_easy_perform(e);
fprintf(stderr, "rc=%d (%s)\n", rc, curl_easy_strerror(rc));
curl_easy_cleanup(e);
curl_global_cleanup();
return 0;
}
Build curl with libssh:
autoreconf -fi
mkdir build && cd build
CFLAGS='-O1 -g' CC=clang \
../configure --with-openssl --with-libssh --enable-debug --disable-shared
make -j
clang -g -I../include poc.c ./lib/.libs/libcurl.a \
-lssl -lcrypto -lz -lnghttp2 -lidn2 -lzstd -lbrotlidec -lldap -llber -lssh \
-o poc
Code-level evidence
$ grep -n SSH_KNOWN_HOSTS_OTHER lib/vssh/libssh.c
279: case SSH_KNOWN_HOSTS_OTHER:
280: case SSH_KNOWN_HOSTS_NOT_FOUND:
$ grep -n SSH_KNOWN_HOSTS_OTHER /usr/include/libssh/libssh.h
254: /* The server gave us a key of a type while we had an other type recorded.
255: It is a possible attack. */
256: SSH_KNOWN_HOSTS_OTHER = -2,
In libssh src/knownhosts.c, the internal matching logic sets SSH_KNOWN_HOSTS_OTHER when a matching host entry exists but its key type differs from the server key type, unless a same-type mismatch has already set SSH_KNOWN_HOSTS_CHANGED.
Reference:
https://git.libssh.org/projects/libssh.git/tree/src/knownhosts.c
Suggested fix
Move SSH_KNOWN_HOSTS_OTHER out of the MISSING arm:
--- a/lib/vssh/libssh.c
+++ b/lib/vssh/libssh.c
@@ -276,7 +276,9 @@
case SSH_KNOWN_HOSTS_OK:
keymatch = CURLKHMATCH_OK;
break;
- case SSH_KNOWN_HOSTS_OTHER:
+ case SSH_KNOWN_HOSTS_OTHER:
+ keymatch = CURLKHMATCH_MISMATCH;
+ break;
case SSH_KNOWN_HOSTS_NOT_FOUND:
case SSH_KNOWN_HOSTS_UNKNOWN:
case SSH_KNOWN_HOSTS_ERROR:
Cheers, AISLE Research Team
Impact
Every connection may be TOFU