GHSA-wg9g-w2j2-8pgr
CVSS 7.8 · Élevée CorrigéGHSA-wg9g-w2j2-8pgr : exécution de code dans MONAI via des fichiers NumPy picklés lus par NumpyReader
Le NumpyReader de MONAI chargeait les fichiers .npy et .npz avec allow_pickle=True : un fichier piégé dans un jeu d’imagerie médicale exécutait du code.
Pour décider
- Impact
- Un fichier de données NumPy piégé peut exécuter du code pendant son chargement dans MONAI.
- Conditions
- Une personne doit charger le fichier .npy ou .npz avec le lecteur concerné. Le code obtient les droits du processus de traitement.
- Action recommandée
- Installer MONAI 1.6.0 ou ultérieur et conserver le chargement pickle désactivé pour les données non fiables.
Fiche de l’avis
Synthèse de l’enquête
La classe NumpyReader de MONAI appelait np.load() avec allow_pickle=True codé en dur, et LoadImage choisit ce lecteur pour tout fichier .npy ou .npz. Un fichier forgé placé dans un jeu de données exécutait donc du code arbitraire, via le protocole __reduce__ de pickle, dans le processus de quiconque le chargeait pour un entraînement ou une inférence. L’option ne pouvait pas être désactivée. Corrigé en 1.6.0, où le chargement pickle est désactivé par défaut.
Le lecteur oublié par le durcissement
Nous avons repris une question laissée ouverte par le durcissement de torch.load dans MONAI : les autres lecteurs de données avaient-ils la même prudence ? La comparaison de NPZDataset et NumpyReader a donné une réponse nette. Le premier conservait le défaut sûr de NumPy ; le second imposait allow_pickle=True dans np.load(), et sa docstring précisait même que l’utilisateur ne pouvait pas changer ce paramètre. Comme LoadImage choisit NumpyReader pour les fichiers .npy et .npz, cette décision touchait le chemin ordinaire des jeux de données.
Nous avons fabriqué un fichier NumPy contenant un objet dont __reduce__ lance une commande témoin. La lecture par NumpyReader a créé le fichier marqueur avant que MONAI ne récupère le tableau. Nous avons signalé le cas le 23 mars 2026. MONAI a fusionné le correctif le 2 juin, publié 1.6.0 et l’avis GHSA-wg9g-w2j2-8pgr le 11 juin, sans CVE. L’impact exige le chargement d’un fichier non fiable ; notre PoC ne transforme pas chaque entraînement MONAI en exécution distante spontanée.
Chargement d’un fichier de données NumPy
MONAI lit les images .npy et .npz avec NumpyReader. Ces fichiers circulent entre équipes de recherche : charger un jeu de données tiers ne devrait pas donner au fichier le droit d’exécuter du Python.
Les fichiers NumPy sont courants dans ce domaine. Volumes prétraités, masques de segmentation et étiquettes sont régulièrement stockés en .npy ou .npz, et ils circulent : jeux de données publics de challenges, archives échangées entre établissements, dossiers sur le stockage partagé d’un laboratoire, exemples joints à des tutoriels. Ceux qui chargent ces fichiers sont rarement ceux qui les ont produits, ce qui fait du lecteur la frontière de sécurité. Un fichier de données devrait être inerte.
Comparer les deux lecteurs NumPy
MONAI avait déjà connu une campagne de durcissement contre la désérialisation. Depuis GHSA-6vm5-6jv9-rjpj, ses appels à torch.load utilisent weights_only=True, et PersistentDataset applique le même réglage par défaut. La question de départ était de savoir si tous les autres chargeurs de la chaîne de données avaient reçu le même traitement.
La réponse est venue de la comparaison de deux classes. NPZDataset, dans monai/data/dataset.py, appelle np.load(npzfile) et conserve la valeur sûre par défaut de NumPy. NumpyReader, dans monai/data/image_reader.py, impose le réglage inverse, et sa docstring précise que les arguments nommés sont transmis à numpy.load « sauf allow_pickle ». La valeur dangereuse était un choix délibéré et figé, que l’utilisateur ne pouvait pas modifier, sur le lecteur que LoadImage utilise pour tous les fichiers NumPy.
Cause racine
L’appel vulnérable, au commit 5b71547 (ligne 1276) :
# monai/data/image_reader.py, NumpyReader.read()
kwargs_ = self.kwargs.copy()
kwargs_.update(kwargs)
for name in filenames:
img = np.load(name, allow_pickle=True, **kwargs_)
if Path(name).name.endswith(".npz"):
npz_keys = list(img.keys()) if self.npz_keys is None else self.npz_keys
for k in npz_keys:
img_.append(img[k])
Comprendre pourquoi cela revient à exécuter du code demande deux éléments du fonctionnement de NumPy et de Python.
Le premier est le format .npy. Un fichier commence par la chaîne magique \x93NUMPY, une version et un en-tête qui décrit le tableau (descr, fortran_order, shape). Pour un tableau numérique ordinaire, les données qui suivent sont des octets bruts. Pour un tableau de dtype object (descr égal à '|O'), NumPy ne peut pas stocker d’octets bruts : np.save écrit alors le tableau sous forme de flux pickle. Au chargement, numpy.lib.format.read_array() lève une ValueError pour un tableau d’objets lorsque allow_pickle est faux, et appelle pickle.load() sur les données lorsqu’il est vrai. np.load possède un second chemin : quand un fichier ne commence ni par la signature NumPy ni par une signature ZIP, et que allow_pickle est vrai, il traite le fichier entier comme un pickle et le passe à pickle.load(). Un simple pickle renommé en .npy est donc accepté lui aussi. Pour les archives .npz, chaque membre est lu avec le même drapeau au moment où on y accède, et NumpyReader accède à tous.
Le second est pickle lui-même. Quand un objet est sérialisé, Python lui demande comment se reconstruire via __reduce__, qui renvoie un appelable et un tuple d’arguments. Le flux enregistre l’appelable par son module et son nom, puis émet un opcode REDUCE. Au chargement, le désérialiseur importe cet appelable et l’appelle avec ces arguments, avant tout code qui pourrait inspecter le résultat. Rien ne limite l’appelable à un constructeur : os.system, subprocess.Popen ou builtins.exec conviennent tout autant, et la classe qui a produit le flux n’a même pas besoin d’exister sur la machine de la victime.
La valeur par défaut de allow_pickle dans NumPy est False depuis la version 1.16.3, précisément pour cette raison. La coder en dur à True réintroduisait le risque dans toutes les chaînes MONAI, ce qui relève de CWE-502 (désérialisation de données non fiables).
Exploitation
Fabriquer un fichier malveillant prend quelques lignes. Un objet dont __reduce__ renvoie os.system et une commande est placé dans un tableau d’objets de dimension zéro, puis enregistré :
import os
import numpy as np
class MaliciousPayload:
def __reduce__(self):
return (os.system, ('echo "MONAI NumpyReader RCE - Code executed" > /tmp/monai_rce_proof.txt',))
np.save("malicious_mask.npy", np.array(MaliciousPayload()), allow_pickle=True)
Côté victime, lire le fichier suffit :
from monai.data.image_reader import NumpyReader
NumpyReader().read("malicious_mask.npy")
# /tmp/monai_rce_proof.txt existe désormais
La commande s’exécute à l’intérieur de np.load(), avant que MONAI ne voie le moindre tableau. Dans une vraie chaîne, la victime n’instancie jamais le lecteur à la main. LoadImage, ou sa variante par dictionnaire LoadImaged dans un Compose, parcourt ses lecteurs enregistrés et retient NumpyReader parce que verify_suffix() reconnaît .npy et .npz. Toutes les classes de jeu de données qui appliquent ces transformations (Dataset, CacheDataset, SmartCacheDataset, PersistentDataset) déclenchent le chargement, souvent dans plusieurs workers du data loader en parallèle.
Le mode de livraison réaliste est l’empoisonnement de données : un masque parmi des milliers dans une archive partagée, un fichier ajouté au dossier commun d’un laboratoire, un exemple fourni avec un tutoriel. Les masques NumPy sont rarement inspectés à la main avant un entraînement.
Impact
L’avis GitHub note le défaut 7,8 avec AV:L/AC:L/PR:N/UI:R. L’attaquant n’a besoin d’aucun compte, mais quelqu’un doit charger le fichier localement, ce qui correspond à l’interaction utilisateur du vecteur. Il obtient en retour l’exécution de code, avec un impact total sur la confidentialité, l’intégrité et la disponibilité, sous le compte qui exécute la tâche d’entraînement ou d’inférence.
Ce compte est en général bien placé. Les serveurs d’entraînement hébergent les jeux de données eux-mêmes, qui dans ce domaine sont souvent des données de patients, ainsi que les poids des modèles, les jetons de suivi d’expériences, les identifiants de stockage cloud et l’accès à des clusters GPU partagés. Un jeu de données empoisonné est en outre persistant et réutilisable : chaque personne et chaque tâche qui le charge est touchée, sans autre action de l’attaquant.
Correctif
MONAI 1.6.0 corrige le défaut avec la PR #8875 (« Adding allow_pickle argument »), fusionnée sous le commit 013bda0. NumpyReader reçoit un argument de constructeur allow_pickle qui vaut False par défaut, et read() l’utilise :
# monai/data/image_reader.py (1.6.0)
def __init__(
self,
npz_keys: KeysCollection | None = None,
channel_dim: str | int | None = None,
allow_pickle: bool = False,
**kwargs,
):
...
self.allow_pickle = allow_pickle
def read(self, data, **kwargs):
...
for name in filenames:
try:
img = np.load(name, allow_pickle=self.allow_pickle, **kwargs_)
except ValueError as e:
raise ValueError(
"MONAI default value for argument `allow_pickle` of `np.load` changed to `False`, "
"explicitly pass `allow_pickle=True` as a constructor argument to NumpyReader "
"to enable pickle loading."
) from e
Avec la valeur par défaut, les deux chemins de NumPy qui mènent à pickle.load() (tableaux d’objets et repli sur le fichier entier) lèvent une ValueError avant qu’un seul octet ne soit désérialisé, et MONAI la transforme en message explicite. La PR est signalée comme changement incompatible : les chaînes qui stockent réellement des objets Python dans des fichiers NumPy doivent désormais l’activer avec NumpyReader(allow_pickle=True), à réserver aux données dont l’origine est maîtrisée. Les tests mis à jour vérifient les deux comportements.
Les notes de version 1.6 décrivent ce changement sous un autre identifiant, GHSA-qxq5-qhx6-94qw, alors que la PR renvoie à GHSA-wg9g-w2j2-8pgr. La même version soumet par ailleurs l’usage de pickle dans la sérialisation des algorithmes Auto3DSeg à la variable d’environnement MONAI_ALLOW_PICKLE.
Détection et mitigation
- Mettre à jour vers MONAI 1.6.0 ou une version ultérieure, puis rechercher dans le code les
allow_pickle=Truepassés àNumpyReaderou àLoadImageet les supprimer partout où les données ne viennent pas d’une source de confiance. - En attendant la mise à jour, enregistrer un lecteur sûr par construction : une sous-classe de
NumpyReaderdontread()appellenp.load(name, allow_pickle=False), passée explicitement àLoadImage(reader=...). - Analyser les jeux de données avant usage. Un fichier
.npydont l’en-tête déclare'descr': '|O', ou un fichier d’extension.npyou.npzqui ne commence ni par\x93NUMPYni parPK, contient un pickle ou en est un, et n’a pas sa place dans un jeu d’imagerie. Des outils commeficklingoupicklescanpermettent d’inspecter les flux pickle trouvés. - Traiter les jeux de données tiers comme des entrées non fiables : vérifier les sommes de contrôle auprès de l’éditeur, conserver la trace de leur provenance et effectuer les premiers chargements dans un environnement isolé, sans identifiants ni accès réseau.
- Sur les machines d’entraînement, surveiller les shells ou utilitaires réseau lancés par des processus Python d’entraînement ou par leurs workers de chargement de données.
Chronologie
| Date | Événement |
|---|---|
| 23/03/2026 | Signalement privé aux mainteneurs via un avis de sécurité GitHub |
| 27/05/2026 | Ouverture de la PR #8875 |
| 02/06/2026 | Fusion de la PR #8875 (013bda0) |
| 11/06/2026 | Publication de MONAI 1.6.0 |
| 11/06/2026 | Publication de l’avis GHSA-wg9g-w2j2-8pgr (sans identifiant CVE) |
| 18/08/2026 | Ajout de l’avis à la GitHub Advisory Database |
Références
- Avis de l’éditeur · GHSA-wg9g-w2j2-8pgr github.com/Project-MONAI/MONAI/security/advisories/GHSA-wg9g-w2j2-8pgr
- MITRE CWE-502 cwe.mitre.org/data/definitions/502.html
- Dépôt du projet github.com/Project-MONAI/MONAI
Citer cet avis
rootzero. « GHSA-wg9g-w2j2-8pgr : exécution de code dans MONAI via des fichiers NumPy picklés lus par NumpyReader ». Recherche de vulnérabilités, rootzero, 11 juin 2026. https://rootzero.eu/research/cve/ghsa-wg9g-w2j2-8pgr-monai-numpy-pickle-rce/
#Recherche de vulnérabilités
Faites auditer votre code avant qu’un autre ne le fasse.
Votre équipe doit décider quoi corriger avant une livraison ou évaluer une dépendance critique. Nous définissons une mission autour de cette décision, avec des preuves et des priorités de correction expliquées.