Reports
MEDIA[AUTENTICACIÓN]#3751712

CVE-2026-9547 en curl: el backend libssh trataba una clave de host de otro tipo como un servidor nuevo

Con libssh, curl comunicaba como "host desconocido" el caso en que el servidor presenta una clave de un tipo distinto al registrado en known_hosts, que libssh considera un posible ataque. Las aplicaciones con política TOFU podían aceptarla sin avisar.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

curl puede usar dos bibliotecas para SFTP/SCP: libssh2 y libssh. Este fallo, registrado como CVE-2026-9547, afecta solo al backend de libssh, en lib/vssh/libssh.c, y está en cómo curl traduce el resultado de la comprobación de la clave del servidor a los valores que recibe la aplicación.

libssh distingue varios resultados al comparar la clave del servidor con known_hosts. Uno de ellos es SSH_KNOWN_HOSTS_OTHER, que la propia cabecera de libssh documenta así:

/* The server gave us a key of a type while we had an other type recorded.
   It is a possible attack. */
SSH_KNOWN_HOSTS_OTHER = -2,

Es decir: el host ya está en known_hosts, pero con una clave de otro tipo (por ejemplo, RSA) y el servidor presenta ahora una de un tipo distinto (por ejemplo, Ed25519). Sin embargo, curl lo agrupaba con los casos de "primer contacto" y se lo comunicaba a la aplicación como CURLKHMATCH_MISSING, en lugar de CURLKHMATCH_MISMATCH:

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;

Según el investigador, el fallo viene del commit 507cf6a13d (21-feb-2020, "libssh: improve known hosts handling"), que pasó la verificación a la API ssh_session_is_known_server() y colocó SSH_KNOWN_HOSTS_OTHER en ese grupo en vez de dejarlo caer en la rama por defecto (CURLKHMATCH_MISMATCH). El backend libssh2 no tiene un estado equivalente y no está afectado.

Cuándo devuelve libssh SSH_KNOWN_HOSTS_OTHER

La lógica está en ssh_known_hosts_check_server_key() (src/knownhosts.c de libssh). Simplificada:

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;

Aplicado a casos concretos:

  • known_hosts solo tiene RSA y el servidor presenta la misma clave RSA → SSH_KNOWN_HOSTS_OK.
  • known_hosts solo tiene RSA y el servidor presenta otra clave RSA → SSH_KNOWN_HOSTS_CHANGED.
  • known_hosts solo tiene RSA y el servidor presenta una clave Ed25519 → SSH_KNOWN_HOSTS_OTHER.
  • known_hosts tiene RSA y Ed25519 y el servidor presenta otra Ed25519 → SSH_KNOWN_HOSTS_CHANGED.
  • known_hosts tiene RSA y Ed25519 y el servidor presenta ECDSA → SSH_KNOWN_HOSTS_OTHER (salvo que también haya una entrada ECDSA distinta).

Un matiz: ssh-rsa, rsa-sha2-256 y rsa-sha2-512 son el mismo tipo base (RSA), así que una clave RSA distinta siempre da CHANGED, no OTHER. Y, como subraya el investigador, el problema de curl no depende de lo fácil que sea llegar a este estado: siempre que libssh devuelve SSH_KNOWN_HOSTS_OTHER, curl lo convierte en CURLKHMATCH_MISSING.

Pasos de reproducción

  1. Preparar un sshd que solo sirva una clave de host Ed25519 (o configurado para que la clave negociada sea Ed25519), escuchando en 127.0.0.1:2222.
  2. Crear un fichero known_hosts que, para ese host y puerto, contenga solo una entrada ssh-rsa, sin ninguna entrada Ed25519 en otros ficheros known_hosts de usuario o globales.
  3. Guardar como poc.c un cliente con un callback CURLOPT_SSH_KEYFUNCTION de tipo TOFU (rechaza MISMATCH, acepta y guarda MISSING):
#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;
}
  1. Compilar curl con libssh y enlazar la prueba contra la biblioteca estática:
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
  1. Ejecutar poc pasando como primer argumento la ruta del known_hosts del paso 2. Como libssh clasifica la situación como SSH_KNOWN_HOSTS_OTHER y curl la traduce a CURLKHMATCH_MISSING, el callback la trata como un host nuevo en lugar de como un cambio de clave.

Como prueba a nivel de código, el investigador muestra dónde aparece la constante en ambos lados:

$ 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,

Impacto

Afecta a las aplicaciones que usan curl con el backend libssh y un CURLOPT_SSH_KEYFUNCTION con lógica TOFU (aceptar y guardar los hosts nuevos, rechazar los que cambian de clave). Si un servidor ya conocido presenta una clave de un tipo que no está registrado para ese host, la aplicación recibe CURLKHMATCH_MISSING, la acepta en silencio y, con CURLKHSTAT_FINE_ADD_TO_FILE, la añade a known_hosts. Lo que libssh describe como "posible ataque" llega a la aplicación como un primer contacto normal, de modo que la protección de known_hosts queda debilitada: en palabras del investigador, cualquier conexión puede acabar siendo TOFU.

Remediación

El reporte figura como resuelto en HackerOne. La corrección propuesta por el investigador consiste en sacar SSH_KNOWN_HOSTS_OTHER del grupo de "desconocido" y hacer que devuelva CURLKHMATCH_MISMATCH:

--- 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:

Cronología: enviado el 20-may-2026 y divulgado el 24-jun-2026.

Qué aprender de este caso

  • Cuando una biblioteca traduce los estados de otra (aquí, los resultados de known_hosts de libssh a curl_khmatch), revisa el switch caso a caso: agrupar varios case en una sola rama es donde un estado de "posible ataque" acaba confundido con uno inocuo.
  • En verificación de claves de host, todo estado que no sea un "no conozco este host" claro debería caer en el lado restrictivo (rechazo o aviso), nunca en el permisivo; en caso de duda, que la rama por defecto sea la del rechazo.
  • Si usas un callback TOFU con CURLOPT_SSH_KEYFUNCTION, ten en cuenta que su seguridad depende de que MISSING signifique de verdad "host nunca visto"; comprobar en el fichero si el host ya tiene alguna entrada antes de añadir una nueva cierra este hueco.
  • Al auditar clientes SSH, prueba el escenario "host registrado con RSA, servidor que presenta Ed25519": es el caso límite que diferencia un cambio de tipo de clave de un host realmente nuevo.