Neutron, nuestro motor de IA, obtuvo un 97.1% en el benchmark CyberGym de UC Berkeley. Más información

Análisis a fondo

Análisis a fondo

Stanza: RCE por deserialización de pickle en el lematizador (CVE-2026-115878)

Stanza 1.14.0 carga sin restricción un pickle gzip de un checkpoint de lematizador, así que un modelo manipulado ejecuta código; corregido en 1.15.0.

Resumen

  • Stanza 1.14.0 guarda su diccionario de lematizador como un pickle comprimido con gzip dentro del checkpoint del modelo y después lo carga con un pickle.loads sin restricción. Un checkpoint manipulado ejecuta código al cargar el modelo.
  • Solo Stanza 1.14.0 está afectada. La ruta es alcanzable cuando una aplicación carga un lemma_model_path influido por quien llama o un modelo de un registro compartido.
  • El aviso de GitHub la califica como 7.8 High con un vector CVSS v3.1 local y interacción del usuario. El aviso incluye una prueba de concepto confirmada de extremo a extremo. No se conoce explotación, y la CVE no está en el catálogo KEV de CISA a 4 de noviembre de 2026.
  • Actualice a Stanza 1.15.0 o posterior. La corrección sustituye el pickle interno por orjson y rechaza el formato de checkpoint vulnerable; consulte "¿Cómo lo corrijo?" para el paso de conversión de modelos.

¿Estoy afectado?

Todas estas condiciones deben cumplirse:

  • La versión instalada de Stanza es 1.14.0. Es la única versión que escribe y lee el formato pos_dict de pickle gzip.
  • Su código carga un checkpoint de lematizador desde una ruta que un atacante puede influir. Algunos ejemplos son un argumento lemma_model_path, un directorio de modelos compartido, un registro de modelos personalizado o un almacén de artefactos que acepta modelos subidos.
  • El atacante puede colocar o reemplazar el archivo .pt que carga el código. Cargar un modelo mediante el registro de descargas predeterminado de Stanza no es la ruta expuesta, porque ese flujo verifica una suma MD5.

La prueba de concepto del aviso llama directamente a la función vulnerable _unpack_pos_dict, y su revisión del código establece la ruta mediante la API documentada stanza.Pipeline(..., lemma_model_path=...). La carga ocurre durante la construcción del pipeline.

Versión Afectada Versión corregida
1.14.0 Sí, cuando se carga un checkpoint influido por quien llama 1.15.0
1.13.0 y anteriores No, estas versiones usan el formato heredado de tupla No aplica
1.15.0 y posteriores No, estas versiones usan el formato JSON v3 Seguir actualizando

Compruebe qué está instalado:

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

Para un contenedor, consulte el contenedor en ejecución en lugar de una imagen recién descargada:

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

Después busque cada lugar que proporcione una ruta de modelo:

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

Un directorio de modelos personalizado compartido entre usuarios o tareas es la ubicación de mayor riesgo. Trate el acceso de escritura a ese directorio como ejecución de código en cada host que carga desde él.

¿Cómo lo corrijo?

  1. Actualice Stanza a 1.15.0 o posterior:
pip install --upgrade "stanza>=1.15.0"
  1. Actualice el archivo de bloqueo con la herramienta que use su proyecto:
pip-compile --upgrade-package stanza requirements.in
poetry update stanza
uv lock --upgrade-package stanza
  1. Reconstruya cada contenedor y entorno virtual que fije Stanza. La actualización cambia el formato del diccionario en disco, así que una imagen antigua que todavía contiene 1.14.0 sigue siendo vulnerable.
  2. Vuelva a descargar los modelos predeterminados de Stanza. La versión 1.15.0 se niega a cargar un checkpoint de versión 2 y lanza un ValueError que nombra el conversor. Para un modelo personalizado, ejecute el conversor incluido:
python -m stanza.utils.lemma.convert_lemma_dict old_model.pt --output new_model.pt
  1. Confirme que la versión instalada es 1.15.0 o posterior y que el pipeline carga el modelo convertido.

