Resumen
Este reporte es una variante de un fallo anterior de curl, CVE-2026-7168, cuya corrección resultó ser incompleta. El problema original: si una aplicación reutilizaba el mismo easy handle de libcurl y cambiaba de proxy entre una transferencia y otra, el estado de autenticación Digest negociado con el primer proxy se enviaba al segundo.
En libcurl 8.20.0 ese caso ya estaba arreglado cuando el proxy se cambia con CURLOPT_PROXY: el estado de autenticación del proxy anterior se limpia. Pero el investigador comprobó que la limpieza no ocurre cuando el proxy efectivo cambia porque cambian las variables de entorno (http_proxy o ALL_PROXY). En ese camino, el handle conserva el estado Digest del proxy A y lo usa para construir la cabecera Proxy-Authorization que manda al proxy B.
La petición que recibía el proxy B tenía este aspecto:
GET http://example.test/protected HTTP/1.1
Host: example.test
Proxy-Authorization: Digest username="silly", realm="realmA", nonce="nonceA", uri="/protected", ...
Tanto realmA como nonceA los había emitido el proxy A, no el B. El fallo recibió el identificador CVE-2026-8927; HackerOne lo clasifica como autenticación incorrecta y con severidad media. El reporte se envió el 18 de mayo de 2026 y se divulgó el 24 de junio de 2026.
Pasos de reproducción
El investigador lo probó contra la versión oficial de curl/libcurl 8.20.0 (cabeceras y biblioteca en tiempo de ejecución de la misma versión). La prueba es totalmente local:
- Compilar o instalar curl/libcurl 8.20.0.
- Levantar dos proxies HTTP en
127.0.0.1en puertos aleatorios:
- proxy A: responde a la primera petición con un reto Digest y a la segunda (ya autenticada) con
200 OK. - proxy B: simplemente guarda la primera petición que recibe y responde
200 OK.
El reto que envía el proxy A es:
HTTP/1.1 407 Proxy Authentication Required
Proxy-Authenticate: Digest realm="realmA", nonce="nonceA", qop="auth", algorithm=MD5
Content-Length: 0
Connection: keep-alive
- Compilar un cliente en C que use un único easy handle con
CURLOPT_PROXYAUTHenCURLAUTH_DIGESTy sinCURLOPT_PROXY, de modo que el proxy salga dehttp_proxy:
CURL *h = curl_easy_init();
curl_easy_setopt(h, CURLOPT_URL, "http://example.test/protected");
curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, sink);
curl_easy_setopt(h, CURLOPT_PROXYAUTH, CURLAUTH_DIGEST);
curl_easy_setopt(h, CURLOPT_VERBOSE, 1L);
char envA[256];
char envB[256];
snprintf(envA, sizeof(envA), "http://silly:[email protected]:%s", pa);
snprintf(envB, sizeof(envB), "http://silly:[email protected]:%s", pb);
/* Primera transferencia: proxy tomado de http_proxy (proxy A) */
setenv("http_proxy", envA, 1);
CURLcode r1 = curl_easy_perform(h);
/* Segunda transferencia: mismo handle, http_proxy apunta ahora al proxy B */
setenv("http_proxy", envB, 1);
CURLcode r2 = curl_easy_perform(h);
curl_easy_cleanup(h);
- Ejecutar el cliente con un entorno limpio (sin
http_proxy,HTTP_PROXY,all_proxy,ALL_PROXY,no_proxyniNO_PROXYheredados) pasándole los dos puertos. - En la primera transferencia, libcurl recibe el reto del proxy A y se autentica con Digest.
- En la segunda, el mismo handle va al proxy B. Revisar la petición capturada por B: contiene
Proxy-Authorization: Digestconrealm="realmA"ynonce="nonceA". - Para confirmar el impacto, el investigador reenvió al proxy A la cabecera capturada por B, y A la aceptó.
Resultado resumido de la prueba completa:
{
"runtime": "8.20.0",
"headers": "8.20.0",
"B_received_proxy_authorization": true,
"B_digest_valid_for_proxyA": true,
"replay_status": "HTTP/1.1 200 OK"
}
Casos de control
Para descartar falsos positivos, el investigador comparó varias situaciones:
| Caso | Resultado | |---|---| | Mismo handle, http_proxy cambia de A a B | Vulnerable: se envía el Digest antiguo | | Mismo handle, ALL_PROXY cambia de A a B | Vulnerable: se envía el Digest antiguo | | CURLOPT_PROXY cambia de A a B | No vulnerable | | Handle nuevo para la segunda petición | No vulnerable | | curl_easy_reset() antes de la segunda petición | No vulnerable | | CURLOPT_PROXY = NULL antes de la segunda petición | No vulnerable | | El proxy B no lleva credenciales | No vulnerable en su prueba | | El proxy B lleva credenciales (o solo usuario) | Vulnerable |
Impacto
Un proxy B malicioso recibe una respuesta Digest calculada a partir del reto del proxy A. En la prueba esa respuesta seguía siendo válida para A y se pudo reutilizar con éxito, de modo que el operador de B podía capturar material de autenticación pensado para A y reutilizarlo ante ese proxy en nombre de la víctima. Es la misma clase de problema que CVE-2026-7168: el estado de autenticación de un proxy cruza la frontera y queda expuesto a otro.
Afecta a aplicaciones que reutilizan el mismo easy handle y en las que el proxy efectivo cambia entre transferencias a través de las variables de entorno, con autenticación Digest hacia el proxy.
Remediación
El reporte consta como resuelto y se le asignó CVE-2026-8927; el original no detalla el parche aplicado. La propuesta del investigador era aplicar la misma limpieza que ya se hacía con CURLOPT_PROXY cuando el proxy resuelto cambia entre transferencias por las variables de entorno, reiniciando al menos este estado (o su equivalente) antes de generar una nueva cabecera Proxy-Authorization:
data->state.proxydigest
data->state.authproxy
En versiones sin corregir, los propios casos de control muestran cómo evitarlo desde la aplicación: usar un handle nuevo, llamar a curl_easy_reset() o poner CURLOPT_PROXY a NULL antes de la siguiente transferencia.
Qué aprender de este caso
- Cuando se corrige un fallo ligado a un "cambio de destino", hay que localizar todos los caminos por los que ese destino puede cambiar: aquí el arreglo cubría
CURLOPT_PROXY, pero no el proxy resuelto desdehttp_proxyoALL_PROXY. Revisar el fix de un CVE buscando entradas alternativas es una fuente clásica de variantes. - El estado de autenticación (nonce, realm, contadores Digest) debe ir atado a la identidad del servidor que lo emitió y compararse con el destino efectivo justo antes de enviarlo, no con el valor de una opción concreta.
- En aplicaciones que reutilizan handles o clientes HTTP con proxies variables, un
curl_easy_reset()o un handle nuevo al cambiar de proxy elimina toda esta clase de arrastre de estado. - Para probar este tipo de fugas bastan dos servidores locales —uno que emite el reto y otro que solo registra lo que le llega—, como en este PoC; es una técnica reutilizable para cualquier cliente con autenticación de proxy.