Neutron, notre moteur d’IA, a obtenu un score de 97,1 % sur le benchmark CyberGym de l’UC Berkeley. En savoir plus

Analyse approfondie

Analyse approfondie

Stanza : RCE par désérialisation pickle dans le lemmatiseur (CVE-2026-115878)

Stanza 1.14.0 charge un pickle gzip interne depuis un checkpoint de lemmatiseur sans restriction, donc un modèle forgé exécute du code ; corrigé dans 1.15.0.

En bref

  • Stanza 1.14.0 stocke son dictionnaire de lemmatiseur sous forme de pickle compressé en gzip dans le checkpoint du modèle, puis le charge avec un pickle.loads sans restriction. Un checkpoint forgé exécute du code au chargement du modèle.
  • Seule la version 1.14.0 de Stanza est concernée. Le chemin est atteignable lorsqu'une application charge un lemma_model_path influencé par l'appelant ou un modèle provenant d'un registre partagé.
  • L'avis GitHub la note 7,8 High avec un vecteur CVSS v3.1 local et une interaction utilisateur. Une preuve de concept de bout en bout confirmée figure dans l'avis. Aucune exploitation n'est connue, et le CVE ne figure pas au catalogue KEV de la CISA au 4 novembre 2026.
  • Passez à Stanza 1.15.0 ou ultérieur. Le correctif remplace le pickle interne par orjson et refuse le format de checkpoint vulnérable ; voir « Comment corriger ? » pour l'étape de conversion des modèles.

Suis-je concerné ?

Toutes ces conditions doivent être réunies :

  • La version installée de Stanza est 1.14.0. C'est la seule version qui écrit et lit le format pos_dict en pickle gzip.
  • Votre code charge un checkpoint de lemmatiseur depuis un chemin qu'un attaquant peut influencer. Citons un argument lemma_model_path, un répertoire de modèles partagé, un registre de modèles personnalisé ou un stockage d'artefacts qui accepte des modèles téléversés.
  • L'attaquant peut placer ou remplacer le fichier .pt chargé par le code. Charger un modèle via le registre de téléchargement par défaut de Stanza n'est pas le chemin exposé, car ce flux vérifie une somme MD5.

La preuve de concept de l'avis appelle directement la fonction vulnérable _unpack_pos_dict, et sa revue du code établit le chemin par l'API documentée stanza.Pipeline(..., lemma_model_path=...). Le chargement a lieu à la construction du pipeline.

Version Concernée Version corrigée
1.14.0 Oui, lorsqu'un checkpoint influencé par l'appelant est chargé 1.15.0
1.13.0 et antérieures Non, ces versions utilisent l'ancien format en tuple Sans objet
1.15.0 et ultérieures Non, ces versions utilisent le format JSON v3 Continuer à mettre à jour

Vérifiez ce qui est installé :

python -c "import stanza; print(stanza.__version__)"
pip show stanza
pip freeze | grep -i '^stanza=='

Pour un conteneur, interrogez le conteneur en cours d'exécution plutôt qu'une image fraîchement téléchargée :

docker exec <container> python -c "import stanza; print(stanza.__version__)"

Cherchez ensuite chaque endroit qui fournit un chemin de modèle :

grep -rn "lemma_model_path" .
grep -rn "lemma" --include="*.py" . | grep -i "model_path\|checkpoint"

Un répertoire de modèles personnalisé partagé entre utilisateurs ou tâches est l'emplacement le plus risqué. Considérez l'accès en écriture à ce répertoire comme une exécution de code sur chaque hôte qui y charge un modèle.

Comment corriger ?

  1. Mettez Stanza à niveau vers 1.15.0 ou ultérieur :
pip install --upgrade "stanza>=1.15.0"
  1. Mettez à jour le fichier de verrouillage avec l'outil utilisé par votre projet :
