GHSA-P4RW-RVV2-7XWR
Vulnerability from github – Published: 2026-09-08 16:55 – Updated: 2026-09-08 16:55Summary
Several corpus readers still step outside NLTK's symlink-aware trusted-root model. They derive in-root paths from trusted corpus state, convert those paths back into plain strings, and reopen them with built-in open() rather than nltk.pathsec.open().
Details
- Vulnerability type: Path traversal and symlink boundary bypass
- Affected component:
nltk.corpus.reader.ipipan,nltk.corpus.reader.crubadan,nltk.corpus.reader.lin - Affected versions: Published
3.9.4and current sourcev3.10.0-rc2both reproduced. - Patched versions: Not yet patched
- Root cause: Root-derived paths are reopened with raw
open()without preserving the trusted-root boundary.
IPIPANCorpusReader opens header.xml derived from morph.xml, CrubadanCorpusReader opens table.txt directly, and LinThesaurusCorpusReader opens simN.lsp paths returned from its own root helpers. Under pathsec.ENFORCE=True, a symlink placed inside the trusted corpus root can point outside the root and still be parsed successfully. It was confirmed parsed outside-root content is returned through public methods such as channels(), domains(), categories(), langs(), crubadan_to_iso(), synonyms(), and scored_synonyms().
PoC
Preconditions - The application processes attacker-influenced corpora inside a trusted NLTK data root or trusted corpus directory.
Steps
1. Create a trusted corpus root and keep pathsec.ENFORCE=True with that root allowlisted.
2. Place symlinked reader inputs such as header.xml, table.txt, or simN.lsp inside the root and point them to external files.
3. Instantiate the corresponding corpus reader and call its normal public methods.
4. Observe that parsed outside-root values are returned even though pathsec.open() blocks the same symlink targets.
Minimal reproducible excerpt
{'ipipan': ['LEAK', 'TOPSECRET', 'CLASSIFIED'], 'crubadan': ['LEAK'], 'lin': [('LEAK', 9.5)]}
Impact
An attacker who can stage corpus files or symlinks under a trusted data root can disclose outside-root content through normal corpus-reader results, defeating the boundary NLTK documents for shared and untrusted-input environments.
Remediation
Preserve PathPointer and required_root semantics end to end. Replace direct open() calls with nltk.pathsec.open() or a reader helper that keeps the trusted-root boundary intact.
References
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/ipipan.py#L162-L192
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/crubadan.py#L74-L98
- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/lin.py#L40-L43
- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/pathsec.py#L521-L545
Fix + full-codebase audit (verified)
I swept every raw file open in the corpus readers, not just the three the umbrella named:
| Reader | Site | Advisory | Root scoping |
|---|---|---|---|
| crubadan | table.txt + <code>-3grams.txt |
p4rw / j5pw | required_root=self.root |
| lin | simN.lsp | p4rw | required_root=self.root |
| xmldocs | XMLCorpusView bare-string fileid | 934p (base reader) | global fallback (view has no root) |
| pl196x | textids index | found by audit | required_root=self._root |
| mte | MTEFileReader | mvf5 | required_root threaded through 8 call sites |
| toolbox | StandardFormat.open codecs.open | cr8c | global sandbox (low-level parser) |
| named_entity | load_ace_file ann/text | 7qj2 | global sandbox |
| nkjp | XML_Tool source file | p4rw class | required_root=self._root |
ipipan already validates via the earlier #3727 fix — unchanged.
Fix
Each site now calls nltk.pathsec.validate_path(path, required_root=…) before opening. Where the reader has a concrete corpus root, the check is scoped with required_root (rejects any escape outside that root). XMLCorpusView carries no root, so it falls back to the global data-root sandbox via getattr(self, "_root", None) — which also avoids an AttributeError on the bare-string path.
Reproduced (captured)
raw open(symlink) reads: 'TOPSECRET_OUTSIDE_ROOT' <- the bypass
validate_path(symlink, required_root): ValueError -> BLOCKS the escape
validate_path(legit in-root): PASSED <- loads normally
Honest residual
The global-sandbox fallback (toolbox, named_entity, xmldocs-view) is only as tight as the allowed-roots list, which currently includes the system temp dir. Scoping every reader with required_root and removing the temp dir from the allowed roots would harden it further (separate advisory / task).
Tests
test_corpus_reader_pathsec.py — symlink escape rejected, in-root file allowed, XMLCorpusView string-fileid no AttributeError, MTEFileReader out-of-root rejected. 46 existing corpus/toolbox tests pass; all edited modules import (no circular import). pre-commit (black/isort/ruff) clean.
Scope caveat
validate_path blocks every symlink escape variant (verified) and equals pathsec.open()'s guarantee, but does NOT block hardlinks (no symlink to resolve; tracked separately as GHSA-f794-5jv7-7672) or the validate-then-open TOCTOU race (shared by pathsec.open; needs O_NOFOLLOW/openat).
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.10.2"
},
"package": {
"ecosystem": "PyPI",
"name": "nltk"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.10.3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-79676"
],
"database_specific": {
"cwe_ids": [
"CWE-22",
"CWE-59"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-08T16:55:38Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nSeveral corpus readers still step outside NLTK\u0027s symlink-aware trusted-root model. They derive in-root paths from trusted corpus state, convert those paths back into plain strings, and reopen them with built-in `open()` rather than `nltk.pathsec.open()`.\n\n### Details\n\n- **Vulnerability type:** Path traversal and symlink boundary bypass\n- **Affected component:** `nltk.corpus.reader.ipipan`, `nltk.corpus.reader.crubadan`, `nltk.corpus.reader.lin`\n- **Affected versions:** Published `3.9.4` and current source `v3.10.0-rc2` both reproduced.\n- **Patched versions:** Not yet patched\n- **Root cause:** Root-derived paths are reopened with raw `open()` without preserving the trusted-root boundary.\n\n`IPIPANCorpusReader` opens `header.xml` derived from `morph.xml`, `CrubadanCorpusReader` opens `table.txt` directly, and `LinThesaurusCorpusReader` opens `simN.lsp` paths returned from its own root helpers. Under `pathsec.ENFORCE=True`, a symlink placed inside the trusted corpus root can point outside the root and still be parsed successfully. It was confirmed parsed outside-root content is returned through public methods such as `channels()`, `domains()`, `categories()`, `langs()`, `crubadan_to_iso()`, `synonyms()`, and `scored_synonyms()`.\n\n### PoC\n\n**Preconditions**\n- The application processes attacker-influenced corpora inside a trusted NLTK data root or trusted corpus directory.\n\n**Steps**\n1. Create a trusted corpus root and keep `pathsec.ENFORCE=True` with that root allowlisted.\n2. Place symlinked reader inputs such as `header.xml`, `table.txt`, or `simN.lsp` inside the root and point them to external files.\n3. Instantiate the corresponding corpus reader and call its normal public methods.\n4. Observe that parsed outside-root values are returned even though `pathsec.open()` blocks the same symlink targets.\n\n**Minimal reproducible excerpt**\n\n```text\n{\u0027ipipan\u0027: [\u0027LEAK\u0027, \u0027TOPSECRET\u0027, \u0027CLASSIFIED\u0027], \u0027crubadan\u0027: [\u0027LEAK\u0027], \u0027lin\u0027: [(\u0027LEAK\u0027, 9.5)]}\n```\n\n### Impact\n\nAn attacker who can stage corpus files or symlinks under a trusted data root can disclose outside-root content through normal corpus-reader results, defeating the boundary NLTK documents for shared and untrusted-input environments.\n\n### Remediation\n\nPreserve `PathPointer` and `required_root` semantics end to end. Replace direct `open()` calls with `nltk.pathsec.open()` or a reader helper that keeps the trusted-root boundary intact.\n\n### References\n\n- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/ipipan.py#L162-L192\n- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/crubadan.py#L74-L98\n- https://github.com/nltk/nltk/blob/3.9.4/nltk/corpus/reader/lin.py#L40-L43\n- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/pathsec.py#L521-L545\n\n---\n\n## Fix + full-codebase audit (verified)\n\n\nI swept every raw file open in the corpus readers, not just the three the umbrella named:\n\n| Reader | Site | Advisory | Root scoping |\n|---|---|---|---|\n| crubadan | table.txt + `\u003ccode\u003e-3grams.txt` | p4rw / j5pw | `required_root=self.root` |\n| lin | simN.lsp | p4rw | `required_root=self.root` |\n| xmldocs | XMLCorpusView bare-string fileid | 934p (base reader) | global fallback (view has no root) |\n| pl196x | textids index | **found by audit** | `required_root=self._root` |\n| mte | MTEFileReader | mvf5 | `required_root` threaded through 8 call sites |\n| toolbox | StandardFormat.open codecs.open | cr8c | global sandbox (low-level parser) |\n| named_entity | load_ace_file ann/text | 7qj2 | global sandbox |\n| nkjp | XML_Tool source file | p4rw class | `required_root=self._root` |\n\n`ipipan` already validates via the earlier #3727 fix \u2014 unchanged.\n\n## Fix\nEach site now calls `nltk.pathsec.validate_path(path, required_root=\u2026)` before opening. Where the reader has a concrete corpus root, the check is **scoped** with `required_root` (rejects any escape outside that root). `XMLCorpusView` carries no root, so it falls back to the global data-root sandbox via `getattr(self, \"_root\", None)` \u2014 which also avoids an AttributeError on the bare-string path.\n\n## Reproduced (captured)\n```\nraw open(symlink) reads: \u0027TOPSECRET_OUTSIDE_ROOT\u0027 \u003c- the bypass\nvalidate_path(symlink, required_root): ValueError -\u003e BLOCKS the escape\nvalidate_path(legit in-root): PASSED \u003c- loads normally\n```\n\n## Honest residual\nThe global-sandbox fallback (toolbox, named_entity, xmldocs-view) is only as tight as the allowed-roots list, which currently includes the **system temp dir**. Scoping every reader with `required_root` and removing the temp dir from the allowed roots would harden it further (separate advisory / task).\n\n## Tests\n`test_corpus_reader_pathsec.py` \u2014 symlink escape rejected, in-root file allowed, XMLCorpusView string-fileid no AttributeError, MTEFileReader out-of-root rejected. 46 existing corpus/toolbox tests pass; all edited modules import (no circular import). pre-commit (black/isort/ruff) clean.\n\n---\n\n## Scope caveat\n`validate_path` blocks every symlink escape variant (verified) and equals `pathsec.open()`\u0027s guarantee, but does NOT block **hardlinks** (no symlink to resolve; tracked separately as GHSA-f794-5jv7-7672) or the validate-then-open TOCTOU race (shared by `pathsec.open`; needs O_NOFOLLOW/openat).",
"id": "GHSA-p4rw-rvv2-7xwr",
"modified": "2026-09-08T16:55:38Z",
"published": "2026-09-08T16:55:38Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/nltk/nltk/security/advisories/GHSA-p4rw-rvv2-7xwr"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-79676"
},
{
"type": "WEB",
"url": "https://github.com/nltk/nltk/commit/10d34b3f4fe3fec74b76527a409eb0acbac2e8ab"
},
{
"type": "PACKAGE",
"url": "https://github.com/nltk/nltk"
},
{
"type": "WEB",
"url": "https://github.com/nltk/nltk/releases/tag/v3.10.3"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3737.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/nltk-before-path-traversal-via-symlink-bypass"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "NLTK: Corpus readers follow symlinks outside trusted roots despite pathsec enforcement"
}
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.
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.