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í:
- Con
"victim:secret", el handle guarda usuariovictimy contraseñasecret. - Con
"attacker"(sin contraseña), cambia el usuario pero la contraseña sigue siendosecret: el estado pasa a serattacker:secret. - Con
NULL, no se borra nada: sigueattacker:secret. - Cuando una transferencia posterior necesita autenticarse ante un proxy, libcurl genera la cabecera
Proxy-Authorizationcon 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).
- Construir la imagen (instala
curl-devy compila el programa de prueba):
cd poc
docker build -t curl-proxyuserpwd-stale-poc .
- Ejecutarla. Arranca el proxy de captura y lanza el binario ya compilado:
docker run --rm curl-proxyuserpwd-stale-poc
- 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:
- Una tarea de confianza A sale por el proxy corporativo con
CURLOPT_PROXYUSERPWD = "victim:secret". - Una tarea B, menos fiable, reutiliza el mismo handle y puede elegir dirección o credenciales del proxy.
- B pone solo un usuario, o pone
NULLpara quitar las credenciales. - libcurl manda
Proxy-Authorization: Basicconattacker:secretal proxy de B. - 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
NULLpara 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.