alta

CVE

CVE-2026-12259, CVE-2026-12261

CWE

CWE-284, CWE-494

Sistemas afectados

  • Versiones del paquete PyPI `nltk` `<= 3.9.4`
  • Aplicaciones, notebooks y pipelines que llaman a `nltk.download()` contra mirrors, proxies o servidores de índice personalizados menos confiables
  • Directorios compartidos `~/nltk_data`, directorios de datos NLTK del sistema y cachés de compilación que almacenan corpus, tokenizadores o modelos de etiquetado para reutilizarlos entre proyectos
  • Servicios de ML y NLP en Python que tratan los activos de corpus y modelos descargados como entradas confiables tras la instalación

Las divulgaciones de NLTK del 3 y 7 de agosto importan porque no son “solo” fallos de descargador de nicho. Se sitúan en el límite de confianza entre el paquete PyPI nltk y los artefactos de corpus o de modelo que muchas aplicaciones Python obtienen dinámicamente con nltk.download(). En nltk <= 3.9.4, ese límite podía cruzarse de dos formas distintas:

  1. CVE-2026-12259: _download_package() podía escribir en disco los bytes del paquete descargado y, para cargas útiles ZIP, comenzar la extracción antes de aplicar las comprobaciones de integridad SHA-256 o MD5.
  2. CVE-2026-12261: _unzip_iter() extraía miembros ZIP a espacios de nombres compartidos corpora/ o taggers/ sin exigir que los miembros del archivo pertenecieran realmente al paquete que se estaba instalando.

Juntos, estos dos fallos crean un pipeline clásico de “confianza después de la escritura”: NLTK podía hacer que el contenido hostil del paquete cobrara vida en disco primero y solo reconocer el problema de integridad después.

Paquete afectado y alcance práctico

El paquete de PyPI afectado es:

PaqueteRango afectadoLínea pública segura actual
nltk<= 3.9.43.10.x

Esto no significa que toda aplicación que importe NLTK esté automáticamente comprometida. La ruta vulnerable se centra en el uso del descargador:

  • código propio que llama a nltk.download()
  • flujos de arranque de notebooks o CLI que descargan corpus automáticamente
  • trabajos de CI que actualizan datos de NLTK durante la configuración de pruebas o entrenamiento
  • entornos que usan mirrors personalizados, índices privados, proxies que interceptan tráfico o gateways de artefactos en caché

Si tu despliegue nunca descarga datos de NLTK en tiempo de ejecución y solo usa directorios de datos preinstalados, fijados y confiables, la exposición directa es mucho más limitada.

Por qué la ruta del descargador es crítica para la seguridad

Muchos equipos separan mentalmente “el paquete Python que instalamos desde PyPI” de “los archivos de corpus o modelo que ese paquete descarga después”. La ruta del descargador de NLTK muestra por qué esa distinción puede ser peligrosa.

Esos artefactos no son documentación inerte. Se convierten en entradas de:

  • tokenizadores
  • etiquetadores
  • lectores de corpus
  • pipelines de entrenamiento y evaluación
  • notebooks y herramientas internas que asumen que el contenido de nltk_data es confiable

Así que, una vez que el descargador instala bytes de paquete envenenados, el resultado es efectivamente una cadena de suministro de paquetes secundaria dentro de la aplicación.

El flujo de datos vulnerable en la 3.9.4

El flujo de control clave en la 3.9.4 se ve así:

descargar los bytes del paquete desde info.url
  -> escribir el archivo en download_dir/info.filename
  -> si es ZIP, extraer en download_dir/info.subdir
  -> hacer que los archivos de corpus/modelo cobren vida en disco
  -> más tarde, _pkg_status() comprueba el checksum y el estado instalado

Ese “más tarde” es todo el problema.

CVE-2026-12259: la integridad ocurría después de la ruta de escritura

En la línea vulnerable, _download_package() dirigía el manejo de ZIP antes de la comprobación de estado posterior:

