At a glance#
| CVE | CVE-2026-12261 |
| Component | nltk.downloader (nltk/downloader.py) |
| Affected | nltk <= 3.9.4 |
| Weakness | CWE-284 (Improper Access Control), CWE-494 (Download Without Integrity Check), CWE-367 (TOCTOU) |
| Upstream fix | PR nltk/nltk#3625, merged 26 Jun 2026 |
| Disclosed | 7 Aug 2026 |
Summary#
NLTK’s data downloader had two problems that combined into something nasty. First, when it extracted a package archive, it dropped files into shared namespace folders like corpora/ and taggers/ without ever checking that the files inside the archive actually belonged to the package being installed. Second, it validated the package’s integrity (size and SHA-256) only after the archive had already been written and extracted.
Put together: a malicious package could overwrite another package’s trusted models or corpora, and by the time any integrity check complained, the poisoned files were already live. The changes survived interpreter restarts and took effect through completely ordinary NLTK API calls. No path traversal tricks needed. No warnings shown.
I found it, reported it to the NLTK team, and the fix was merged upstream.
Background: how nltk.download() works#
Almost every NLTK user has run nltk.download('punkt') or similar. Behind that call, the downloader fetches a zip archive for the requested package and extracts it into a subdirectory of your NLTK data folder. Each package declares a subdir, so tagger models land in taggers/, corpora in corpora/, and so on.
The key detail: those subdirectories are shared namespaces. Every package with subdir='taggers' extracts into the same taggers/ folder. That is fine as long as each package only writes its own files. There was nothing enforcing that.
The bug#
Two flaws, both in the download-and-install path:
1. No ownership check during extraction.
_unzip_iter() extracted archive members into the shared subdir namespace based purely on the package’s declared subdir. It never verified that a member’s top-level path actually belonged to the package being installed. So a package declaring subdir='taggers' could ship a member named taggers/<some other package's model file>, and the downloader would happily write it straight over the victim’s file. This was not a path traversal bug. The paths were perfectly normal. The downloader just never asked “does this file belong to you?”
2. Integrity validated after the damage was done.
_pkg_status() compared the downloaded file’s size and SHA-256 checksum, but only after the archive had been committed and extracted. If the checksum failed, the user saw an error, but the extracted files were already sitting in the shared namespace, already active. A check that runs after the payload is live is not a security control. It is a notification.
Proof of concept#
The attack flow, end to end:
# --- Attacker side ---
# Build a zip for a malicious package, "helpful_tagger_pack".
# The package declares subdir='taggers'.
# Inside the zip, include a member that belongs to someone else:
# taggers/averaged_perceptron_tagger/averaged_perceptron_tagger.pickle
# (a trojaned copy of a trusted, widely used model)
# Host it on a poisoned package index or a compromised mirror.
# --- Victim side ---
import nltk
nltk.download('helpful_tagger_pack') # looks like a normal, useful download
# _unzip_iter() extracts every member into <nltk_data>/taggers/
# without checking the members belong to 'helpful_tagger_pack'.
# The trusted averaged_perceptron_tagger model is now overwritten.
# _pkg_status() runs its size/sha256 check AFTER this point.
# Even if it fails, the poisoned model is already on disk and already live.
from nltk.tag import pos_tag
from nltk.tokenize import word_tokenize
pos_tag(word_tokenize("This is now running on poisoned weights."))
# No error. No warning. The API works exactly as before.
What makes this ugly is how quiet it is. The victim ran one normal-looking download. Everything after that behaves normally, except the model weights are whatever the attacker shipped. And because NLTK loads these files from disk on every run, the poisoning persists across fresh interpreter restarts.
Impact#
Who should care:
- ML pipelines and research environments. A poisoned tagger or corpus silently changes model outputs. In reproducibility-sensitive work, that corrupts results without leaving an obvious trace.
- Shared and multi-user machines. NLTK data directories are often shared. One user’s download poisons the well for everyone on the box.
- Supply chain angle. The realistic trigger is a malicious package on a package index (typosquat a popular data package name) or a compromised mirror. The victim only has to run
nltk.download()once.
What the attacker gets is not code execution directly. It is something arguably worse for this ecosystem: silent, persistent control over the data that models and analyses run on, with the tampering invisible to normal API usage.
Severity context: Snyk’s entry lists an EPSS score of 0.19% (10th percentile), so observed exploitation probability is low. The damage scenario is narrow but the blast radius inside it is wide, because trust in downloaded NLTK data is basically absolute.
The fix#
Upstream fixed this in PR #3625 (“fix: enforce package ownership during ZIP extraction”), merged 26 Jun 2026. The changes:
_unzip_iter()takes a newexpected_rootparameter. Both validation phases now reject any archive member whose top-level path component does not match the owning package’s id. Cross-package writes are blocked with a clear error before any extraction happens.- Size and SHA-256 are now validated on the
.tmpfile beforeos.replace()commits it. On mismatch, the install fails closed and the temp file is removed. - Legacy MD5 checksums are validated before extraction too.
- The on-disk layout (
zipdir = download_dir/subdir/) is unchanged, so_pkg_status()and the data loaders see no difference. - Regression coverage was added (
test_downloader.py,test_downloader_atomic.py). All 5 existing downloader tests pass, plus the new security tests: cross-package overwrite blocked, integrity rejected before extraction, legitimate installs still work, victim package status unchanged after a blocked attack.
At the time of public disclosure, Snyk’s entry noted no fixed release version was listed yet. The fix itself was in develop from late June 2026.
Timeline#
| Date | Event |
|---|---|
| 26 Jun 2026 | Upstream fix merged (PR nltk/nltk#3625) |
| 7 Aug 2026 | CVE-2026-12261 record published, public disclosure |
What I took away from this#
The interesting bug here was not exotic. Nobody audits downloader code; it is plumbing. But plumbing that writes files other people trust is security-critical code, full stop. Two lessons I keep coming back to:
- An integrity check that runs after installation is theater. Order matters more than the strength of the hash. SHA-256 is a fine algorithm; running it after the files are live makes it decorative.
- Shared namespaces need ownership rules. The moment two packages write into the same folder, you need something that answers “which files belong to whom.” NLTK had nothing, so the answer was “whoever writes last.”
References#
- CVE-2026-12261 record (cve.org)
- Fix PR: enforce package ownership during ZIP extraction (nltk/nltk#3625)
- Fix commit (
7a5740af) - Snyk entry: SNYK-PYTHON-NLTK-18592338