Si no puede actualizar, reduzca la exposición mientras lo organiza:

  • Cargue solo archivos de modelo cuyo SHA-256 haya verificado contra un valor que usted controle.
  • Restrinja el acceso de escritura al directorio de modelos y a cualquier registro que lo alimente.
  • Prefiera el flujo de descarga predeterminado de Stanza, que verifica una suma MD5, frente a una ruta proporcionada por quien llama.
  • Ejecute las tareas de carga de modelos con una cuenta de bajos privilegios y sin credenciales de nube.

Esas medidas reducen quién puede proporcionar un archivo. No hacen seguro el pickle.loads, y la prueba de concepto del aviso sigue funcionando contra 1.14.0.

¿Cuál es la causa raíz?

Stanza es una biblioteca Python de procesamiento de lenguaje natural del grupo Stanford NLP. En la versión 1.14.0, el lematizador guarda su diccionario de partes de la oración, pos_dict, como una asignación anidada entre una parte de la oración y una asignación de palabra a lema.

El entrenador serializa esa asignación con pickle.dumps, comprime el resultado con gzip y guarda los bytes en el campo dicts del checkpoint. El código vulnerable está en stanza/models/lemma/trainer.py en la etiqueta v1.14.0, líneas 54 a 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 lee el checkpoint externo con torch.load(..., weights_only=True) en la línea 367. Ese indicador limita el pickle externo a tipos primitivos y tensores. Un valor bytes es un tipo primitivo, así que el flujo gzip interno atraviesa la lista de permitidos externa sin inspección. La línea 378 entrega después esos bytes a _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 flujo pickle guarda una función invocable por su nombre global, junto con los argumentos de la llamada, y pickle.loads busca esa función y la llama. Un flujo malicioso puede nombrar cualquier función invocable, incluida os.system. La prueba de concepto del aviso construye el flujo con un método __reduce__ que devuelve (os.system, (command,)), y el comando se ejecuta cuando se llama a _unpack_pos_dict.

El weights_only=True externo no es una defensa aquí. La operación peligrosa es el pickle.loads interno, que recibe datos que el cargador externo trató como una cadena de bytes opaca. El aviso señala que esto es distinto del respaldo weights_only=False corregido en CVE-2026-54499.

¿Cómo se explota?

  1. El atacante construye un checkpoint .pt cuyo dicts_version es 2 y cuyo valor dicts es un pickle comprimido con gzip. El __reduce__ del pickle devuelve una función invocable y sus argumentos, por ejemplo os.system y un comando.
  2. La aplicación víctima construye un pipeline con lemma_model_path apuntando a ese archivo, o lo carga desde un registro compartido o un directorio de modelos en el que el atacante puede escribir.
  3. torch.load(..., weights_only=True) acepta el checkpoint externo. El valor dicts es un objeto bytes, así que la lista de permitidos externa no lo examina.
  4. _unpack_pos_dict descomprime los bytes y llama a pickle.loads. El flujo pickle invoca la función durante la deserialización.
  5. El comando se ejecuta como el usuario que carga el modelo, antes de que el pipeline procese texto.

El aviso incluye una prueba de concepto mínima. Su clase de payload es lo bastante corta para mostrar el mecanismo:

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

El aviso indica que el payload se ejecutó contra Stanza 1.14.0, PyTorch 2.13.0 y CPython 3.13.7, y que el archivo marcador registró la salida del comando. La prueba de concepto llama a _unpack_pos_dict directamente y también establece la ruta mediante stanza.Pipeline por revisión del código.

¿Cómo se desarrolla el ataque?

Un checkpoint influido por quien llama transporta un pickle anidado más allá de la lista de permitidos weights_only externa y hasta un pickle.loads sin restricción, que ejecuta el comando del atacante como el usuario que carga el modelo.

flowchart LR A["El atacante proporciona un checkpoint .pt manipulado"] --> B["El pipeline carga lemma_model_path"] B --> C["torch.load weights_only=True deja pasar los bytes"] C --> D["_unpack_pos_dict descomprime el flujo interno"] D --> E["pickle.loads llama a __reduce__"] E --> F["El comando se ejecuta como el usuario que carga el modelo"]

¿Qué puede hacer un atacante?

Un atacante que puede proporcionar el checkpoint ejecuta código arbitrario con los privilegios del proceso que lo carga. En un notebook, es el usuario del notebook. En un servicio, es la cuenta de servicio. En una tarea de CI, es el usuario de compilación con acceso al repositorio y a sus secretos.