if info.filename.endswith(".zip"):
    zipdir = os.path.join(download_dir, info.subdir)
    if info.unzip or os.path.exists(os.path.join(zipdir, info.id)):
        for msg in _unzip_iter(filepath, zipdir, verbose=False):
            yield msg

Pero la lógica de checksum vivía en _pkg_status() después de que el archivo ya estuviera presente:

sha256_checksum = getattr(info, "sha256_checksum", None)
if sha256_checksum:
    if sha256_hexdigest(filepath) != sha256_checksum:
        return self.STALE
else:
    md5_checksum = getattr(info, "checksum", None)
    if md5_hexdigest(filepath) != md5_checksum:
        return self.STALE

Eso significa que un proxy malicioso, un mirror comprometido u otra posición de sustitución de respuestas podía entregar bytes ZIP controlados por el atacante, dejarlos aterrizar en disco y potencialmente permitir que se descomprimieran en el directorio de datos antes de que NLTK decidiera que el paquete estaba obsoleto o no era válido.

En términos de aplicación, el control de integridad actuaba más como detección posterior a la instalación que como prevención previa a la instalación.

CVE-2026-12261: los espacios de nombres compartidos permitían que un paquete sobrescribiera a otro

El fallo de diseño más interesante es que la extracción ZIP no trataba el ID del paquete como el límite de propiedad. La extracción apuntaba al espacio de nombres compartido declarado por info.subdir:

zipdir = os.path.join(download_dir, info.subdir)
for msg in _unzip_iter(filepath, zipdir, verbose=False):
    yield msg

Si el paquete declaraba subdir="taggers", entonces el archivo aterrizaba bajo .../taggers/. El código vulnerable no exigía que los miembros del archivo permanecieran dentro del directorio del paquete propietario bajo ese espacio de nombres.

Así que un paquete malicioso podía presentar entradas con la forma:

taggers/
  averaged_perceptron_tagger_eng/
    ...archivos de modelo confiables...

incluso si el paquete que se estaba instalando no era averaged_perceptron_tagger_eng.

La cadena de explotación se ve así:

el atacante controla los bytes del paquete
  -> el paquete declara un espacio de nombres compartido legítimo como taggers/
  -> los nombres de los miembros del archivo apuntan al directorio de otro paquete dentro de ese espacio de nombres
  -> el extractor escribe esos archivos en la ubicación en disco del paquete confiable
  -> las APIs normales de NLTK cargan después el corpus o modelo envenenado

Por eso el aviso describe envenenamiento de recursos y modelos entre paquetes en lugar de path traversal. El atacante no necesita ../. El atacante solo necesita que el descargador confunda un espacio de nombres compartido con una raíz aislada por paquete.

Qué cambió la corrección de junio

La corrección upstream es técnicamente valiosa porque cierra ambas mitades de la cadena en lugar de parchear solo un síntoma.

Primero, NLTK empezó a verificar el archivo de descarga temporal antes de confirmarlo:

sha256_checksum = getattr(info, "sha256_checksum", None)
if sha256_checksum:
    if sha256_hexdigest(tmp_filepath) != sha256_checksum:
        _safe_remove(tmp_filepath)
        yield ErrorMessage(info, f"Integrity check failed for {info.id!r}: sha256 mismatch")
        return

validate_path(
    filepath,
    context="Downloader._download_package",
    required_root=download_dir,
)
os.replace(tmp_filepath, filepath)

Eso cambia el límite de “escribir primero, quejarse después” a “fallar antes de publicar”.

Segundo, _unzip_iter() obtuvo un parámetro expected_root y el llamador ahora pasa el ID del paquete:

for msg in _unzip_iter(
    filepath, zipdir, verbose=False, expected_root=info.id
):
    yield msg

Dentro del extractor, NLTK ahora rechaza los miembros del archivo cuyo componente de ruta de nivel superior no coincide con el paquete propietario:

if expected_root is not None:
    top = member.replace("\\", "/").lstrip("/").split("/")[0]
    if top != expected_root:
        yield ErrorMessage(
            filename,
            f"Cross-package overwrite blocked: member {member!r} "
            f"is outside the owning package directory {expected_root!r}",
        )

