A bookworm librarian who refuses to fetch `../secret` but happily hands over `%2e%2e/secret`
NLTK's nltk.data.load() and nltk.data.find() look up resource names (corpora, models, taggers) and resolve them through url2pathname(). The validation regex _UNSAFE_NO_PROTOCOL_RE strips out literal ../ sequences — but it runs BEFORE percent-decoding. So ..%2fsecret or %2e%2e/etc/passwd sails past the check, then url2pathname() decodes it into a real traversal. The original fix for Issue #3504 landed in 3.9.4; this CVE-2026-12243 is the *incomplete-fix* follow-on, meaning 3.9.4 is still vulnerable.
Vendor scored this HIGH (7.5) with AV:N/AC:L/PR:N/UI:N/C:H. That vector is technically defensible — read-anywhere on the host process — but the network-exploitable framing is misleading. NLTK is a *library*, not a network service. The 'attacker' has to be someone who controls the resource-name string passed into nltk.data.load(). In 95% of deployments that string is a hard-coded constant like 'tokenizers/punkt'. The HIGH rating only holds if you've built a web app that accepts user-supplied corpus names — which is unusual.
3 steps from start to impact.
Attacker finds an app calling nltk.data.load() with user-controlled input
nltk.data.load(), nltk.data.find(), or nltk.data.retrieve(). This is the gating prerequisite — without it, the vuln is unreachable. Realistic targets: NLP-as-a-service web apps, custom Jupyter dashboards, internal model-selection UIs.- Application uses NLTK ≤ 3.9.4
- Resource-name parameter is influenced by HTTP input or user-uploaded config
- Vast majority of NLTK usage is in batch scripts and notebooks with hardcoded corpus names
- Most production NLP services use spaCy, HuggingFace, or fastText — NLTK is largely an academic/teaching toolkit
nltk.data.* callsCraft percent-encoded traversal payload
.. with %2e%2e (or mixed-case variants %2E%2E) and / with %2f if needed. Payload like %2e%2e/%2e%2e/%2e%2e/etc/passwd or ..%2fetc%2fpasswd will pass _UNSAFE_NO_PROTOCOL_RE validation, then url2pathname() decodes it into ../../../etc/passwd.- No outer input sanitization in the host application
- Application doesn't enforce an allow-list of resource names
- Application-level allow-lists (very common in mature code) defeat this entirely
- Frameworks like Django/Flask that URL-decode params upstream may collapse the encoding before NLTK sees it
File read at process privilege
.env, AWS credentials in ~/.aws/credentials, SSH keys, application source, database config. Confidentiality only — no write, no execute (that's the separate CVE-2026-33236 downloader bug).- Process runs as a user with read access to the target file
- Containerized deployments (the modern default) limit blast radius to the container's filesystem
- Least-privilege service accounts mean no access to host secrets
The supporting signals.
| In-the-wild exploitation | None observed. No GreyNoise tags, no honeypot hits, no incident reports as of 2026-06-30 (disclosed same day). |
|---|---|
| Proof-of-concept | PoC payload strings published directly in GitHub Issue #3504 by the original reporter. Trivial to weaponize — single-line curl. |
| EPSS | Not yet scored (disclosed today). Comparable library-level path-traversal CVEs trend <0.5% / <30th percentile. |
| KEV status | Not listed. CISA KEV requires evidence of active exploitation; none exists. |
| CVSS vector interpretation | CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N — read-only file disclosure, scored network-reachable because NLTK is callable from web handlers. Real reachability depends on the host app. |
| Affected versions | NLTK ≤ 3.9.4. Note 3.9.4 was itself the *attempted* fix for Issue #3504, which was incomplete. |
| Fixed version | Pending — track nltk/nltk releases. No upstream patched build at time of writing; monitor for 3.9.5 or 3.10. |
| Exposure data | NLTK is shipped in ~1.3M PyPI installs/month. Web-exposed surface is a tiny fraction — Shodan/Censys cannot enumerate library usage directly. |
| Disclosure date | 2026-06-30 (today). Pre-patch disclosure — defenders are ahead of attackers for now. |
| Reporter | Same researcher as original Issue #3504 (see GitHub thread). |
noisgate verdict.
Downgraded from vendor HIGH (7.5) to MEDIUM (5.3) because the single most decisive factor is reachability: this is a library bug whose attack surface requires an application to pass attacker-controlled strings into nltk.data.load() — a code pattern that exists in well under 1% of NLTK deployments. The bug is real and the bypass is trivial, but the real-world exposure population does not justify a HIGH bucket.
Why this verdict
- Reachability gate is brutal: the CVE only fires when user input reaches
nltk.data.load/find/retrieve. In typical NLP pipelines the resource name is a hardcoded constant. Vendor CVSS assumes the worst-case integration; reality is far narrower. - Confidentiality-only, no write/exec:
C:H/I:N/A:Nmeans file disclosure only. The companion CVE-2026-33236 downloader bug is the scary one (arbitrary file *overwrite*); this one is read-only. - Role multiplier — NLP/ML worker tier: chain succeeds and yields file read at process privilege. On a hardened, containerized ML inference pod with a least-privilege service account, blast radius is one container's secrets. NLTK is not canonically deployed on identity, hypervisor, CA, backup, or kernel-mode roles, so no CRITICAL floor triggers.
- Role multiplier — dev/notebook/Jupyter tier: chain succeeds but the 'attacker' is usually the notebook author themselves; not a meaningful threat model.
- Pre-disclosure parity: no upstream patch yet (as of 2026-06-30), but also no public weaponization beyond the PoC strings in the issue thread. Defenders are not behind.
Why not higher?
Not HIGH because NLTK is a library, not a network-exposed service, and the prerequisite that user input flows into a resource-name parameter is uncommon. There is no KEV listing, no active exploitation, no I/A impact, and no high-value-role deployment pattern (NLTK is not used on identity, hypervisor, backup, or CA tiers).
Why not lower?
Not LOW because the bypass is trivially exploitable where it *is* reachable, the PoC is public, and C:H file read can disclose .env secrets and credentials that pivot to lateral movement. For shops that DO have NLP web apps consuming user-named resources, this is a real same-week problem.
What to do — in priority order.
- Audit every call site of
nltk.data.load,nltk.data.find,nltk.data.retrieve— Grep your codebase:rg 'nltk\.data\.(load|find|retrieve)'. Any call where the first argument is not a string literal or a value from an allow-list is the exposure. Complete the audit within the noisgate MEDIUM mitigation window — but for any reachable call sites, treat as HIGH and mitigate within 30 days. - Enforce a resource-name allow-list at the application boundary — Wrap NLTK calls in a thin shim that rejects anything not in a hardcoded set (e.g.
{'punkt', 'stopwords', 'averaged_perceptron_tagger'}). This eliminates the vuln regardless of NLTK version. Ship within 30 days for any reachable call site. - Pre-decode and validate input strings — Call
urllib.parse.unquote()recursively until idempotent, then reject any string containing..or starting with/. Apply at the request handler, before the value ever reaches NLTK. - Drop the NLTK worker into a read-only mount with no secrets — Container-level fix: mount only the NLTK data directory and the model files. No
.env, no~/.aws, no/etc/shadowreachable. Even successful traversal returns nothing useful. - Pin NLTK and monitor for 3.9.5+ — Lock
nltk==3.9.4inrequirements.txtto prevent surprise upgrades, then subscribe to nltk/nltk releases. Apply the vendor patch on release within the noisgate remediation window.
- WAF rules that only block literal
../— that's exactly the bypass; double-encoded payloads sail through. - Upgrading to 3.9.4 — 3.9.4 IS the vulnerable version; the original Issue #3504 fix was incomplete.
- Network egress controls — this is a file-read bug, not a callback; data exits via the same HTTP response the attacker called.
- EDR on the NLTK worker host — file reads by the legitimate Python process look like normal activity unless you have FIM on specific sensitive paths.
Crowdsourced verification payload.
Run on any host or container that imports NLTK, as the same user the application runs as. Invoke with python3 check_cve_2026_12243.py — no arguments. No privileges beyond the application user are needed. Outputs VULNERABLE, PATCHED, or UNKNOWN and exits 1/0/2 respectively.
#!/usr/bin/env python3
# check_cve_2026_12243.py
# Detects NLTK installations vulnerable to CVE-2026-12243
# (percent-encoded path traversal in nltk.data.load/find).
# Exit codes: 0 = PATCHED, 1 = VULNERABLE, 2 = UNKNOWN
import sys
import tempfile
import os
try:
import nltk
import nltk.data
except ImportError:
print('UNKNOWN: nltk not installed in this interpreter')
sys.exit(2)
ver = getattr(nltk, '__version__', 'unknown')
print(f'Detected NLTK version: {ver}')
# Known-vulnerable: <= 3.9.4. Adjust upper bound when fix ships.
try:
from packaging.version import Version
is_vuln_version = Version(ver) <= Version('3.9.4')
except Exception:
is_vuln_version = ver.startswith(('3.9.', '3.8.', '3.7.', '3.6.', '3.5.', '3.4.', '3.3.', '3.2.', '3.1.', '3.0.'))
# Behavioral confirmation: try the bypass against a tempfile.
with tempfile.NamedTemporaryFile('w', delete=False, suffix='.canary') as f:
f.write('CVE-2026-12243-canary')
canary_path = f.name
try:
# Build a percent-encoded relative path that traverses to the canary.
abs_dir = os.path.dirname(canary_path)
fname = os.path.basename(canary_path)
# Encode '..' as %2e%2e and '/' as %2f to bypass naive checks.
encoded = '%2e%2e%2f' * (abs_dir.count(os.sep) + 2) + abs_dir.lstrip(os.sep).replace(os.sep, '%2f') + '%2f' + fname
try:
nltk.data.find(encoded)
behavior_vuln = True
except LookupError:
behavior_vuln = False
except Exception:
behavior_vuln = False
finally:
try:
os.unlink(canary_path)
except OSError:
pass
if is_vuln_version or behavior_vuln:
print('VULNERABLE: nltk <= 3.9.4 — percent-encoded traversal bypass present')
sys.exit(1)
print('PATCHED: nltk version above known-vulnerable range')
sys.exit(0)
If you remember one thing.
rg 'nltk\.data\.(load|find|retrieve)' --type py across every service repo. If every call site passes a string literal or value from a hardcoded set, you can route this to the noisgate remediation SLA of ≤ 365 days — there is no mitigation SLA at MEDIUM, and the bug is unreachable. If ANY call site takes attacker-influenced input, escalate that service to HIGH and apply the noisgate mitigation SLA of ≤ 30 days: ship the allow-list shim or unquote+validate fix this sprint. Pin nltk==3.9.4 to prevent drift, subscribe to the nltk/nltk release feed, and apply the upstream patch when 3.9.5 ships. No KEV, no active exploitation — do not pull the fire alarm.Sources
- OffSeq Threat Radar — CVE-2026-12243
- GitHub Issue #3504 — original path traversal report
- GitHub Issue #3566 — advisory missing patched version note
- GHSA-469j-vmhf-r6v7 — related NLTK downloader AFO advisory
- GitHub Advisory CVE-2026-33236 — NLTK downloader path traversal
- NLTK ChangeLog (develop branch)
- nltk/nltk repository
- CWE-22: Path Traversal
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.