Reports
BAJA[OTROS]#4099618

curl reutilizaba conexiones ligadas a otra dirección de origen al usar --interface con prefijos (CVE-2026-107929)

Con CURLOPT_INTERFACE en sus formas con prefijo (if!, host!, ifhost!), libcurl no tenía en cuenta la dirección de origen al reutilizar conexiones, así que una petición podía salir desde la IP pedida por otra anterior.

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 un caché de conexiones abiertas y, antes de abrir una nueva, busca si ya tiene una que sirva para la siguiente transferencia. Para decidirlo compara una serie de parámetros de la conexión: si coinciden, la reutiliza tal cual, sin volver a crear ni a ligar el socket.

El fallo está en cómo entra la opción CURLOPT_INTERFACE (la --interface de la línea de órdenes) en esa comparación. La opción admite tres prefijos documentados, if!, host! e ifhost!, que sirven para indicar explícitamente si el valor es un nombre de interfaz, una dirección/host o ambas cosas. Internamente pasa esto:

  • Curl_parse_interface() y setstropt_interface() reparten el valor en tres cadenas: STRING_DEVICE, STRING_INTERFACE y STRING_BINDHOST (lib/cf-socket.c:588, lib/setopt.c:155-183).
  • bindlocal(), que es quien liga el socket, usa las tres (lib/cf-socket.c:661-663).
  • Pero allocate_conn() solo copia STRING_DEVICE a conn->localdev (lib/url.c:1208-1209), y url_match_connect_config() solo compara ese campo y los puertos locales (lib/url.c:625-628).

Resultado: si se usa una forma con prefijo, el valor acaba en STRING_INTERFACE o STRING_BINDHOST, que no forman parte de la clave de reutilización. Dos transferencias que piden direcciones de origen distintas parecen idénticas para el caché, y la segunda se envía por el socket ya ligado de la primera. La forma sin prefijo no tiene el problema porque cae en STRING_DEVICE, que sí se compara.

El código incluye un comentario (lib/url.c:619-624) que reconoce que la comparación es aproximada a propósito, pero parte de que la reutilización usará "the exact same binding parameters" (exactamente los mismos parámetros de ligado), algo que aquí no se cumple: los parámetros son explícitamente distintos y el error va en la dirección insegura.

Hay un efecto secundario: como al reutilizar no se vuelve a ligar nada, un if! con una interfaz inexistente tampoco devuelve el error CURLE_INTERFACE_FAILED que promete la documentación de CURLOPT_INTERFACE(3).

Versiones afectadas según el investigador: de la 8.9.0 a la 8.22.0 y la rama master de ese momento. Lo reprodujo en la 8.22.0 publicada y en master efd42dfbbf656870f8885815d07b0d16a5771f69. En master 424aa994ac223c0f90839aadee0fb59595344fce solo leyó el código: allocate_conn() se había movido a url.c:1147, pero seguía copiando únicamente STRING_DEVICE. El límite inferior lo deduce leyendo el commit 3060557af702dd59155ea703a0790455c2f49a59, que dividió la opción en tres cadenas sin añadir las dos nuevas a la clave; la 8.9.0 no la llegó a probar.

curl 8.22.0 (x86_64-pc-linux-gnu) libcurl/8.22.0 OpenSSL/3.0.13 zlib/1.3 zstd/1.5.5
Release-Date: 2026-09-02

Entorno: Linux 6.8.0 x86_64, clang 18.1.3, compilado desde el tarball firmado de la 8.22.0 con --enable-debug (el código implicado no depende de ese modo).

Pasos de reproducción

Hace falta un servidor HTTP/1.1 con keep-alive; si no, no hay conexión que reutilizar y el fallo no se ve. Este devuelve la IP desde la que le llega la petición:

