Nov 13, 2026·9 min read·4 visits
NLTK versions 3.10.0 through 3.10.3 fail to resolve symbolic links when initializing the YCOECorpusReader 'psd' and 'pos' subdirectories. Processing an untrusted corpus archive with symlinks allows unauthorized read access to files outside the designated corpus root. Upgrade to NLTK 3.10.4 to enforce realpath containment checks.
CVE-2026-126304 is a symlink path traversal vulnerability (CWE-59) in the Natural Language Toolkit (NLTK) versions 3.10.0 prior to 3.10.4. The YCOECorpusReader class constructs internal sub-readers for the 'psd' and 'pos' subdirectories using FileSystemPathPointer.join(), which relies exclusively on lexical string-prefix validation rather than canonical path resolution. An attacker who supplies a crafted YCOE corpus containing symbolic links in place of the 'psd' or 'pos' subdirectories can bypass directory confinement and read arbitrary files from sibling corpora or sensitive directories within the NLTK data sandbox.
The Natural Language Toolkit (NLTK) provides standardized corpus readers to parse and process linguistic datasets stored on the local filesystem. Among these is the YCOECorpusReader class, defined in nltk/corpus/reader/ycoe.py, which manages the York-Toronto-Helsinki Parsed Corpus of Old English Prose (YCOE). This reader orchestrates access to two distinct dataset representations located in subdirectories beneath the corpus root: syntactic bracket-parse trees stored in the psd directory and part-of-speech tagged text stored in the pos directory.
In NLTK versions 3.10.0 through 3.10.3, YCOECorpusReader initializes its internal sub-readers without verifying whether the psd and pos paths resolve to locations inside the parent corpus root on the underlying filesystem. Specifically, the constructor invokes self.root.join("psd") and self.root.join("pos") to construct path pointers for YCOEParseCorpusReader and TaggedCorpusReader. While FileSystemPathPointer.join() performs a lexical check to verify that the resulting path string starts with the parent root prefix, it does not resolve symbolic links.
This discrepancy introduces an Improper Link Resolution Before File Access vulnerability, tracked as CWE-59 (with secondary classification under CWE-22, Path Traversal). In applications or machine learning pipelines that ingest corpus directories or extract corpus archives from external sources, an attacker can substitute the psd or pos subdirectories with symbolic links pointing outside the corpus boundary.
When the application subsequently invokes standard reader interface methods—such as words(), sents(), tagged_words(), or parsed_sents()—the sub-readers traverse the symbolic link and enumerate files in the target directory. This allows an unauthenticated attacker to read unauthorized files from sibling corpora, temporary workspaces, or other readable locations accessible within the NLTK path security sandbox.
The root cause of CVE-2026-126304 lies in the architectural boundary between lexical path representation in nltk.data.FileSystemPathPointer and canonical filesystem verification in nltk.pathsec. When YCOECorpusReader.__init__() is called, the root argument is normalized into a FileSystemPathPointer instance by the base CorpusReader class. The YCOECorpusReader constructor then derives the paths for its two child readers using self.root.join("psd") and self.root.join("pos").
The FileSystemPathPointer.join() method constructs a new path by joining the base path string with the relative identifier using standard path manipulation functions. To prevent basic dot-dot (../) traversal sequences, the lexical check verifies that the joined path string retains the prefix of self.root.path. For example, if self.root.path is /sandbox/nltk_data/corpora/ycoe, joining "pos" yields /sandbox/nltk_data/corpora/ycoe/pos. Because this string starts with /sandbox/nltk_data/corpora/ycoe, the lexical check succeeds without inspecting the filesystem inode.
Once self.root.join("pos") returns a new FileSystemPathPointer pointing to /sandbox/nltk_data/corpora/ycoe/pos, YCOECorpusReader passes this pointer directly as the authoritative root parameter to TaggedCorpusReader (and similarly passes psd to YCOEParseCorpusReader). The child readers treat the provided path pointer as their trusted base directory and apply regular expression filters (.*\.pos or .*\.psd) to discover all matching files beneath that directory.
Because no os.path.realpath() check was executed prior to instantiating the sub-readers, if /sandbox/nltk_data/corpora/ycoe/pos is a symbolic link pointing to /sandbox/nltk_data/corpora/private_dataset/pos, the child reader's root resolves directly to the external target. Any subsequent file operations performed by the child reader operate within the context of the symlink target, completely bypassing the intended confinement of the parent YCOECorpusReader root.
In vulnerable versions of NLTK (3.10.0 to 3.10.3), nltk/corpus/reader/ycoe.py defined YCOECorpusReader.__init__() by directly passing the result of self.root.join() into the sub-reader constructors:
class YCOECorpusReader(CorpusReader):
"""
Corpus reader for the York-Toronto-Helsinki Parsed Corpus of Old
English Prose (YCOE).
"""
def __init__(self, root, encoding="utf8"):
CorpusReader.__init__(self, root, [], encoding)
# VULNERABLE: self.root.join() only performs a string-prefix check.
# If 'psd' or 'pos' is a symlink pointing outside 'self.root',
# the sub-reader initializes with an external directory as its root.
self._psd_reader = YCOEParseCorpusReader(
self.root.join("psd"), ".*", ".psd", encoding=encoding
)
self._pos_reader = TaggedCorpusReader(
self.root.join("pos"), ".*", ".pos", encoding=encoding
)To remediate this flaw in Pull Request #3888 (commits 68434c0427f8d296f3adfddb518525038d1f7bfa and 2c563b13f9dce7c15eefd4b03463e1b69b64f1f3), the maintainers imported validate_path from the nltk.pathsec module and inserted an explicit containment verification loop prior to instantiating the child readers:
--- a/nltk/corpus/reader/ycoe.py
+++ b/nltk/corpus/reader/ycoe.py
@@ -24,6 +24,7 @@
from nltk.corpus.reader.bracket_parse import BracketParseCorpusReader
from nltk.corpus.reader.tagged import TaggedCorpusReader
from nltk.corpus.reader.util import *
+from nltk.pathsec import validate_path
from nltk.tokenize import RegexpTokenizer
@@ -37,6 +38,15 @@ class YCOECorpusReader(CorpusReader):
def __init__(self, root, encoding="utf8"):
CorpusReader.__init__(self, root, [], encoding)
+ # A psd/pos directory symlink escapes the corpus root: join() does a
+ # string-prefix check only, not symlink resolution, so files outside the
+ # YCOE corpus would be enumerated and read (CWE-59).
+ for sub in ("psd", "pos"):
+ validate_path(
+ self.root.join(sub),
+ context="YCOECorpusReader",
+ required_root=self.root,
+ )
+
self._psd_reader = YCOEParseCorpusReader(
self.root.join("psd"), ".*", ".psd", encoding=encoding
)
self._pos_reader = TaggedCorpusReader(
self.root.join("pos"), ".*", ".pos", encoding=encoding
)The nltk.pathsec.validate_path() function resolves both the candidate path (self.root.join(sub)) and the required_root (self.root) using canonical filesystem resolution (os.path.realpath). It then verifies that the resolved subdirectory path is strictly contained within the resolved self.root directory tree.
If either psd or pos is a symbolic link pointing to a directory outside self.root, validate_path raises an exception during YCOECorpusReader.__init__() before YCOEParseCorpusReader or TaggedCorpusReader can be constructed or enumerate files. While this check prevents directory-level symlink escapes during initialization, a time-of-check to time-of-use (TOCTOU) window theoretically exists if an attacker has concurrent write access to replace the directory with a symlink after __init__() completes; however, in typical corpus ingestion workflows where archives are extracted prior to reader instantiation, the synchronous check at initialization effectively blocks exploitation.
Exploiting CVE-2026-126304 requires an application to initialize YCOECorpusReader on a directory structure controlled or supplied by an attacker, and subsequently return or expose the parsed tokens or trees to the user. No authentication or specialized runtime conditions are needed if the target system processes user-uploaded corpus packages or third-party NLP dataset repositories.
To prepare the exploit, an attacker creates a directory structure matching the expected YCOE layout. Instead of populating pos/ or psd/ with standard Old English corpus files, the attacker creates symbolic links pointing to target directories on the host filesystem:
malicious_ycoe/
├── psd/ -> /sandbox/nltk_data/private_corpora/secret_corpus/psd
└── pos/ -> /sandbox/nltk_data/private_corpora/secret_corpus/posWhen this directory structure is packaged (for example, within a TAR or ZIP archive that preserves symbolic links) and extracted on the target host, the symlinks resolve relative to the target environment. Because TaggedCorpusReader and YCOEParseCorpusReader filter files by suffix (.pos and .psd respectively) or when explicit documents parameters are passed to reader methods, any compatible files in the symlinked target directory become immediately readable.
The official NLTK regression test suite (nltk/test/unit/test_ycoe_streamview_pathsec.py) demonstrates this exact exploitation vector in a reproducible proof-of-concept:
import os
import shutil
import pytest
from nltk.corpus.reader.ycoe import YCOECorpusReader
def test_sibling_corpus_symlink_escape(pathsec_sandbox, monkeypatch):
root, _ = pathsec_sandbox
# 1. Create a legitimate YCOE corpus directory
corpus = os.path.join(root, "ycoe")
os.makedirs(os.path.join(corpus, "psd"), exist_ok=True)
os.makedirs(os.path.join(corpus, "pos"), exist_ok=True)
# 2. Create a separate target sibling corpus containing confidential tokens
CANARY = "SECRET_TOKEN_EXFILTRATED"
sibling = os.path.join(root, "other")
os.makedirs(os.path.join(sibling, "pos"), exist_ok=True)
with open(os.path.join(sibling, "pos", "secret.pos"), "w", encoding="utf-8") as f:
f.write(f"Ic_PRO^N {CANARY}_VBP ._. cotest,1.1_ID\n")
# 3. Replace the ycoe/pos directory with a symlink to the sibling directory
shutil.rmtree(os.path.join(corpus, "pos"))
os.symlink(os.path.join(sibling, "pos"), os.path.join(corpus, "pos"))
# 4. In vulnerable versions (3.10.0 - 3.10.3), YCOECorpusReader traverses the symlink:
reader = YCOECorpusReader(corpus)
leaked_words = list(reader.words())
assert CANARY in leaked_wordsWhen reader.words() executes, YCOECorpusReader delegates the call to self._pos_reader.words(). The TaggedCorpusReader iterates over /path/to/other/pos/secret.pos, parses the SECRET_TOKEN_EXFILTRATED_VBP token, strips the part-of-speech tag (_VBP), and returns SECRET_TOKEN_EXFILTRATED in the output list.
The primary impact of CVE-2026-126304 is unauthorized disclosure of sensitive information stored on the filesystem (Confidentiality Impact: High). In multi-tenant machine learning environments, automated NLP grading platforms, or shared research sandboxes where NLTK processes untrusted corpus directories, an attacker can read proprietary datasets, sibling corpora, or sensitive text files residing within the NLTK data sandbox.
The vulnerability is scored at 8.7 (HIGH) under CVSS v4.0 (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N). The Network (AV:N) attack vector and Low (AC:L) complexity reflect scenarios where corpus packages are ingested remotely over a network without requiring special runtime prerequisites (AT:N), privileges (PR:N), or user interaction (UI:N).
> [!NOTE]
> While the vulnerability provides a reliable read-only file disclosure primitive (VC:H), it does not allow file modification (VI:N) or direct denial of service (VA:N), as the corpus readers only open files in read mode.
According to FIRST EPSS telemetry, CVE-2026-126304 has an exploitation probability score of 0.00522 (0.52%, 44.89th percentile) and is not listed in the CISA Known Exploited Vulnerabilities (KEV) catalog. The scope of files readable via this flaw is bounded by OS-level filesystem permissions of the Python process and any global NLTK pathsec sandbox roots configured by the host environment.
The primary remediation for CVE-2026-126304 is to upgrade the nltk package to version 3.10.4 or later, which enforces canonical path containment checks via nltk.pathsec.validate_path during YCOECorpusReader initialization.
Administrators and developers can upgrade the package using pip:
pip install --upgrade "nltk>=3.10.4"For environments where an immediate upgrade is not feasible, security teams should implement defense-in-depth controls around corpus ingestion and extraction:
.tar, .tar.gz, .zip), reject any archive members that are symbolic links (tarinfo.issym()) or hard links (tarinfo.islnk()). In Python 3.12 and later, always pass filter="data" to tarfile.TarFile.extractall(), which blocks symbolic links pointing outside the extraction root.YCOECorpusReader(root), explicitly verify that os.path.realpath(os.path.join(root, "psd")) and os.path.realpath(os.path.join(root, "pos")) start with os.path.realpath(root) + os.sep.~/nltk_data, /usr/share/nltk_data) are owned by a privileged account with read-only permissions for worker processes.CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N| Product | Affected Versions | Fixed Version |
|---|---|---|
Natural Language Toolkit (NLTK) NLTK Project | >= 3.10.0, < 3.10.4 | 3.10.4 |
| Attribute | Detail |
|---|---|
| CVE ID | CVE-2026-126304 (GHSA-qg35-44qx-7vrr) |
| Vulnerability Class | CWE-59: Improper Link Resolution Before File Access ('Link Following') |
| Affected Component | YCOECorpusReader (nltk/corpus/reader/ycoe.py) |
| CVSS v4.0 Score | 8.7 HIGH (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N) |
| EPSS Score | 0.52% (44.89th percentile) |
| Impact | Unauthorized arbitrary file read outside corpus root via symlink traversal |
| Exploit Status | Proof-of-Concept available in NLTK unit test suite |
| Fixed Version | NLTK 3.10.4 |
The software attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource outside the intended security boundary.
CVE-2026-126300 (GHSA-ffr9-mgrr-wcvr) is an inefficient algorithmic complexity vulnerability (CWE-407) in the Natural Language Toolkit (NLTK) Python library affecting versions 3.2.5 prior to 3.10.4. The flaw resides in the quote-restoration routine of TreebankWordTokenizer.span_tokenize and NLTKWordTokenizer.span_tokenize, where repeated calls to list.pop(0) over matched quote tokens introduce quadratic O(n^2) time complexity. An unauthenticated remote attacker who supplies input containing long sequences of double-quote characters can exhaust CPU resources and degrade service availability.
The Natural Language Toolkit (NLTK) versions 3.6 through 3.10.3 contain an algorithmic complexity vulnerability in the LegalitySyllableTokenizer.tokenize method. When processing tokens composed of continuous runs of vowels, an unguarded string reversal and set membership lookup loop executes in quadratic time relative to the input token length. Remote attackers capable of submitting arbitrary text to applications utilizing this tokenizer can trigger sustained single-core CPU exhaustion and application-level denial of service.
CVE-2026-128077 (GHSA-r99w-jh2x-3mg9) is an authorization bypass and interpretation conflict vulnerability affecting Nuxt and its underlying @nuxt/nitro-server package in versions 4.6.0 through 4.6.3. When an incoming HTTP request path begins with a double forward slash (e.g., //x/admin), the WHATWG URL parser within the Nuxt server-side page renderer interprets the initial segment as a network host/authority and resolves the remaining path (/admin) against the application's page router. Because Nitro server middleware, route-scoped serverHandlers, and Nitro routeRules evaluate the unparsed request path (//x/admin), path-based access controls, redirects, and security header policies configured for /admin fail to match and are bypassed.
Prior to version 2.10.2, the sqlite3-ruby gem explicitly cast 64-bit Ruby string lengths (long) to 32-bit signed integers (int) when invoking the underlying SQLite C API. On 64-bit platforms, providing a string of 4 GiB (2^32 bytes) or larger causes integer truncation, instructing SQLite to process only a prefix of the string. This flaw affects SQL query preparation, bound parameter values, and return values from custom Ruby-defined SQL functions and aggregates, allowing trailing SQL clauses such as WHERE restrictions to be silently dropped under specific conditions.
CVE-2026-128068 (GHSA-cpgf-5mjf-73p7) is a Regular Expression Denial of Service (ReDoS) vulnerability in the rails-html-sanitizer Ruby gem affecting versions 1.0.3 through 1.7.1. The flaw resides in the Rails::HTML::PermitScrubber#scrub_attribute method, which uses the multiline regular expression /^\s*[^#\s].*/m to enforce local-only URI fragment references on SVG elements. On Ruby runtimes prior to version 3.2 (CRuby <= 3.1 and JRuby), evaluating an attribute value composed of repeated newline and whitespace characters without a hash character triggers quadratic backtracking O(n^2). Applications are only vulnerable if their sanitization allowlist is explicitly configured to permit SVG reference elements such as <use> alongside href or xlink:href attributes.
CVE-2026-126360 (GHSA-mf5v-w3w3-f6cg) is an algorithmic complexity vulnerability (Regular Expression Denial of Service) in the trace context propagation logic of the opentelemetry-api package within the OpenTelemetry Python ecosystem. In versions prior to 1.45.2, the TraceContextTextMapPropagator.extract() method utilizes a regular expression susceptible to quadratic-time backtracking when parsing malformed W3C traceparent headers containing long sequences of whitespace followed by an embedded newline character. In Python applications—particularly those using single-threaded asynchronous event loops—an unauthenticated remote attacker transmitting payloads over newline-preserving carriers can monopolize CPU execution and induce application-level Denial of Service.