# Natural Language Toolkit (NLTK) has path traversal in FramenetCorpusReader.frame() that allows arbitrary XML file read, bypassing the nltk.pathsec sandbox (ENFORCE=True)

NLTK's FramenetCorpusReader.frame() joins an attacker-supplied frame name into an XML path read via builtin open(), escaping the corpus root and bypassing the nltk.pathsec sandbox even with ENFORCE=True.

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

**Target:** FramenetCorpusReader.frame() XML file loader in the nltk Python library
**Root cause:** frame_by_name joins the corpus root, frame directory and caller-supplied name with a fixed .xml extension without containment checks, then builds an XMLCorpusView from a string path. String-path views read with builtin open(), so nltk.pathsec.validate_path() is never invoked and ENFORCE=True does not apply.
**Steps:**
1. Enable the sandbox: `import nltk.pathsec as ps; ps.ENFORCE = True`
2. Build a minimal FrameNet corpus root with frame/, fulltext/, lu/ dirs, frameIndex.xml and frRelation.xml, and place a frame-shaped secret.xml outside the root
3. Instantiate `FramenetCorpusReader(str(root), [])`
4. Call `fn.frame(os.path.join('..','..','..','private','secret'))`
5. Observe the out-of-root XML is parsed and its definition returned despite ENFORCE=True; a patched build raises FramenetError before open()
**How found:** Noticed frame_by_name builds the path itself and passes a string (not a PathPointer) to XMLCorpusView, so the generic CorpusReader.open() hardening and pathsec.validate_path() are never reached — the same traversal class previously fixed for generic readers.
**Exploitation:** Any service routing user-influenced input into frame() can be made to read arbitrary .xml files the process can access; frame-shaped XML is returned parsed, other files yield a distinguishable parse error acting as a file-existence/readability oracle. Sibling methods doc() and the lexical-unit loader reach the same primitive via a malicious corpus index (filename field, lexUnit ID).
**Escalation:** Crafted FrameNet corpus data drives the same traversal without a caller-supplied name, widening exposure to any consumer of untrusted corpus directories.
**Insight:** When a library advertises a sandbox, grep for code paths that construct file paths manually and hand strings to lower-level readers instead of the guarded open()/PathPointer API; sibling methods sharing the same pattern (index-driven filenames, ID attributes) are usually vulnerable too.

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

**PoC calling frame() with traversal name**

```
fn = FramenetCorpusReader(str(root), [])

    # Attacker-controlled frame name climbs out of <root>/frame/ up to <base>/private/secret.xml
    evil = os.path.join("..", "..", "..", "private", "secret")
    print(f"[*] calling   fn.frame({evil!r})")

    try:
        f = fn.frame(evil)
        definition = f["definition"]
```

**Secret XML file placed outside corpus root**

```
secret = base / "private"
    secret.mkdir()
    (secret / "secret.xml").write_text(FRAME_XML)

    return base, root, secret / "secret.xml"
```

