| CVE ID | CVE-2026-12243 |
| Affects | NLTK (Natural Language Toolkit) ≤ 3.9.4 |
| Weakness | CWE-22 (Path Traversal) |
| CVSS 3.1 | 7.5 High — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
|
| Root cause | Percent-encoding bypasses path validation — a classic decode-after-check bug |
| Impact | Arbitrary file read |
| Fixed in | 3.10.0 |
| Backstory | An earlier fix for GitHub Issue #3504 turned out to be incomplete |
NLTK is one of the most widely used NLP libraries in the Python ecosystem, and nltk.data.load() / nltk.data.find() sit on the hot path every time a corpus or model gets loaded. Both functions turn a "resource name" string into a filesystem path, and that conversion had a validation bypass: a literal ../ gets blocked correctly, but its percent-encoded form — %2e%2e%2f or plain %2f — sails through the check and only gets decoded into a real path afterward.
Why it happened — the gap in the earlier fix (Issue #3504)
NLTK had dealt with path traversal before, and the mitigation lived as a regex filter in nltk/data.py:
# nltk/data.py (vulnerable, as of 3.9.4)
_UNSAFE_NO_PROTOCOL_RE = re.compile(
r"(?:\.\./|\.\.$|^/|\\|[A-Za-z]:[/\\])"
)
def find(resource_name, paths=None):
resource_name = normalize_resource_name(resource_name, True)
if _UNSAFE_NO_PROTOCOL_RE.search(resource_name):
raise ValueError(f"Unsafe resource path: {resource_name!r}")
# ... resource_name, having passed the check, is used as-is below
p = os.path.join(path_, url2pathname(resource_name))
if os.path.exists(p):
return FileSystemPathPointer(p)
Literal ../, a leading /, backslashes, and Windows drive letters (C:/) are all caught precisely by this regex. The problem is what gets checked. _UNSAFE_NO_PROTOCOL_RE.search() only ever runs against the raw, still-URL-encoded string. But the very next line calls the standard library's url2pathname(), which has the side effect of decoding %xx percent sequences.
In other words: validation happens on the encoded string, while the filesystem path is built from the decoded one. That gap between check-time and use-time is exactly what this vulnerability exploits — the textbook shape of a "decode-after-check" (or TOCTOU-style) flaw.
Walking through the attack string corpora/..%2f..%2f..%2fetc%2fpasswd step by step:
-
_UNSAFE_NO_PROTOCOL_REinspects the raw string...%2fcontains no literal../— it's literally the characters%,2,f— so the regex doesn't match, and the check passes. - The now-validated string is handed to
url2pathname(), which decodes%2finto/and%2einto.. - The decoding produces
corpora/../../../etc/passwd— exactly the pattern the regex was supposed to stop. -
os.path.join(nltk_data_dir, decoded_path)normalizes this and walks straight out of the intended directory, landing on/etc/passwd.
Three payloads, two different outcomes
Based on the PoC filed on huntr, comparing three payloads makes the bypass condition obvious:
-
nltk:../../../etc/passwd— a literal traversal._UNSAFE_NO_PROTOCOL_REcatches../immediately, aValueErroris raised, and the request is blocked as designed. -
nltk:%2fetc%2fpasswd— a percent-encoded leading slash. As a string it matches none of^/,../, backslash, or a drive letter, so it sails through the check.url2pathname()decodes it to/etc/passwd. -
nltk:corpora/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd—%2e%2eis a different string from.., so it passes the same check. After decoding, it walks five levels up and out.
A related variant, nltk:%2fproc%2fself%2fenviron, targets the process environment file directly. /proc/self/environ frequently leaks API keys, database credentials, and cloud secrets that were passed in as environment variables, which makes it a particularly attractive target once the primary check is bypassed.
There was a second layer of defense — but it isn't enforced by default
NLTK also ships a nltk.pathsec module meant to re-check the path right before the file is actually opened. The catch: this check isn't enforced unless you explicitly opt in.
# typical pattern inside nltk/pathsec.py
ENFORCE = os.environ.get('NLTK_PATHSEC_ENFORCE', '').lower() in ('1', 'true', 'yes')
def validate_something(path):
if is_violation(path):
if ENFORCE:
raise SecurityError('...') # only raises if the env var is set
else:
warnings.warn('...', RuntimeWarning) # default: warn and keep going
ENFORCE stays False unless the NLTK_PATHSEC_ENFORCE environment variable is explicitly set. So out of the box, a dangerous path only produces a RuntimeWarning — the open() call itself still goes through. The one backstop you might expect to catch a bypassed regex check ends up being little more than a log line unless you turn it on yourself.
Who's affected
This bug matters for any application that passes externally controlled input into nltk.data.load() or nltk.data.find() as the resource name:
- NLP web services or APIs that let users specify a corpus/model name
- Hosted notebook services that execute user-supplied code
- Multi-tenant ML pipelines that parameterize resource identifiers per tenant
- CI/CD pipelines that build resource paths from external input
The CVSS vector (C:H/I:N/A:N) tells the story: this is a confidentiality-only issue. Nothing gets modified or taken down — it's arbitrary read access to anything the process's user can read. Beyond /etc/passwd and /proc/self/environ, that includes application config files, SSH private keys, and any locally cached cloud-metadata responses.
The fix in 3.10.0
3.10.0 targets the root cause directly — decode-then-check — by adding an _assert_no_encoded_bypass() function that re-runs the same validation against the decoded form of the string.
from urllib.parse import unquote
def _assert_no_encoded_bypass(name, error_label=None):
"""
Reject `name` if its URL-decoded form contains an unsafe pattern.
unquote() is applied exactly once. url2pathname() itself only does a
single decode pass, so this mirrors that behavior; decoding
repeatedly would change the meaning of legitimately encoded values
like "%2520" (a literal "%20").
"""
decoded = unquote(name)
if decoded != name and _UNSAFE_NO_PROTOCOL_RE.search(decoded):
label = name if error_label is None else error_label
raise ValueError(f"Unsafe resource path: {label!r}")
def _reject_unsafe_no_protocol(resource_url):
if _UNSAFE_NO_PROTOCOL_RE.search(resource_url):
raise ValueError(f"Unsafe resource path: {resource_url!r}")
# re-check the decoded form against the same policy
_assert_no_encoded_bypass(resource_url)
Three things matter here:
-
The same regex is reused, not duplicated. Rather than inventing a new blocklist,
_UNSAFE_NO_PROTOCOL_REis applied to both the raw string and itsunquote()-decoded form. There's only one policy to keep in sync. -
Decoding happens exactly once. Matching
url2pathname()'s single decode pass avoids breaking legitimately double-encoded values such as%2520(a literal%20), which repeated decoding would otherwise mangle. -
Every entry point calls it.
_reject_unsafe_no_protocol(), thenltk:scheme handling insidenormalize_resource_url(), and the defense-in-depth check insidefind()all now call_assert_no_encoded_bypass()— so there's no remaining code path where a resource name turns into a file path without the decoded check running.
What to do about it
- Upgrade to NLTK 3.10.0 or later. This is the real fix.
- If an immediate upgrade isn't possible, set
NLTK_PATHSEC_ENFORCE=trueto activate thepathseclayer's hard block. Treat this as a stopgap, not a substitute for patching — it's a mitigation, not a root-cause fix. - Audit any code path where a resource name passed to
nltk.data.load()/nltk.data.find()originates from user input. An application-level allowlist of permitted corpus/model names is a reasonable defense-in-depth measure on top of the library fix.



Top comments (0)