{"id":"GHSA-j456-xh4h-cpf2","kind":"vulnerability","aliases":["GHSA-j456-xh4h-cpf2"],"related":[],"title":"NLTK: WekaClassifier passes an unvalidated caller-controlled model path to the weka JVM, bypassing nltk.pathsec containment","description":"## Summary\n`WekaClassifier` in NLTK passes a caller-controlled model file path to the weka Java VM without any `nltk.pathsec` validation. `WekaClassifier.train` puts the caller's `model_filename` verbatim into the weka argv `[\"-d\", model_filename, ...]` (weka *writes* the trained model there), and `WekaClassifier._classify_many` puts `self._model` into `[\"-l\", self._model, ...]` (weka *reads* it). Under `nltk.pathsec.ENFORCE=True` (the default on current HEAD), NLTK's stated invariant — established by advisory CVE-2026-81726 / GHSA-8mgp-746c-j5xp — is that caller-controlled model-artifact paths must be bounded to the allowed NLTK data roots before any native tool opens them. `WekaClassifier` is the one `java()`-based tool wrapper that skips this entirely, giving an untrusted workflow arbitrary-location file **write** (`-d`) and an arbitrary file **read/parse oracle** (`-l`), defeating pathsec containment.\n\nThis is a standalone, unfixed variant of the same class as CVE-2026-81726, on a code path the CVE-2026-81726 fix never touched. That advisory enumerates 6 functions (`TransitionParser.train`, `TransitionParser.parse`, `AveragedPerceptron.save`, `AveragedPerceptron.load`, `PerceptronTagger.save_to_json`, `save_maxent_params`) — `WekaClassifier` is not among them, and weka.py contains no `validate_tool_path`/`validate_model_resource` at all.\n\n## Root Cause\n`WekaClassifier.train(cls, model_filename, ...)` builds `cmd = [javaclass, \"-d\", model_filename, \"-t\", train_filename]` (nltk/classify/weka.py:256) and `WekaClassifier._classify_many` builds `[..., \"-l\", self._model, \"-T\", <staged>, ...]` (weka.py:131-133); `self._model` is stored unvalidated in `__init__` (weka.py:108-110). Only the internally-generated `train.arff`/`test.arff` are staged via `make_staging_dir`; the model path is passed as-is. `nltk.internals.java()` (internals.py:388-431) validates the classpath (`_verify_jar_sandbox`), the JVM launcher options (`_validate_java_options`, the CVE-2026-12841 fix), the main-class token `cmd[0]`, and rejects `@argfile` tokens in any position — but it explicitly permits program arguments starting with `-` and never inspects the interior of a program-arg path value. So `-d <path>` / `-l <path>` flow unchecked to `subprocess.Popen(final_cmd, shell=False)` and the weka JVM performs an unbounded native open/write on the caller-chosen path.\n\nSibling wrappers prove the intended pattern: `CRFTagger.train`/`set_model_file` call `validate_tool_path` (crf.py:134,300); `MaltParser.generate_malt_command` validates `-i`/`-o` via `validate_tool_path` (malt.py:340,344), its model via `validate_model_resource` (malt.py:326) and `working_dir` via `validate_path` (malt.py:206). weka.py already uses pathsec elsewhere (`validate_path` for WEKAHOME, `pathsec_open` for ARFF writes) but never for the model path.\n\n## Impact\nUnder `pathsec.ENFORCE=True` (default on HEAD), an untrusted workflow that chooses the weka model save/load path obtains:\n- Arbitrary-location file **write** via `-d` — overwrite or create a file at an attacker-chosen absolute or `..`-traversal path outside the allowed NLTK data roots (content constrained to weka's serialized-model format; the target choice is the integrity impact). This corrupts/clobbers arbitrary chosen files.\n- Arbitrary file **read/parse oracle** via `-l` — weka loads an out-of-root path as a model, yielding an existence/parse differential (file content is not echoed back → confidentiality impact is limited).\n\nThis defeats the pathsec containment advisory CVE-2026-81726 promises, at the same privilege level / threat model that advisory was accepted under.\n\n## Proof of Concept\n```python\nimport nltk.pathsec as ps; ps.ENFORCE = True\nfrom nltk.classify.weka import WekaClassifier\n# write-side: model written OUTSIDE any allowed NLTK data root (refused for CRFTagger, allowed here)\nWekaClassifier.train(\"/tmp/outside_sandbox/evil.model\",\n                     [({\"a\": 1}, \"pos\"), ({\"a\": 0}, \"neg\")])\n# read-side: -l opens an arbitrary path\nclf = WekaClassifier(fmt, \"/home/victim/secret.model\")\nclf.classify_many([{\"a\": 1}])\n```\nThe argv construction — the vulnerable step — is confirmed without weka installed (see Bypass Evidence); a configured weka.jar is needed only to observe the JVM's actual file I/O.\n\n## Attack Chain\n1. **Entry.** Action: an app running with `pathsec.ENFORCE=True` lets an untrusted workflow choose the weka model path and calls `WekaClassifier.train(\"../../../home/victim/x\", featuresets)` (write) or `WekaClassifier(fmt, \"/home/victim/secret.model\").classify_many(...)` (read). Guard: none — plain classmethod/constructor parameter (weka.py:230,108-110). Bypass proof: grep shows weka.py uses no `validate_tool_path`/`validate_model_resource`/`validate_tool_dir` (NONE).\n2. **Command build.** Action: the path is placed into `cmd = [javaclass, \"-d\", model_filename, \"-t\", train_filename]` (weka.py:256) / `[\"-l\", self._model, ...]`. Guard: only the internally-generated `train.arff` is staged via `make_staging_dir`; the model path is passed as-is. Bypass proof: runtime capture yielded `['weka.classifiers.bayes.NaiveBayes', '-d', '/tmp/outside_sandbox_weka.model', '-t', '<staged>/train.arff']` — the raw out-of-root path is `cmd[2]`.\n3. **java() sink.** Action: `java(cmd, classpath=_weka_classpath, ...)` → `subprocess.Popen(final_cmd)` (internals.py:431). Guard: `java()` validates classpath, JVM `options`, `cmd[0]`, and rejects `@argfile` tokens. Bypass proof: none of these inspect the `-d`/`-l` program-arg value; internals.py explicitly documents that program args after the main class may start with `-`, and the path is neither `cmd[0]` nor an `@`-token, so every `java()` gate passes. Negative control: the same path through `validate_tool_path(..., for_write=True, must_exist=False)` raises `PermissionError`.\n4. **Impact.** Action: the weka JVM opens the path outside the allowed roots → arbitrary-location write (`-d`) / parse-existence oracle (`-l`). Guard: pathsec is never consulted (no `validate_tool_path`, no `pathsec_open`). Bypass proof: the directly-analogous sibling `CRFTagger.train` calls `validate_tool_path(model_file, must_exist=False)` (crf.py:300); weka does not, and commit `ee6d6138` hardened weka's other sinks (scratch files, WEKAHOME jar) while leaving the model path unvalidated.\n\n## Bypass Evidence\n- Negative control (guard is real): `validate_tool_path(\"/tmp/outside_sandbox_weka.model\", for_write=True, must_exist=False)` → `PermissionError`; `validate_tool_path(\"../../../home/victim/.ssh/authorized_keys\", ...)` → `PermissionError`.\n- Sink reached unvalidated (guard absent): captured write-side argv `['weka.classifiers.bayes.NaiveBayes', '-d', '/tmp/outside_sandbox_weka.model', '-t', '<staged>/train.arff']` and read-side `[..., '-l', '/home/victim/secret.model', '-T', '<staged>/test.arff', '-p', '0']`.\n- Version: `git show v3.10.3:nltk/classify/weka.py` contains both the `-d` model-path build (line 235) and the `-l` read, and NO `validate_tool_path`.\n\n## Affected Versions\n`<= 3.10.3` (present on the latest release tag v3.10.3 and on HEAD `c06cd264`). No published fix has ever targeted the weka `-d`/`-l` model path; commits after v3.10.3 (`ee6d6138`, `92aeada6`, `a9752559`) touched weka.py for other sinks but did not add model-path validation.\n\n## Suggested Fix\nBefore building the `-d`/`-l`/`-T` command, validate the model path with pathsec, mirroring `CRFTagger`/`MaltParser`:\n```python\nvalidate_tool_path(model_filename, context=\"WekaClassifier.train\", for_write=True, must_exist=False)\nvalidate_tool_path(self._model, context=\"WekaClassifier._classify_many\")\n```\n\n\n---\nReported by **zx (Jace)** — GitHub: @manus-use\n\n## Fix status (2026-09-28)\n\nStatus: MERGED on develop (unreleased; v3.10.3 of 2026-08-12 predates the fix).\n\nPR: #3863 \"Bound the WekaClassifier model path to the pathsec data roots\", merged 2026-09-06, merge commit bc2878725 on develop. Fix commit: 6a1760629.\n\nProbe: nltk/test/unit/security_probes/ghsa_j456_xh4h_cpf2.py, added by PR #3905 \"Add GHSA-j456 live probe; expand the GHSA-8mgp probe attack set\" (commit a96715264, merged 2026-09-20, merge commit 84d987bf8); verdict on develop 2fa3f48fe: FIXED (WekaClassifier -l and -d refuse out-of-root model paths).\n\nRelease: none yet; no release tag contains 6a1760629.","summary":"NLTK's WekaClassifier passes a caller-supplied model path straight to the weka Java tool, skipping the pathsec containment checks that other NLTK wrappers apply. An application that lets an untrusted workflow choose that path lets an attacker write or overwrite files outside NLTK's data roots, and probe whether arbitrary files exist or parse as models.","severity":"high","cvss":8.6,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"nltk","fixed":["3.10.4"],"affected":"<= 3.10.3","ecosystem":"pypi"}],"ecosystems":["pypi"],"tags":["cwe-22","cwe-73","nltk","package","python","self-hosted","weka"],"not_affected_if":["default-config","no-weka-use","static-model-path"],"how_to_check":"- Run `pip show nltk` and confirm the version is `<= 3.10.3`.\n- Search your code for `WekaClassifier` calls where `model_filename` or the model path comes from user input, a request, or another untrusted workflow.\n- Check whether the code sets `nltk.pathsec.ENFORCE` to `True`, which is the default on current HEAD.","fix":"- Upgrade NLTK to the first release that includes commit `6a1760629`; the fix is merged on develop but no release contains it yet as of 2026-09-28.\n- Until a release ships, do not pass untrusted values to `WekaClassifier.train` or the `WekaClassifier` constructor as the model path; use fixed, server-chosen paths instead.","status":"open","published_at":"2026-10-27T14:50:30+00:00","first_seen_at":"2026-10-27T14:52:48+00:00","triaged_at":"2026-10-27T14:53:53+00:00","updated_at":"2026-10-27T14:53:53+00:00","category":"package","confidence":0.7,"triage":{"at":"2026-10-27T14:53:53Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade NLTK to the first release that includes commit `6a1760629`; the fix is merged on develop but no release contains it yet as of 2026-09-28.\n- Until a release ships, do not pass untrusted values to `WekaClassifier.train` or the `WekaClassifier` constructor as the model path; use fixed, server-chosen paths instead.","reach":"unknown","title":"NLTK: WekaClassifier model path skips pathsec, allowing out-of-root file writes and reads","impact":"medium","summary":"NLTK's WekaClassifier passes a caller-supplied model path straight to the weka Java tool, skipping the pathsec containment checks that other NLTK wrappers apply. An application that lets an untrusted workflow choose that path lets an attacker write or overwrite files outside NLTK's data roots, and probe whether arbitrary files exist or parse as models.","category":"package","nicknames":[],"who_cares":["python","nltk","weka","self-hosted"],"confidence":0.7,"needs_auth":"unknown","worst_case":"Attacker overwrites or probes files outside NLTK data roots via weka","analyst_note":"Find the first NLTK release containing commit 6a1760629 (PR #3863) and record it as the fixed version; source for the develop-only status is the PR merge on bc2878725.","how_to_check":"- Run `pip show nltk` and confirm the version is `<= 3.10.3`.\n- Search your code for `WekaClassifier` calls where `model_filename` or the model path comes from user input, a request, or another untrusted workflow.\n- Check whether the code sets `nltk.pathsec.ENFORCE` to `True`, which is the default on current HEAD.","needs_analyst":true,"not_affected_if":["default-config","static-model-path","no-weka-use"],"who_is_affected":"Exposure requires an application that passes an attacker-influenced path into WekaClassifier, either as the model_filename argument to train or as the model path to the constructor. Applications that only use WekaClassifier with paths fixed in code or config, or that do not use weka at all, are not exposed.","user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"unknown"},"cost_usd":0.001517,"input_tokens":6787,"output_tokens":1677,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-110':318C '-12841':359C '-133':310C '-2026':166C,241C,249C,358C,587C '-22':30B '-431':342C '-73':32B '-81726':167C,242C,250C,588C '/tmp/outside_sandbox/evil.model':633C '0':367C,638C '1':635C '256':302C '300':436C '344':448C '6':257C '746c':171C '8mgp':170C 'absolut':518C 'accept':600C 'advisori':164C,255C,585C,598C 'allow':187C,524C,623C,630C 'alreadi':464C 'among':272C 'appli':65B 'applic':67B 'arbitrari':91B,214C,221C,502C,546C,549C 'arbitrary-loc':213C,501C 'arff':475C 'arg':392C 'argfil':370C 'argument':380C 'argv':130C 'artifact':180C 'as-i':337C 'attack':78B,516C 'attacker-chosen':515C 'averagedperceptron.load':262C 'averagedperceptron.save':261C 'back':575C 'base':202C 'bound':184C 'build':292C,305C 'bypass':15A 'call':431C 'caller':7A,46B,105C,122C,176C,418C 'caller-chosen':417C 'caller-control':6A,104C,175C 'caller-suppli':45B 'caus':287C 'check':60B 'choic':539C 'choos':73B,494C 'chosen':419C,517C,547C 'class':238C,364C 'classpath':345C 'cls':289C 'cmd':293C,366C,403C 'code':245C 'command':439C 'concept':604C 'confidenti':576C 'constrain':529C 'contain':17A,59B,228C,276C,584C 'content':528C,571C 'control':8A,106C,177C 'corrupts/clobbers':545C 'cpf2':22A,27A 'creat':510C 'crf.py:134':435C 'crftagger':629C 'crftagger.train':427C 'current':156C 'cve':165C,240C,248C,357C,586C 'cwe':29B,31B 'd':131C,218C,295C,396C,507C 'data':86B,189C,526C,625C 'default':154C,487C 'defeat':226C,581C 'differenti':569C 'dir':331C,458C 'echo':574C 'elsewher':467C 'entir':208C 'enumer':256C 'establish':162C 'exist':93B 'existence/parse':568C 'explicit':377C 'fals':405C 'file':82B,92B,108C,216C,222C,430C,504C,512C,548C,550C,570C 'filenam':125C,133C,291C,297C,300C 'final':402C 'fix':251C,360C 'flow':398C 'format':536C 'function':258C 'generat':323C 'ghsa':19A,24A,169C 'ghsa-8mgp-746c-j5xp':168C 'ghsa-j456-xh4h-cpf2':18A,23A 'give':209C 'head':157C,489C 'host':38B 'impact':483C,543C,577C 'import':606C,614C 'init':316C 'inspect':385C 'integr':542C 'intend':425C 'interior':387C 'intern':322C 'internally-gener':321C 'internals.py:388':341C 'invari':161C 'j456':20A,25A 'j5xp':172C 'jar':347C 'java':54B,113C,201C,354C 'javaclass':294C 'json':265C 'jvm':14A,350C,409C 'l':145C,225C,306C,397C,554C,643C 'launcher':351C 'let':69B,76B 'level':594C 'limit':579C 'load':556C 'locat':215C,503C 'main':363C 'main-class':362C 'make':329C 'malt':438C 'malt.py:206':462C 'malt.py:326':455C 'malt.py:340':447C 'maltparser.generate':437C 'maxent':267C 'model':9A,48B,97B,107C,124C,132C,138C,179C,282C,290C,296C,333C,429C,450C,453C,481C,497C,535C,565C,596C,619C 'model-artifact':178C 'must':182C 'nativ':193C,413C 'neg':639C 'never':252C,384C,478C 'nltk':1A,28A,33B,40B,63B,84B,101C,158C,188C,525C,624C 'nltk.classify.weka':613C 'nltk.internals.java':340C 'nltk.pathsec':16A,117C,607C 'nltk.pathsec.enforce':151C 'nltk/classify/weka.py':301C 'o':442C 'obtain':500C 'one':200C 'open':195C,473C,644C 'open/write':414C 'option':352C,355C 'oracl':224C,552C 'out-of-root':558C 'outsid':83B,522C,621C 'overwrit':81B,508C 'packag':34B 'param':268C 'pars':95B 'pass':3A,43B,102C,336C 'path':10A,49B,75B,109C,181C,246C,280C,334C,393C,420C,434C,446C,461C,469C,482C,499C,521C,562C 'pathsec':58B,227C,466C,472C,583C 'pathsec.enforce':485C 'pattern':426C 'perceptrontagger.save':263C 'perform':410C 'permit':378C 'pos':636C 'posit':374C 'privileg':593C 'probe':89B 'program':379C,391C 'program-arg':390C 'promis':589C 'proof':602C 'prove':423C 'ps':609C 'ps.enforce':610C 'put':120C,142C 'python':35B,605C 'read':148C,641C 'read-sid':640C 'read/parse':223C,551C 'refus':627C 'reject':369C 'resourc':283C,454C 'root':87B,190C,286C,527C,561C,626C 'sandbox':348C 'save':266C 'save/load':498C 'self':37B 'self-host':36B 'self._model':143C,146C,307C,311C 'serial':534C 'serialized-model':533C 'set':428C 'shell':404C 'sibl':421C 'side':618C,642C 'skip':56B,206C 'stage':327C,330C 'standalon':232C 'start':381C 'state':160C 'store':313C 'straight':50B 'subprocess.popen':401C 'summari':98C 'suppli':47B 'target':538C 'test.arff':325C 'threat':595C 'token':365C,371C 'tool':55B,194C,203C,279C,433C,445C 'touch':253C 'train':137C,299C 'train.arff':324C 'transitionparser.parse':260C 'transitionparser.train':259C 'travers':520C 'true':152C,486C,611C 'unbound':412C 'uncheck':399C 'unfix':233C 'untrust':71B,211C,491C 'unvalid':5A,314C 'use':465C 'valid':118C,278C,281C,343C,353C,432C,440C,444C,452C,460C,468C 'valu':394C 'variant':234C 'verbatim':126C 'verifi':346C 'via':328C,443C,451C,459C,506C,553C 'vm':114C 'weka':13A,39B,53B,112C,129C,134C,147C,408C,496C,531C,555C 'weka.py':275C,463C 'weka.py:108':317C 'weka.py:131':309C 'wekaclassifi':2A,42B,99C,197C,269C,615C 'wekaclassifier._classify_many':141C,304C 'wekaclassifier.train':119C,288C,632C 'wekahom':471C 'whether':90B 'without':115C 'work':457C 'workflow':72B,212C,492C 'wrapper':64B,204C,422C 'write':79B,135C,217C,476C,505C,617C 'write-sid':616C 'written':620C 'xh4h':21A,26A 'yield':566C","nicknames":[],"mitigations":[],"who_affected":"Exposure requires an application that passes an attacker-influenced path into WekaClassifier, either as the model_filename argument to train or as the model path to the constructor. Applications that only use WekaClassifier with paths fixed in code or config, or that do not use weka at all, are not exposed.","tier":"B","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-27T14:50:30+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-j456-xh4h-cpf2","url":"https://github.com/advisories/GHSA-j456-xh4h-cpf2","severity":"high","published_at":"2026-10-27T14:50:30+00:00","modified_at":"2026-10-27T14:50:31+00:00","first_seen_at":"2026-10-27T14:52:48+00:00","fetched_at":"2026-10-27T15:03:12+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}