high

CVE

CVE-2026-12259, CVE-2026-12261

CWE

CWE-284, CWE-494

Affected Surface

  • PyPI package `nltk` versions `<= 3.9.4`
  • Applications, notebooks, and pipelines that call `nltk.download()` against less-trusted mirrors, proxies, or custom index servers
  • Shared `~/nltk_data`, system NLTK data directories, and build caches that store corpora, tokenizers, or tagger models for reuse across projects
  • Python ML and NLP services that treat downloaded corpus and model assets as trusted inputs after installation

The August 3 and August 7 NLTK disclosures matter because they are not “just” niche downloader bugs. They sit on the trust boundary between the nltk PyPI package and the corpus or model artifacts that many Python applications fetch dynamically with nltk.download(). In nltk <= 3.9.4, that boundary could be crossed in two different ways:

  1. CVE-2026-12259: _download_package() could write downloaded package bytes to disk and, for ZIP payloads, start extraction before enforcing SHA-256 or MD5 integrity checks.
  2. CVE-2026-12261: _unzip_iter() extracted ZIP members into shared corpora/ or taggers/ namespaces without enforcing that the archive members actually belonged to the package being installed.

Together, those two flaws create a classic “trust-after-write” pipeline: NLTK could make hostile package content live on disk first and only recognize the integrity problem later.

Affected package and practical scope

The affected PyPI package is:

PackageAffected rangeCurrent safe public line
nltk<= 3.9.43.10.x

This does not mean every application that imports NLTK is automatically compromised. The vulnerable path is centered on downloader usage:

  • first-party code calling nltk.download()
  • notebook or CLI bootstrap flows that auto-download corpora
  • CI jobs that refresh NLTK data during test or training setup
  • environments using custom mirrors, private indexes, intercepting proxies, or cached artifact gateways

If your deployment never downloads NLTK data at runtime and only uses pre-populated, pinned, trusted data directories, the direct exposure is much narrower.

Why the downloader path is security-critical

A lot of teams mentally separate “the Python package we installed from PyPI” from “the corpus or model files that package downloads later.” NLTK’s downloader path shows why that distinction can be dangerous.

Those artifacts are not inert documentation. They become inputs to:

  • tokenizers
  • taggers
  • corpora readers
  • training and evaluation pipelines
  • notebooks and internal tools that assume nltk_data content is trustworthy

So once the downloader installs poisoned package bytes, the result is effectively a secondary package supply chain inside the application.

The vulnerable 3.9.4 data flow

The key 3.9.4 control flow looks like this:

download package bytes from info.url
  -> write archive to download_dir/info.filename
  -> if ZIP, extract into download_dir/info.subdir
  -> make corpus/model files live on disk
  -> later, _pkg_status() checks checksum and installed state

That “later” is the whole problem.

CVE-2026-12259: integrity happened after the write path

In the vulnerable line, _download_package() drove ZIP handling before the later status check:

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

But the checksum logic lived in _pkg_status() after the file was already present:

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

That means a malicious proxy, compromised mirror, or other response-substitution position could deliver attacker-controlled ZIP bytes, let them land on disk, and potentially let them unzip into the data directory before NLTK decided the package was stale or invalid.

In application terms, the integrity control was acting more like post-install detection than pre-install prevention.

CVE-2026-12261: shared namespaces let one package overwrite another

The more interesting design flaw is that ZIP extraction did not treat the package ID as the ownership boundary. Extraction targeted the shared namespace declared by info.subdir:

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

If the package declared subdir="taggers", then the archive landed under .../taggers/. The vulnerable code did not require the archive members to stay inside the owning package directory under that namespace.

So a malicious package could present entries shaped like:

taggers/
  averaged_perceptron_tagger_eng/
    ...trusted model files...

even if the package being installed was not averaged_perceptron_tagger_eng.

The exploit chain looks like this:

attacker controls package bytes
  -> package claims a legitimate shared namespace such as taggers/
  -> archive member names target another package's directory inside that namespace
  -> extractor writes those files into the trusted package's on-disk location
  -> normal NLTK APIs load the poisoned corpus or model later

This is why the advisory describes cross-package resource and model poisoning rather than path traversal. The attacker does not need ../. The attacker only needs the downloader to confuse a shared namespace with a package-isolated root.

What the June fix changed

The upstream fix is technically valuable because it closes both halves of the chain rather than patching only one symptom.

First, NLTK started verifying the temporary download file before committing it:

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)

That changes the boundary from “write first, complain later” to “fail before publish.”

Second, _unzip_iter() gained an expected_root parameter and the caller now passes the package ID:

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

Inside the extractor, NLTK now rejects archive members whose top-level path component does not match the owning package:

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}",
        )

That is the right fix shape. It preserves the download_dir/subdir/ layout that existing status and loader code expects, but it restores the real trust boundary at the archive-member level.

Why AppSec teams should care

This is a supply-chain problem even though the word “package manager” only appears once in the flow. The real trust boundary is:

PyPI wheel for nltk
  + remote corpus/model index
  + downloader network path
  + on-disk shared cache
  + later model/corpus consumers

That matters in several common enterprise patterns:

  • shared Jupyter or notebook infrastructure
  • internal ML pipelines that hydrate corpora on demand
  • CI jobs that auto-download test corpora
  • developer workstations that reuse the same nltk_data directory across projects
  • proxies or artifact mirrors that terminate and re-originate package traffic

If an attacker can poison the NLTK data install path, they may not get immediate code execution, but they can still shape downstream classification, tagging, parsing, evaluation, or reproducibility-sensitive workflows with attacker-chosen content.

Detection and scoping

Start by identifying direct dependency use and downloader call sites:

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

Then look for hosts that store downloaded NLTK assets in shared locations:

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

Typical scoping paths include:

  • ~/nltk_data
  • /usr/share/nltk_data
  • virtualenv-local NLTK data directories
  • project caches copied into CI images

For environments where downloader traffic traverses a proxy or custom mirror, review:

  • the configured server_index_url
  • TLS interception or artifact gateway behavior
  • any custom bootstrap code that replaces official indexes or package sources

The highest-risk historical cases are machines that both:

  1. downloaded NLTK data during the vulnerable period, and
  2. later reused that data for production, evaluation, or model-development work.

Response guidance

  1. upgrade nltk to a fixed current release in the 3.10.x line;
  2. delete and repopulate cached nltk_data directories from trusted sources;
  3. review any custom downloader mirror, index, or proxy configuration;
  4. treat prior downloaded corpora and model files as untrusted if they were hydrated through affected versions in a less-trusted network path.

If a team wants a conservative recovery path, rebuilding the NLTK data cache is often better than trying to reason file-by-file about whether a poisoned corpus or tagger was ever introduced.

Bottom line

CVE-2026-12259 and CVE-2026-12261 are a good reminder that security boundaries do not stop at the PyPI artifact. In nltk <= 3.9.4, the downloader could make remote package content authoritative too early, then place that content into shared corpus and model namespaces that later application code trusted implicitly.

For AppSec teams, the lesson is straightforward: if a library downloads executable-adjacent data, model assets, or package-shaped content after install, that downloader path deserves the same threat modeling as the original package manager.

From research to remediation

Check whether this pattern exists in your codebase

Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.

References