El código se ejecuta durante la construcción del pipeline, antes de que la aplicación procese un solo documento. Puede leer los archivos de modelo, los conjuntos de datos a los que accede la tarea, las variables de entorno, las credenciales de nube y los tokens de API. También puede escribir en el sistema de archivos o iniciar un proceso que sobreviva a la carga.

En una tarea de CI que construye un artefacto de modelo, la carga puede ejecutarse antes de las pruebas, leer el token de CI del entorno y publicar en el repositorio. Un servicio de alojamiento de modelos expone la misma primitiva a cualquiera que pueda subir un checkpoint, aunque su API de inferencia exija autenticación.

Las condiciones son las mismas que en "¿Estoy afectado?": Stanza 1.14.0, un checkpoint de una ruta influida por el atacante y la capacidad de colocar o reemplazar ese archivo. El vector CVSS es local con interacción del usuario, CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H, porque la víctima debe cargar el archivo. El atacante no necesita credenciales para la instalación de Stanza.

No se conoce explotación en la naturaleza. El aviso contiene una prueba de concepto confirmada de extremo a extremo, y el descubridor la probó contra el paquete publicado, PyTorch 2.13.0 y CPython 3.13.7.

¿Por qué funciona la corrección?

La corrección de 1.15.0 elimina pickle de la ruta del diccionario. El commit de versión b21e1c9b47b4fccff21d3b0a6c0687a19410c2f0 cambia el serializador a orjson y el deserializador a 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)

El mismo commit introduce una nueva versión de formato y rechaza la anterior:

-_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 analiza datos JSON en diccionarios, listas, cadenas y números. No puede nombrar una función invocable ni reconstruir un objeto arbitrario, así que un diccionario manipulado no puede ejecutar código. Rechazar la versión 2 impide que un atacante use el formato pickle antiguo contra una instalación corregida.

La corrección es completa para la ruta demostrada. Cambia tanto la escritura como la lectura, y rechaza los checkpoints antiguos en lugar de intentar sanearlos. El coste es que los modelos personalizados de versión 2 deben volver a descargarse o convertirse con convert_lemma_dict.py.

¿Cómo compruebo si estuve comprometido?

No se publican indicadores de campaña para esta falla. La prueba de concepto del aviso escribe un archivo marcador solo para demostrar la ejecución, así que una ruta de marcador no es un indicador de producción.

Compruebe la cadena de suministro de modelos:

  • Enumere cada archivo .pt que carga un pipeline de Stanza y compare su origen con su registro de modelos o el registro de descargas. Un archivo sin origen registrado es una pista.
  • Revise los cambios recientes en los valores de lemma_model_path y en cualquier directorio de modelos compartido. Un checkpoint nuevo o reemplazado es el mecanismo de entrega.
  • Revise los registros de CI y notebooks en busca de descargas de modelos desde hosts distintos del repositorio de modelos de Stanza. El pipeline predeterminado descarga desde las URL del proyecto.
  • Busque procesos, archivos o conexiones salientes que aparecieran al cargar un modelo. La prueba de concepto ejecuta un comando de shell, así que el proceso hijo puede aparecer en los registros de auditoría.

Si se cargó un checkpoint malicioso, trate el host como comprometido. Rote todos los secretos que el proceso de carga podía leer, incluidas las claves de nube, los tokens de API y los tokens de Hugging Face. Reconstruya el entorno desde una imagen sana en lugar de borrar un solo archivo.

Cronología

La corrección llegó 14 días después de 1.14.0 y después esperó 64 días hasta una versión.

  • 2026-07-15: Stanza 1.14.0 se publica con el formato de diccionario versión 2 de pickle gzip.
  • 2026-07-29: El commit de corrección sustituye pickle por orjson y añade el formato versión 3.
  • 2026-10-01: Stanza 1.15.0 se publica con la corrección.
  • 2026-10-23: Se reserva el identificador CVE.
  • 2026-11-03: Se publican el aviso de GitHub y el registro CVE.