pip-compile --upgrade-package stanza requirements.in
poetry update stanza
uv lock --upgrade-package stanza
  1. Reconstruisez chaque conteneur et environnement virtuel qui épingle Stanza. La mise à niveau change le format du dictionnaire sur disque, donc une ancienne image qui contient encore 1.14.0 reste vulnérable.
  2. Retéléchargez les modèles Stanza par défaut. La version 1.15.0 refuse de charger un checkpoint de version 2 et lève une ValueError qui nomme le convertisseur. Pour un modèle personnalisé, exécutez le convertisseur fourni :
python -m stanza.utils.lemma.convert_lemma_dict old_model.pt --output new_model.pt
  1. Confirmez que la version installée est 1.15.0 ou ultérieure et que le pipeline charge le modèle converti.

Si une mise à niveau n'est pas possible, réduisez l'exposition en attendant :

  • Ne chargez que des fichiers de modèle dont vous avez vérifié le SHA-256 par rapport à une valeur que vous contrôlez.
  • Restreignez l'accès en écriture au répertoire de modèles et à tout registre qui l'alimente.
  • Préférez le flux de téléchargement par défaut de Stanza, qui vérifie une somme MD5, à un chemin fourni par l'appelant.
  • Exécutez les tâches de chargement de modèles avec un compte à faibles privilèges et sans identifiants cloud.

Ces mesures réduisent qui peut fournir un fichier. Elles ne rendent pas l'appel pickle.loads sûr, et la preuve de concept de l'avis fonctionne toujours contre 1.14.0.

Quelle est la cause racine ?

Stanza est une bibliothèque Python de traitement du langage naturel du groupe Stanford NLP. Dans la version 1.14.0, le lemmatiseur conserve son dictionnaire de parties du discours, pos_dict, sous forme de correspondance imbriquée entre une partie du discours et une correspondance mot vers lemme.

L'entraîneur sérialise cette correspondance avec pickle.dumps, compresse le résultat avec gzip et stocke les octets dans le champ dicts du checkpoint. Le code vulnérable se trouve dans stanza/models/lemma/trainer.py à l'étiquette v1.14.0, lignes 54 à 59 :

def _unpack_pos_dict(data):
    """Deserialize pos_dict from gzip-compressed pickle bytes."""
    buf = io.BytesIO(data)
    with gzip.GzipFile(fileobj=buf, mode="rb") as gz:
        raw = gz.read()
    return pickle.loads(raw)

Trainer.load lit le checkpoint externe avec torch.load(..., weights_only=True) à la ligne 367. Cet indicateur limite le pickle externe aux types primitifs et aux tenseurs. Une valeur bytes est un type primitif, donc le flux gzip interne traverse la liste d'autorisation externe sans inspection. La ligne 378 transmet ensuite ces octets à _unpack_pos_dict :

checkpoint = torch.load(filename, lambda storage, loc: storage, weights_only=True)
...
dicts_version = checkpoint.get('dicts_version', _DICTS_VERSION_LEGACY)
if dicts_version == _DICTS_VERSION_POS:
    self.pos_dict = _unpack_pos_dict(checkpoint['dicts'])

Un flux pickle stocke un appelable par son nom global, avec les arguments de l'appel, et pickle.loads retrouve cet appelable et l'appelle. Un flux malveillant peut nommer n'importe quel appelable, y compris os.system. La preuve de concept de l'avis construit le flux avec une méthode __reduce__ qui renvoie (os.system, (command,)), et la commande s'exécute lorsque _unpack_pos_dict est appelée.

Le weights_only=True externe n'est pas une défense ici. L'opération dangereuse est le pickle.loads interne, qui reçoit des données que le chargeur externe a traitées comme une chaîne d'octets opaque. L'avis précise que ce problème est distinct du repli weights_only=False corrigé sous CVE-2026-54499.

