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:
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.CVE-2026-12261:_unzip_iter()extracted ZIP members into sharedcorpora/ortaggers/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:
| Package | Affected range | Current safe public line |
|---|---|---|
nltk | <= 3.9.4 | 3.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_datacontent 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_datadirectory 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:
- downloaded NLTK data during the vulnerable period, and
- later reused that data for production, evaluation, or model-development work.
Response guidance
- upgrade
nltkto a fixed current release in the3.10.xline; - delete and repopulate cached
nltk_datadirectories from trusted sources; - review any custom downloader mirror, index, or proxy configuration;
- 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.