Reports
BAJA[OTROS]#4124849

CVE-2026-111951: curl -OJ vacía un fichero existente si llega un Content-Disposition sin nombre antes del bueno

Con -O -J, un servidor que envía primero un Content-Disposition sin filename= y después otro con el nombre de un fichero ya existente consigue que curl lo deje a 0 bytes, aunque curl diga que se negó a sobrescribirlo.

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

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 respuesta 302 lleva Content-Disposition: inline antes de Location:.
  • 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

  1. Levantar cualquier servidor HTTP en 127.0.0.1:32700 que responda exactamente esto:
HTTP/1.1 200 OK
Content-Disposition: inline
Content-Disposition: attachment; filename="victim"
Content-Length: 8

PAYLOAD
  1. En el directorio de trabajo, crear un fichero victim con contenido y lanzar curl:
printf 'ORIGINAL-CONTENT\n' > victim
curl -sS -OJ http://127.0.0.1:32700/victim; echo "exit=$?"; wc -c victim
  1. 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 302 intermedia con -L bastaban para provocar el mismo truncado.