import http.server, socketserver
class H(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    def do_GET(self):
        b = ("peer=%s\n" % self.client_address[0]).encode()
        self.send_response(200)
        self.send_header("Content-Length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)
socketserver.ThreadingTCPServer(("127.0.0.1", 18232), H).serve_forever()
  1. Con el servidor en marcha, lanzar dos transferencias encadenadas con --next, la primera ligada a 127.0.0.2 y la segunda a 127.0.0.3, ambas con el prefijo host!:
curl -s -o /dev/null -w "local=%{local_ip} connects=%{num_connects}\n" --interface "host!127.0.0.2" http://127.0.0.1:18232/ --next -o /dev/null -w "local=%{local_ip} connects=%{num_connects}\n" --interface "host!127.0.0.3" http://127.0.0.1:18232/

La segunda no abre conexión nueva y sale desde la .2, aunque pidió la .3:

local=127.0.0.2 connects=1
local=127.0.0.2 connects=0     <- this one asked for 127.0.0.3
  1. Como control, repetir lo mismo sin prefijo (--interface "127.0.0.2" y después --interface "127.0.0.3"). Ahora sí se abre una segunda conexión con el origen correcto:
local=127.0.0.2 connects=1
local=127.0.0.3 connects=1
  1. Repetir con el mismo valor con prefijo en ambas (--interface "host!127.0.0.2" dos veces). La conexión se reutiliza, lo que demuestra que meter el valor en la clave no quitaría ninguna reutilización legítima:
local=127.0.0.2 connects=1
local=127.0.0.2 connects=0
  1. Para el error que se salta: poner en la segunda transferencia --interface "if!no-such-if-here". Reutiliza la conexión caliente y curl termina con código 0. La misma opción en una ejecución sola, sin conexión previa, termina correctamente con el código 45.

Impacto

El caso peligroso es una aplicación que asigna direcciones de origen distintas a usuarios o clientes distintos (por ejemplo, una IP de salida por inquilino) y comparte el caché de conexiones de libcurl, o que habla con un servidor que autoriza según la IP de origen. Una petición configurada para la identidad B sale por el socket ya ligado de la identidad A y el destino la trata como si fuera de A.

El investigador lo probó con un servidor local que solo aceptaba la petición protegida desde 127.0.0.2 y respondía 403 al resto: la transferencia configurada con host!127.0.0.3 reutilizó el socket de la .2 y obtuvo la respuesta correcta en 3 de 3 intentos. Con su parche, abrió conexión desde la .3 y recibió 403 en 3 de 3.

Sin una frontera de ese tipo, el efecto se reduce a que la opción se ignora en silencio al reutilizar y el error documentado de if! nunca aparece. HackerOne lo recoge con severidad baja; el investigador lo veía como media según los criterios de curl, aunque aceptaba la baja por lo poco que se usan las formas con prefijo.

Remediación

El investigador ofreció un parche mínimo sobre efd42dfb: reconstruir en el campo existente conn->localdev el valor completo que tuvo éxito, prefijo incluido, de modo que la comparación y la limpieza siguen igual y no hace falta añadir punteros nuevos a la conexión. Lo acompañaba una prueba que espera connects=1/connects=1/connects=0 y que en la 8.22.0 sin parchear falla con 1/0/0. El reporte consta como resuelto y con el identificador CVE-2026-107929.

Qué aprender de este caso

  • Cuando una opción se parsea y se reparte en varios campos internos, comprueba que todos los que usa el código de ligado o conexión aparezcan también en la función que decide la reutilización (aquí, bindlocal() frente a url_match_connect_config()).
  • Las variantes poco usadas de una opción (prefijos como if! o host!) son buen sitio para buscar: compara su comportamiento con la forma básica, como hizo el control del paso 2.
  • En cualquier pool de conexiones compartido, todo parámetro que cambie la identidad de la conexión (IP de origen, certificado de cliente, proxy) tiene que formar parte de la clave; ante la duda, es preferible abrir una conexión de más que reutilizar una equivocada.
  • Si un servicio autoriza por IP de origen y los clientes comparten caché de conexiones, separa los cachés por identidad en lugar de fiarte de que la librería distinga los ligados.