Comment la faille est-elle exploitée ?

  1. L'attaquant construit un checkpoint .pt dont dicts_version vaut 2 et dont la valeur dicts est un pickle compressé en gzip. Le __reduce__ du pickle renvoie un appelable et ses arguments, par exemple os.system et une commande.
  2. L'application victime construit un pipeline avec lemma_model_path pointant vers ce fichier, ou le charge depuis un registre partagé ou un répertoire de modèles dans lequel l'attaquant peut écrire.
  3. torch.load(..., weights_only=True) accepte le checkpoint externe. La valeur dicts est un objet bytes, donc la liste d'autorisation externe ne l'examine pas.
  4. _unpack_pos_dict décompresse les octets et appelle pickle.loads. Le flux pickle invoque l'appelable pendant la désérialisation.
  5. La commande s'exécute avec l'utilisateur qui charge le modèle, avant que le pipeline ne traite du texte.

L'avis contient une preuve de concept minimale. Sa classe de payload est assez courte pour montrer le mécanisme :

class Payload:
    def __reduce__(self):
        return (os.system, ("id > /tmp/stanza_lemma_poc_pwned",))

L'avis indique que le payload s'est exécuté contre Stanza 1.14.0, PyTorch 2.13.0 et CPython 3.13.7, et que le fichier marqueur a enregistré la sortie de la commande. La preuve de concept appelle _unpack_pos_dict directement et établit aussi le chemin via stanza.Pipeline par revue du code.

Comment se déroule l'attaque ?

Un checkpoint influencé par l'appelant transporte un pickle imbriqué au-delà de la liste d'autorisation weights_only externe et jusqu'à un pickle.loads sans restriction, qui exécute la commande de l'attaquant avec l'utilisateur qui charge le modèle.

flowchart LR A["L'attaquant fournit un checkpoint .pt forgé"] --> B["Le pipeline charge lemma_model_path"] B --> C["torch.load weights_only=True laisse passer les octets"] C --> D["_unpack_pos_dict décompresse le flux interne"] D --> E["pickle.loads appelle __reduce__"] E --> F["La commande s'exécute avec l'utilisateur qui charge le modèle"]

Que peut faire un attaquant ?

Un attaquant qui peut fournir le checkpoint exécute du code arbitraire avec les privilèges du processus qui le charge. Dans un notebook, c'est l'utilisateur du notebook. Dans un service, c'est le compte de service. Dans une tâche CI, c'est l'utilisateur de build qui accède au dépôt et à ses secrets.

Le code s'exécute pendant la construction du pipeline, avant que l'application ne traite un seul document. Il peut lire les fichiers de modèle, les jeux de données accessibles à la tâche, les variables d'environnement, les identifiants cloud et les jetons d'API. Il peut aussi écrire sur le système de fichiers ou lancer un processus qui survit au chargement.

Dans une tâche CI qui construit un artefact de modèle, le chargement peut s'exécuter avant les tests, lire le jeton CI dans l'environnement et pousser vers le dépôt. Un service d'hébergement de modèles expose la même primitive à quiconque peut téléverser un checkpoint, même lorsque son API d'inférence exige une authentification.

Les conditions sont les mêmes que dans « Suis-je concerné ? » : Stanza 1.14.0, un checkpoint provenant d'un chemin influencé par l'attaquant et la possibilité de placer ou remplacer ce fichier. Le vecteur CVSS est local avec interaction utilisateur, CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H, car la victime doit charger le fichier. L'attaquant n'a pas besoin d'identifiants pour l'installation de Stanza.

Aucune exploitation en conditions réelles n'est connue. L'avis contient une preuve de concept de bout en bout confirmée, et le découvreur l'a testée contre le paquet publié, PyTorch 2.13.0 et CPython 3.13.7.

Pourquoi le correctif fonctionne-t-il ?

Le correctif de 1.15.0 retire pickle du chemin du dictionnaire. Le commit de version b21e1c9b47b4fccff21d3b0a6c0687a19410c2f0 remplace le sérialiseur par orjson et le désérialiseur par orjson.loads :

 def _pack_pos_dict(pos_dict):
-    raw = pickle.dumps(pos_dict, protocol=4)
+    raw = orjson.dumps(pos_dict)
     ...
 def _unpack_pos_dict(data):
-    return pickle.loads(raw)
+    return orjson.loads(raw)

Le même commit introduit une nouvelle version de format et refuse l'ancienne :

