DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-12243 — How a Percent-Encoded Slash Bypasses NLTK's Path Traversal Guard

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. _UNSAFE_NO_PROTOCOL_RE inspects the raw string. ..%2f contains no literal ../ — it's literally the characters %, 2, f — so the regex doesn't match, and the check passes.
  2. The now-validated string is handed to url2pathname(), which decodes %2f into / and %2e into ..
  3. The decoding produces corpora/../../../etc/passwd — exactly the pattern the regex was supposed to stop.
  4. 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_RE catches ../ immediately, a ValueError is 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%2e is 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Three things matter here:

  1. The same regex is reused, not duplicated. Rather than inventing a new blocklist, _UNSAFE_NO_PROTOCOL_RE is applied to both the raw string and its unquote()-decoded form. There's only one policy to keep in sync.
  2. 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.
  3. Every entry point calls it. _reject_unsafe_no_protocol(), the nltk: scheme handling inside normalize_resource_url(), and the defense-in-depth check inside find() 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=true to activate the pathsec layer'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)