Technology Sep 11, 2026 · 7 min read

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 Fi...

DE
DEV Community
by GUIDANCE WHITE
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)

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

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:

  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.
DE
Source

This article was originally published by DEV Community and written by GUIDANCE WHITE.

Read original article on DEV Community
Back to Discover

Reading List