Resumen
La combinación de opciones -O -J (--remote-name + --remote-header-name) le dice a curl que guarde la descarga con el nombre que indique el servidor en la cabecera Content-Disposition. La documentación (docs/cmdline-opts/remote-header-name.md) promete que, si ese nombre ya existe en el directorio de destino, el fichero no se sobrescribe y curl termina con error.
Esa promesa se rompía cuando la respuesta incluía dos cabeceras Content-Disposition: una primera sin un filename= utilizable (por ejemplo, inline) y una segunda que sí nombra un fichero existente. El resultado: curl vaciaba ese fichero a 0 bytes y, a continuación, salía con el código 23 diciendo que se había negado a sobrescribirlo. El usuario perdía el contenido sin que el mensaje de error lo dejara claro.
El fallo afecta solo a la herramienta de línea de órdenes y se comprobó en curl 8.23.0, 8.22.0 y en la rama principal (commit 70f3f855c6), en macOS con OpenSSL 3.6.5. Se le asignó el CVE-2026-111951 y la debilidad registrada es External Control of File Name or Path (CWE-73).
Dónde estaba el error
En src/tool_cb_hdr.c (líneas 405 a 408 en curl-8_23_0), la función content_disposition() trata el caso de una cabecera sin filename= dejando que el flujo continúe hasta tool_create_output_file(). Como en ese momento is_cd_filename sigue siendo FALSE, esa función abre el nombre derivado de la URL con:
fopen(fname, "wb")
(src/tool_cb_wrt.c, línea 87). El modo "wb" trunca el fichero al abrirlo. Cuando después llega la cabecera con filename="victim", set_filename() la rechaza porque el flujo de salida ya está abierto, pero el daño ya está hecho: el fichero ya se ha quedado vacío.
El investigador encontró la misma situación en otras variantes:
- Con
-L, cuando una respuesta302llevaContent-Disposition: inlineantes deLocation:. - Cuando la primera cabecera usa
filename*=. - Sobre HTTPS.
- Con
-i, el fichero no queda vacío sino que se rellena con las cabeceras de la respuesta.
Pasos de reproducción
- Levantar cualquier servidor HTTP en
127.0.0.1:32700que responda exactamente esto:
HTTP/1.1 200 OK
Content-Disposition: inline
Content-Disposition: attachment; filename="victim"
Content-Length: 8
PAYLOAD
- En el directorio de trabajo, crear un fichero
victimcon contenido y lanzar curl:
printf 'ORIGINAL-CONTENT\n' > victim
curl -sS -OJ http://127.0.0.1:32700/victim; echo "exit=$?"; wc -c victim
- Observar el resultado: curl sale con error 23 y el fichero ha quedado vacío.
curl: (23) client returned ERROR on write of 52 bytes
exit=23
0 victim
Como prueba de control, si el servidor envía solo Content-Disposition: attachment; filename="victim", curl también sale con código 23 pero victim conserva sus 17 bytes. La diferencia está únicamente en esa primera cabecera sin nombre.
Impacto
Un servidor malicioso o comprometido puede destruir el contenido de un fichero local del usuario. Por ejemplo, si alguien ejecuta curl -OJ https://host/report.pdf (o con -L) en un directorio donde ya tiene un report.pdf, ese fichero acaba con 0 bytes, mientras curl informa de que se negó a sobrescribirlo. Se trata de pérdida de datos en un fichero local; el reporte está clasificado con severidad baja.
Remediación
El reporte figura como resuelto. El investigador propuso este cambio en content_disposition(): si todavía no hay flujo de salida abierto, en lugar de crear el fichero se guarda la cabecera en el búfer (cuando se usa -i) o simplemente se continúa, sin abrir aún el fichero de destino.
--- a/src/tool_cb_hdr.c
+++ b/src/tool_cb_hdr.c
@@ -405,8 +405,12 @@ static size_t content_disposition(const char *str, const char *end,
}
break;
}
- if(!outs->stream && !tool_create_output_file(outs, per->config))
- return CURL_WRITEFUNC_ERROR;
+ if(!outs->stream) {
+ if(hdrcbdata->config->show_headers)
+ return buffer_header(hdrcbdata, str, cb);
+ return cb;
+ }
if(tool_write_headers(&per->hdrcbdata, outs->stream))
return CURL_WRITEFUNC_ERROR;
Según el investigador, buffer_header() necesita una declaración adelantada antes de content_disposition(). Con el parche, todos los casos descritos dejan el fichero intacto y una respuesta con Content-Disposition sin ningún nombre sigue usando como reserva el nombre de la URL.
Qué aprender de este caso
- Cuando una herramienta decide el nombre de un fichero a partir de cabeceras, revisa qué pasa si la misma cabecera llega repetida y la primera aparición es incompleta: es fácil que el código se comprometa con un valor por defecto antes de ver la cabecera definitiva.
- Abrir un fichero con
fopen(..., "wb")lo trunca en el acto. Si después hay una comprobación de "no sobrescribir", llega tarde; la comprobación tiene que ir antes de abrir, o abrir con un modo exclusivo que falle si el fichero ya existe. - Un mensaje de error que dice "me negué a escribir" no garantiza que no se haya tocado nada: al probar protecciones contra sobrescritura, comprueba siempre el tamaño y el contenido del fichero después, como hace la prueba de control de este reporte.
- Las redirecciones son otro sitio donde buscar fallos parecidos: en este caso, las cabeceras de una respuesta
302intermedia con-Lbastaban para provocar el mismo truncado.