La corrección llegó 14 días después de la versión vulnerable, pero esperó 64 días hasta la publicación de 1.15.0, dejando a los usuarios sin versión corregida durante más de dos meses.
Figura 1: Corregido en 14 días, publicado 64 días después. Fuente: etiquetas de versión de Stanza, commit de corrección y aviso de GitHub, verificados el 4 de noviembre de 2026.

Preguntas frecuentes

¿Todas las versiones de Stanza están afectadas?

No. Solo 1.14.0 escribe y lee el formato versión 2 vulnerable. Las versiones hasta 1.13.0 usan el formato heredado de tupla, y 1.15.0 y posteriores usan el formato JSON versión 3.

¿Me protege weights_only=True?

No. La llamada externa torch.load está restringida, pero el campo dicts es un objeto bytes que la lista de permitidos externa no inspecciona. La llamada peligrosa es el pickle.loads interno de _unpack_pos_dict.

¿Es lo mismo que CVE-2026-54499?

No. CVE-2026-54499 cubría un respaldo weights_only=False en una ruta de carga anterior. Esta falla permanece en la ruta weights_only=True y abusa del flujo de bytes anidado.

¿Debo volver a descargar mis modelos?

Sí para los checkpoints de versión 2. Stanza 1.15.0 los rechaza con un ValueError que remite a convert_lemma_dict.py. Vuelva a descargar los modelos predeterminados o convierta un modelo personalizado antes de cargarlo.

¿Se explotó la falla en la naturaleza?

No se conoce explotación pública a 4 de noviembre de 2026. El aviso contiene una prueba de concepto confirmada de extremo a extremo, y la CVE no está en el catálogo KEV de CISA.

¿Qué riesgo tiene un directorio de modelos compartido?

Es ejecución de código para cada cuenta que puede escribir en él. Un usuario que puede reemplazar un archivo .pt puede ejecutar código como cada usuario o servicio que después cargue ese archivo, así que trate el directorio como una ruta de código privilegiada.

Encuentre instalaciones de Stanza vulnerables con Ostorlab

El análisis de composición de software y el SBOM de Ostorlab inventarían los paquetes Python de sus aplicaciones y señalan las versiones que resuelven realmente sus manifiestos y archivos de bloqueo. Úselo para encontrar Stanza 1.14.0 y las rutas de carga de modelos que necesitan la actualización a 1.15.0.

Referencias

  • GitHub, "Stanza: RCE via pickle deserialization in the lemmatizer" (GHSA-gh9r-c94j-cmp5, publicado el 3 de noviembre de 2026). https://github.com/advisories/GHSA-gh9r-c94j-cmp5 (verificado el 4 de noviembre de 2026)
  • CVE Program, "CVE-2026-115878" (publicado el 3 de noviembre de 2026). https://www.cve.org/CVERecord?id=CVE-2026-115878 (verificado el 4 de noviembre de 2026)
  • Stanford NLP, "Patch a security hole by using orjson to encode/decode the lemma dictionary instead of pickle" (commit b21e1c9b, escrito el 29 de julio de 2026). https://github.com/stanfordnlp/stanza/commit/b21e1c9b47b4fccff21d3b0a6c0687a19410c2f0 (verificado el 4 de noviembre de 2026)
  • Stanford NLP, "Use orjson for the lemma dictionary" (pull request 1649). https://github.com/stanfordnlp/stanza/pull/1649 (verificado el 4 de noviembre de 2026)
  • Stanford NLP, "Stanza 1.15.0" (publicado el 1 de octubre de 2026). https://github.com/stanfordnlp/stanza/releases/tag/v1.15.0 (verificado el 4 de noviembre de 2026)
  • Stanford NLP, "stanza/models/lemma/trainer.py at v1.14.0" (código en la etiqueta vulnerable). https://github.com/stanfordnlp/stanza/blob/v1.14.0/stanza/models/lemma/trainer.py (verificado el 4 de noviembre de 2026)
  • PyPI Stats, "stanza" (descargas semanales, verificadas el 4 de noviembre de 2026). https://pypistats.org/packages/stanza (verificado el 4 de noviembre de 2026)
  • CISA, "Known Exploited Vulnerabilities Catalog" (feed verificado el 4 de noviembre de 2026). https://www.cisa.gov/known-exploited-vulnerabilities-catalog (verificado el 4 de noviembre de 2026)