-_DICTS_VERSION_LEGACY = 1   # old (word_dict, composite_dict) tuple
-_DICTS_VERSION_POS    = 2   # new {pos: {word: lemma}}, gzip-pickled bytes
+_DICTS_VERSION_LEGACY     = 1   # old (word_dict, composite_dict) tuple
+_DICTS_VERSION_POS_PICKLE = 2   # new {pos: {word: lemma}}, gzip+pickle bytes (security hole, replaced by v3)
+_DICTS_VERSION_POS        = 3   # new {pos: {word: lemma}}, gzip+json bytes
...
 if dicts_version == _DICTS_VERSION_POS:
     self.pos_dict = _unpack_pos_dict(checkpoint['dicts'])
+elif dicts_version == _DICTS_VERSION_POS_PICKLE:
+    raise ValueError(
+        f"Lemmatizer model {filename!r} uses a pickle-based dict format (v2) "
+        "that is no longer supported for security reasons. "
+        "Please re-download the model or run convert_lemma_dict.py to upgrade it to v3."
+    )

orjson.loads analyse des données JSON en dictionnaires, listes, chaînes et nombres. Il ne peut pas nommer un appelable ni reconstruire un objet arbitraire, donc un dictionnaire forgé ne peut pas exécuter du code. Refuser la version 2 empêche un attaquant d'utiliser l'ancien format pickle contre une installation corrigée.

Le correctif est complet pour le chemin démontré. Il change à la fois l'écriture et la lecture, et il refuse les anciens checkpoints au lieu de tenter de les assainir. Le coût est que les modèles personnalisés en version 2 doivent être retéléchargés ou convertis avec convert_lemma_dict.py.

Comment vérifier si j'ai été compromis ?

Aucun indicateur de campagne n'est publié pour cette faille. La preuve de concept de l'avis écrit un fichier marqueur uniquement pour prouver l'exécution, donc un chemin de marqueur n'est pas un indicateur de production.

Vérifiez plutôt la chaîne d'approvisionnement des modèles :

  • Listez chaque fichier .pt chargé par un pipeline Stanza et comparez son origine avec votre registre de modèles ou votre journal de téléchargement. Un fichier sans origine enregistrée est une piste.
  • Examinez les changements récents des valeurs lemma_model_path et de tout répertoire de modèles partagé. Un checkpoint nouveau ou remplacé est le vecteur de livraison.
  • Examinez les journaux CI et notebook pour des téléchargements de modèles depuis des hôtes autres que le dépôt de modèles de Stanza. Le pipeline par défaut télécharge depuis les URL du projet.
  • Cherchez des processus, fichiers ou connexions sortantes apparus au chargement d'un modèle. La preuve de concept lance une commande shell, donc le processus enfant peut apparaître dans les journaux d'audit.

Si un checkpoint malveillant a été chargé, traitez l'hôte comme compromis. Changez tous les secrets que le processus de chargement pouvait lire, y compris les clés cloud, les jetons d'API et les jetons Hugging Face. Reconstruisez l'environnement à partir d'une image saine plutôt que de supprimer un seul fichier.

Chronologie

Le correctif est arrivé 14 jours après 1.14.0, puis a attendu 64 jours avant une version.

  • 2026-07-15 : Stanza 1.14.0 est publiée avec le format de dictionnaire version 2 en pickle gzip.
  • 2026-07-29 : Le commit de correction remplace pickle par orjson et ajoute le format version 3.
  • 2026-10-01 : Stanza 1.15.0 est publiée avec le correctif.
  • 2026-10-23 : L'identifiant CVE est réservé.
  • 2026-11-03 : L'avis GitHub et l'enregistrement CVE sont publiés.

Le correctif est arrivé 14 jours après la version vulnérable, mais a attendu 64 jours avant la publication de 1.15.0, laissant les utilisateurs sans version corrigée pendant plus de deux mois.
Figure 1 : Corrigé en 14 jours, publié 64 jours plus tard. Source : étiquettes de version Stanza, commit de correction et avis GitHub, vérifiés le 4 novembre 2026.

