Resumen
El fallo es una lectura fuera de límites (buffer over-read) en la función gtls_send() de curl, la que envía datos por TLS cuando curl está compilado con GnuTLS. Con OpenSSL no se reproduce. Versión afectada: curl 8.22.0. Tiene asignado el CVE-2026-107928 y el investigador lo envió como severidad baja.
La raíz del problema está en una suposición: gtls_send() da por hecho que gnutls_record_send() nunca devuelve más bytes de los que se le han pasado. Pero GnuTLS sí lo hace en un caso concreto: cuando un envío anterior quedó bloqueado, GnuTLS guarda ese registro ya cifrado (16384 bytes) y, en la siguiente llamada, lo envía y devuelve su longitud, sin mirar qué buffer ni qué longitud recibe en esa llamada.
Normalmente la siguiente llamada lleva los mismos bytes y no pasa nada. El problema aparece cuando, entre medias, el servidor manda un PING de WebSocket:
- La subida (
curl -T fichero wss://...) se bloquea porque el servidor deja de leer y el socket se llena. GnuTLS se queda con el tercer registro cifrado y devuelveGNUTLS_E_AGAIN; esos bytes siguen en el buffer de la petición (req.sendbuf) para reintentarlos. - El servidor envía un PING. curl prepara un PONG y
ws_flush()lo envía directamente desde el buffer de envío WebSocket (ws->sendbuf), antes que el buffer de la petición. - La llamada queda como
gnutls_record_send(PONG, 1304)(los PONG acumulados mientras el socket estaba lleno) y devuelve16384. - En
gtls_send()la única comprobación es unDEBUGASSERT(nwritten <= remain), que no existe en compilaciones de producción. El código suma 16384 al puntero y se lo resta a la longitud restante, que da la vuelta (pasa a0xffffffffffffc518) y el bucle continúa. - A partir de ahí
gnutls_record_send()cifra y envía al servidor bloques de 16384 bytes de la memoria que hay después del PONG, hasta que el socket vuelve a llenarse.
Esquema simplificado del flujo en la fase del PING, según el apéndice del investigador:
gtls_send(buf=out, blen=1304) remain = 1304
├─ gnutls_record_send(out, 1304) = 16384 <- más que remain
├─ DEBUGASSERT(nwritten <= remain) (no está en release)
├─ buf = out + 16384 <- fuera de ws->sendbuf
├─ remain = 1304 - 16384 <- 0xffffffffffffc518
├─ gnutls_record_send(out+16384, remain) = 16384 <- LECTURA FUERA DE LÍMITES
├─ ...
├─ gnutls_record_send(out+114688, remain) = GNUTLS_E_AGAIN
└─ return CURLE_OK, *pnwritten = 114688
Lo mismo afecta a aplicaciones que usan libcurl: si tras un CURLE_AGAIN llaman a curl_easy_send() con un buffer más corto, puede ocurrir igual. Por ejemplo, curl_easy_send(buf, 5, &n) puede devolver CURLE_OK con n valiendo 131072.
Pasos de reproducción
El investigador adjuntó un servidor malicioso (evil-server.py) y un contenedor Docker para reproducirlo. Con el servidor en el mismo directorio, los pasos son:
- Descargar curl 8.22.0, comprobar el hash y descomprimirlo:
wget 'https://curl.se/download/curl-8.22.0.tar.gz'
sha1sum curl-8.22.0.tar.gz # should be 284e444b67f676a281906cfc86e9672780555cd4
tar xf curl-8.22.0.tar.gz
mkdir build ; cd build
- Compilarlo con GnuTLS en lugar de OpenSSL y con WebSockets activados:
CC=clang CFLAGS="-O2 -g" LDFLAGS="-Wl,-rpath,/opt/curl-gnutls/lib" \
../curl-8.22.0/configure --prefix=/opt/curl-gnutls \
--enable-static --enable-shared --disable-docs --disable-manual \
--with-gnutls --without-openssl --with-zlib --with-brotli --with-zstd --with-libidn2 \
--with-nghttp2 --with-libpsl --with-libssh2 --with-gssapi \
--enable-ldap --enable-ldaps --enable-websockets --enable-mqtt --enable-ipfs \
--enable-ntlm --enable-smb --enable-httpsig --enable-httpsrr --enable-ssls-export \
&& make -j"$(nproc)"
- Crear un fichero de 32 MB con un único byte repetido, para que cualquier otra cosa que reciba el servidor salte a la vista:
head -c 33554432 /dev/zero | tr '\0' A > As.bin
- Arrancar el servidor malicioso:
python3 evil-server.py & sleep 1
- Subir el fichero por
wss://.--insecure(el certificado es autofirmado) y-sS(para que el medidor de progreso no ensucie la salida) no son necesarios para que se produzca el fallo:
./src/curl --insecure -sS -T As.bin wss://127.0.0.1:19239/
El servidor deja de leer, lee luego una cantidad concreta y envía un PING. A partir de ese punto el flujo deja de ser tramas WebSocket con 0x41 y llega memoria de curl:
[srv] upgraded to WebSocket, not reading, so that curl's send blocks
[srv] reading 131072 bytes, then sending PING number 151
[srv] PING number 151: 2359296 bytes into the upload the stream stops being WebSocket frames of 0x41
[srv] LEAK: at least 65536 bytes of curl's memory arrived instead
[srv] it starts with old data from curl's WebSocket send buffer; 49152 bytes further on comes this:
+00c000 00 00 00 00 00 00 00 31 00 01 00 00 00 00 00 00 .......1........
+00c010 00 00 00 00 00 00 00 ff ff 00 00 00 00 00 00 00 ................
+00c020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 89 ................
+00c030 04 70 69 6e 67 00 00 00 00 00 00 00 00 00 00 00 .ping...........
...
curl: (56) Recv failure: Connection reset by peer
En el volcado, el bloque de 64 KB del buffer de envío termina en +00c000; después se ve la cabecera de malloc filtrada y la struct buf_chunk de la siguiente reserva, que contiene el propio PING que mandó el servidor. Con -v --trace-config ssl, curl registra el envío como gtls_send(len=1304) -> 0, 114688.
Impacto
Un servidor wss:// malicioso puede obtener memoria del proceso cliente mientras se le sube un fichero, siempre que curl o libcurl estén compilados con GnuTLS (el investigador apunta que podría ser lo habitual en Debian y Ubuntu). El servidor controla cuánta memoria recibe ajustando cuánto lee antes de enviar el PING. Si la lectura llega al final del heap, curl termina con un fallo de segmentación.
Las condiciones limitan el alcance, y por eso se envió como severidad baja: hace falta GnuTLS, una subida de al menos unos pocos MB y que el destino sea un servidor wss:// controlado por el atacante.
Qué aprender de este caso
- Si una función envuelve una API de envío de terceros (
gnutls_record_send(),SSL_write()…), revisa qué hace cuando el valor devuelto es mayor que la longitud pedida: aquí, avanzar el puntero y restar a la longitud restante sin comprobarlo convirtió 16384 bytes de más en una longitud gigantesca al dar la vuelta. - Un
DEBUGASSERToassertno es una comprobación de seguridad: desaparece en producción. Las invariantes de las que depende la memoria (comonwritten <= remain) deben comprobarse siempre y devolver error si no se cumplen. - Las APIs con reintento tras
EAGAINsuelen exigir que se vuelvan a pasar exactamente los mismos bytes. Busca rutas que intercalen otro envío entre medias, como un PONG automático o mensajes de control que se cuelan por delante del buffer pendiente. - Para cazar este tipo de fallos, el truco del PoC es útil: subir un fichero con un único byte repetido hace que cualquier dato ajeno en el tráfico salte a la vista.