Esa es la forma correcta de la corrección. Preserva el diseño download_dir/subdir/ que el código de estado y de carga existente espera, pero restaura el verdadero límite de confianza a nivel de miembro de archivo.

Por qué debería importar a los equipos de AppSec

Este es un problema de cadena de suministro aunque la frase “gestor de paquetes” solo aparezca una vez en el flujo. El verdadero límite de confianza es:

wheel de PyPI para nltk
  + índice remoto de corpus/modelos
  + ruta de red del descargador
  + caché compartida en disco
  + consumidores posteriores de modelos/corpus

Eso importa en varios patrones empresariales comunes:

  • infraestructura compartida de Jupyter o notebooks
  • pipelines internos de ML que hidratan corpus a demanda
  • trabajos de CI que descargan automáticamente corpus de prueba
  • estaciones de trabajo de desarrolladores que reutilizan el mismo directorio nltk_data entre proyectos
  • proxies o mirrors de artefactos que terminan y reoriginan el tráfico de paquetes

Si un atacante puede envenenar la ruta de instalación de datos de NLTK, puede que no obtenga ejecución de código inmediata, pero aun así puede moldear flujos de clasificación, etiquetado, análisis, evaluación o sensibles a la reproducibilidad con contenido elegido por el atacante.

Detección y delimitación

Empieza identificando el uso de dependencias directas y los puntos de llamada del descargador:

python3 -m pip show nltk
rg -n 'nltk\\.download\\(' .

Luego busca hosts que almacenan activos de NLTK descargados en ubicaciones compartidas:

python3 - <<'PY'
import nltk
print(nltk.data.path)
PY

Las rutas de delimitación típicas incluyen:

  • ~/nltk_data
  • /usr/share/nltk_data
  • directorios de datos de NLTK locales del virtualenv
  • cachés de proyecto copiadas en imágenes de CI

Para entornos donde el tráfico del descargador atraviesa un proxy o mirror personalizado, revisa:

  • el server_index_url configurado
  • el comportamiento de interceptación TLS o del gateway de artefactos
  • cualquier código de arranque personalizado que sustituya índices o fuentes de paquetes oficiales

Los casos históricos de mayor riesgo son las máquinas que:

  1. descargaron datos de NLTK durante el periodo vulnerable, y
  2. reutilizaron después esos datos para trabajos de producción, evaluación o desarrollo de modelos.

Guía de respuesta

  1. actualiza nltk a una versión corregida actual en la línea 3.10.x;
  2. elimina y repuebla los directorios en caché de nltk_data desde fuentes confiables;
  3. revisa cualquier mirror, índice o configuración de proxy personalizado del descargador;
  4. trata los corpus y archivos de modelo descargados previamente como no confiables si se hidrataron a través de versiones afectadas en una ruta de red menos confiable.

Si un equipo quiere una ruta de recuperación conservadora, reconstruir la caché de datos de NLTK suele ser mejor que intentar razonar archivo por archivo sobre si alguna vez se introdujo un corpus o etiquetador envenenado.

Conclusión

CVE-2026-12259 y CVE-2026-12261 son un buen recordatorio de que los límites de seguridad no terminan en el artefacto de PyPI. En nltk <= 3.9.4, el descargador podía dar autoridad demasiado pronto al contenido remoto de paquetes, y luego colocar ese contenido en espacios de nombres compartidos de corpus y modelos en los que el código de aplicación posterior confiaba implícitamente.

Para los equipos de AppSec, la lección es sencilla: si una biblioteca descarga datos adyacentes a lo ejecutable, activos de modelo o contenido con forma de paquete después de la instalación, esa ruta de descarga merece el mismo modelado de amenazas que el gestor de paquetes original.

De la investigación a la remediación

Comprueba si este patrón existe en tu código

Convierte esta investigación en un flujo de remediación. Analiza dependencias y manifiestos para detectar riesgos similares en la cadena de suministro y prioriza las correcciones con contexto de alcanzabilidad.

Referencias