FAQ

Toutes les versions de Stanza sont-elles concernées ?

Non. Seule la 1.14.0 écrit et lit le format version 2 vulnérable. Les versions jusqu'à 1.13.0 utilisent l'ancien format en tuple, et 1.15.0 et ultérieures utilisent le format JSON version 3.

weights_only=True me protège-t-il ?

Non. L'appel torch.load externe est restreint, mais le champ dicts est un objet bytes que la liste d'autorisation externe n'inspecte pas. L'appel dangereux est le pickle.loads interne à _unpack_pos_dict.

Est-ce la même chose que CVE-2026-54499 ?

Non. CVE-2026-54499 couvrait un repli weights_only=False dans un ancien chemin de chargement. Cette faille reste dans le chemin weights_only=True et abuse du flux d'octets imbriqué.

Dois-je retélécharger mes modèles ?

Oui pour les checkpoints en version 2. Stanza 1.15.0 les refuse avec une ValueError qui renvoie à convert_lemma_dict.py. Retéléchargez les modèles par défaut ou convertissez un modèle personnalisé avant de le charger.

La faille a-t-elle été exploitée en conditions réelles ?

Aucune exploitation publique n'est connue au 4 novembre 2026. L'avis contient une preuve de concept de bout en bout confirmée, et le CVE ne figure pas au catalogue KEV de la CISA.

Quel est le risque d'un répertoire de modèles partagé ?

C'est une exécution de code pour chaque compte qui peut y écrire. Un utilisateur qui peut remplacer un fichier .pt peut exécuter du code avec chaque utilisateur ou service qui charge ensuite ce fichier, donc traitez le répertoire comme un chemin de code privilégié.

Trouver les installations Stanza vulnérables avec Ostorlab

L'analyse de composition logicielle et le SBOM d'Ostorlab inventorient les paquets Python de vos applications et signalent les versions que vos manifestes et fichiers de verrouillage résolvent réellement. Utilisez-les pour trouver Stanza 1.14.0 et les chemins de chargement de modèles qui nécessitent la mise à niveau vers 1.15.0.

Références

  • GitHub, « Stanza: RCE via pickle deserialization in the lemmatizer » (GHSA-gh9r-c94j-cmp5, publié le 3 novembre 2026). https://github.com/advisories/GHSA-gh9r-c94j-cmp5 (vérifié le 4 novembre 2026)
  • CVE Program, « CVE-2026-115878 » (publié le 3 novembre 2026). https://www.cve.org/CVERecord?id=CVE-2026-115878 (vérifié le 4 novembre 2026)
  • Stanford NLP, « Patch a security hole by using orjson to encode/decode the lemma dictionary instead of pickle » (commit b21e1c9b, rédigé le 29 juillet 2026). https://github.com/stanfordnlp/stanza/commit/b21e1c9b47b4fccff21d3b0a6c0687a19410c2f0 (vérifié le 4 novembre 2026)
  • Stanford NLP, « Use orjson for the lemma dictionary » (pull request 1649). https://github.com/stanfordnlp/stanza/pull/1649 (vérifié le 4 novembre 2026)
  • Stanford NLP, « Stanza 1.15.0 » (publié le 1er octobre 2026). https://github.com/stanfordnlp/stanza/releases/tag/v1.15.0 (vérifié le 4 novembre 2026)
  • Stanford NLP, « stanza/models/lemma/trainer.py at v1.14.0 » (source à l'étiquette vulnérable). https://github.com/stanfordnlp/stanza/blob/v1.14.0/stanza/models/lemma/trainer.py (vérifié le 4 novembre 2026)
  • PyPI Stats, « stanza » (téléchargements hebdomadaires, vérifiés le 4 novembre 2026). https://pypistats.org/packages/stanza (vérifié le 4 novembre 2026)
  • CISA, « Known Exploited Vulnerabilities Catalog » (flux vérifié le 4 novembre 2026). https://www.cisa.gov/known-exploited-vulnerabilities-catalog (vérifié le 4 novembre 2026)