Reports
MEDIA[EXPOSICIÓN DE DATOS]#3750295

libcurl conservaba la contraseña del proxy al cambiar o borrar CURLOPT_PROXYUSERPWD y la enviaba a otro proxy

Desde curl 8.8.0, volver a fijar CURLOPT_PROXYUSERPWD solo con usuario, o a NULL, no borraba la contraseña anterior. Un handle reutilizado podía mandar esa contraseña vieja a un proxy distinto.

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

En libcurl, la opción CURLOPT_PROXYUSERPWD recibe una cadena usuario:contraseña para autenticarse ante un proxy HTTP. Según la documentación, si se fija varias veces gana la última, y si se fija a NULL deja de usarse. El fallo, registrado como CVE-2026-9079 (severidad media en HackerOne), es que el código no cumplía ninguna de las dos cosas del todo: la contraseña antigua se quedaba guardada dentro del easy handle.

El origen está en lib/setopt.c. El commit d5e83eb745762f48d8fafadc5df5dd3ae8d8941e (url: do not URL decode proxy credentials), incluido en curl 8.8.0, cambió la forma de procesar la opción: primero separa la cadena en dos variables temporales, u y p, y después solo sustituye los campos internos que tengan valor:

case CURLOPT_PROXYUSERPWD: {
  char *u = NULL;
  char *p = NULL;
  result = setstropt_userpwd(ptr, &u, &p);

  if(!result && u) {
    curlx_safefree(s->str[STRING_PROXYUSERNAME]);
    result = Curl_urldecode(u, 0, &s->str[STRING_PROXYUSERNAME], NULL,
                            REJECT_ZERO);
  }
  if(!result && p) {
    curlx_safefree(s->str[STRING_PROXYPASSWORD]);
    result = Curl_urldecode(p, 0, &s->str[STRING_PROXYPASSWORD], NULL,
                            REJECT_ZERO);
  }

Si la nueva cadena no trae contraseña, p es nulo y STRING_PROXYPASSWORD no se toca. Si se pasa NULL, ni u ni p tienen valor y no se libera nada. Hasta 8.7.1 la opción escribía directamente en los campos internos con setstropt_userpwd() y los limpiaba, así que esas versiones no están afectadas.

La secuencia queda así:

  1. Con "victim:secret", el handle guarda usuario victim y contraseña secret.
  2. Con "attacker" (sin contraseña), cambia el usuario pero la contraseña sigue siendo secret: el estado pasa a ser attacker:secret.
  3. Con NULL, no se borra nada: sigue attacker:secret.
  4. Cuando una transferencia posterior necesita autenticarse ante un proxy, libcurl genera la cabecera Proxy-Authorization con esos datos residuales.

Versiones afectadas confirmadas: de la 8.8.0 a la 8.20.0, además de la 8.21.0-DEV en el commit b2476a07128fc1e83a0b322fe6eb9dfa761db53d. Se verificó también con el paquete libcurl 8.14.1-r2 que trae Alpine 3.20. El investigador propuso CWE-226 (información sensible no eliminada antes de reutilizar un recurso), con CWE-200 como alternativa.

Pasos de reproducción

La prueba de concepto usa el libcurl del sistema de Alpine 3.20, sin compilar curl. Consta de un Dockerfile, un programa en C (poc-proxyuserpwd-stale.c) que llama a la API pública de libcurl con las tres configuraciones de arriba sobre el mismo handle, un proxy local en Python (proxy-capture-server.py) que captura y decodifica Proxy-Authorization, y un script de ejecución (run-built-poc.sh).

  1. Construir la imagen (instala curl-dev y compila el programa de prueba):
cd poc
docker build -t curl-proxyuserpwd-stale-poc .
  1. Ejecutarla. Arranca el proxy de captura y lanza el binario ya compilado:
docker run --rm curl-proxyuserpwd-stale-poc
  1. Revisar lo que recibió el proxy. En una versión vulnerable sale esto:
[poc] transfer return codes:
SUMMARY initial=0 user_only=0 null_clear=0
[poc] proxy capture:
GET http://example.invalid/ auth=Basic dmljdGltOnNlY3JldA== decoded=victim:secret
GET http://example.invalid/ auth=Basic YXR0YWNrZXI6c2VjcmV0 decoded=attacker:secret
GET http://example.invalid/ auth=Basic YXR0YWNrZXI6c2VjcmV0 decoded=attacker:secret
[poc] VULNERABLE: stale proxy password was sent after replacement/clear

La primera petición es correcta. La segunda no debería llevar secret, porque la aplicación solo había puesto el usuario attacker. La tercera no debería llevar ninguna cabecera Proxy-Authorization, porque la opción se había puesto a NULL. En una versión corregida, la segunda petición va sin la contraseña antigua y la tercera, sin credenciales.

Impacto

El riesgo afecta a las aplicaciones que usan libcurl como biblioteca, reutilizan los easy handles (normalmente por rendimiento) y dejan que tareas con distinto nivel de confianza configuren el proxy: pools de proxies, servicios de descarga, crawlers, sistemas de automatización o de CI. No afecta a todos los usos puntuales de la herramienta curl en línea de comandos.

El escenario que plantea el investigador:

  1. Una tarea de confianza A sale por el proxy corporativo con CURLOPT_PROXYUSERPWD = "victim:secret".
  2. Una tarea B, menos fiable, reutiliza el mismo handle y puede elegir dirección o credenciales del proxy.
  3. B pone solo un usuario, o pone NULL para quitar las credenciales.
  4. libcurl manda Proxy-Authorization: Basic con attacker:secret al proxy de B.
  5. Si ese proxy lo controla o lo observa el atacante, obtiene la contraseña de A.

Con autenticación Basic basta con decodificar el Base64 de la cabecera para tener usuario:contraseña en claro. Con esquemas de desafío-respuesta, la contraseña residual sigue entrando en el cálculo de la respuesta, de modo que el estado de autenticación se filtra igualmente entre proxies o tareas. El investigador sugirió tratarlo como alto (CVSS v4.0 de 8,2, CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N), pero en HackerOne consta con severidad media.

Remediación

El reporte figura como resuelto y tiene asignado el CVE-2026-9079, pero no detalla el parche ni la versión que lo corrige. El comportamiento esperado lo dice la propia documentación: una nueva asignación de CURLOPT_PROXYUSERPWD sustituye por completo a la anterior, también la contraseña, y NULL borra usuario y contraseña.

Qué aprender de este caso

  • Cuando un setter separa una cadena compuesta (usuario:contraseña) y actualiza cada campo solo "si existe", revisa qué ocurre con el campo ausente: si no se borra explícitamente, hereda el valor anterior.
  • El caso de pasar NULL para limpiar suele ser el menos probado. En cualquier API con opciones que se pueden borrar, merece la pena comprobar que el borrado afecta a todos los campos que se derivan de esa opción.
  • Una refactorización que no cambia la intención (aquí, dejar de decodificar las credenciales) puede cambiar la semántica de limpieza. Al revisar ese tipo de commits, compara el estado interno antes y después en cada caso: valor completo, parcial y nulo.
  • Si tu aplicación reutiliza handles o clientes HTTP entre tareas de distintos usuarios, no confíes en sobrescribir opciones una a una: reinicia el objeto o crea uno nuevo cuando cambie el contexto de confianza, sobre todo con credenciales de proxy.