Neutron, our AI engine, scored 97.1% on UC Berkeley's CyberGym benchmark. Learn more

Deep Dive

Deep Dive

Stanza Lemmatizer RCE via Pickle Deserialization (CVE-2026-115878)

Stanza 1.14.0 loads an inner gzip pickle from a lemma checkpoint with no restriction, so a crafted model runs code; fixed in 1.15.0.

TL;DR

  • Stanza 1.14.0 stores its lemmatizer dictionary as a gzip-compressed pickle inside the model checkpoint, then loads it with an unrestricted pickle.loads. A crafted checkpoint runs code when the model is loaded.
  • Only Stanza 1.14.0 is affected. The path is reachable when an application loads a caller-influenced lemma_model_path or a model from a shared registry.
  • The GitHub advisory rates it 7.8 High (CVSS v3.1, local, user interaction). A confirmed end-to-end PoC is in the advisory. No exploitation is known, and the CVE is not in CISA KEV as of November 4, 2026.
  • Upgrade to Stanza 1.15.0 or later. The fix replaces the inner pickle with orjson and rejects the vulnerable checkpoint format; see "How do I fix it?" for the model conversion step.

Am I affected?

All of these conditions must hold:

  • The installed Stanza version is 1.14.0. It is the only release that writes and reads the gzip-pickle pos_dict format.
  • Your code loads a lemmatizer checkpoint from a path an attacker can influence. Examples are a lemma_model_path argument, a shared model directory, a custom model registry, or an artifact store that accepts uploaded models.
  • The attacker can place or replace the .pt file that the code loads. Loading a model through Stanza's default download registry is not the exposed path, because that flow verifies an MD5 checksum.

The advisory's proof of concept calls the vulnerable _unpack_pos_dict function directly, and its source review establishes the path through the documented stanza.Pipeline(..., lemma_model_path=...) API. The load happens during pipeline construction.

Version Affected Fixed version
1.14.0 Yes, when a caller-influenced checkpoint is loaded 1.15.0
1.13.0 and earlier No, these releases use the legacy tuple format Not applicable
1.15.0 and later No, these releases use the JSON v3 format Keep updating

Check what is installed:

python -c "import stanza; print(stanza.__version__)"
pip show stanza
pip freeze | grep -i '^stanza=='

For a container, query the running container rather than a freshly pulled image:

docker exec <container> python -c "import stanza; print(stanza.__version__)"

Then find every place that supplies a model path:

grep -rn "lemma_model_path" .
grep -rn "lemma" --include="*.py" . | grep -i "model_path\|checkpoint"

A custom model directory shared between users or jobs is the highest-risk location. Treat write access to it as code execution on every host that loads from it.

How do I fix it?

  1. Upgrade Stanza to 1.15.0 or later:
pip install --upgrade "stanza>=1.15.0"
  1. Update the lock file with the tool your project uses:
pip-compile --upgrade-package stanza requirements.in
poetry update stanza
uv lock --upgrade-package stanza
  1. Rebuild every container and virtual environment that pins Stanza. The upgrade changes the on-disk dictionary format, so an old image that still carries 1.14.0 remains vulnerable.
  2. Re-download the default Stanza models. Version 1.15.0 refuses to load a version 2 checkpoint and raises a ValueError that names the converter. For a custom model, run the bundled converter:
python -m stanza.utils.lemma.convert_lemma_dict old_model.pt --output new_model.pt
  1. Confirm that the installed version is 1.15.0 or later and that the pipeline loads the converted model.

If an upgrade is not possible, reduce exposure while you arrange it:

  • Load only model files whose SHA-256 you have verified against a value you control.
  • Restrict write access to the model directory and to any registry that feeds it.
  • Prefer Stanza's default download flow, which verifies an MD5 checksum, over a caller-supplied path.
  • Run model-loading jobs with a low-privilege account and no cloud credentials.

Those steps reduce who can supply a file. They do not make the pickle.loads call safe, and the advisory's proof of concept still works against 1.14.0.

What is the root cause?

Stanza is a Python natural-language processing library from the Stanford NLP group. In version 1.14.0, the lemmatizer keeps its part-of-speech dictionary, pos_dict, as a nested mapping from a part of speech to a word-to-lemma mapping.

The trainer serializes that mapping with pickle.dumps, compresses the result with gzip, and stores the bytes in the checkpoint's dicts field. The vulnerable code is in stanza/models/lemma/trainer.py at the v1.14.0 tag, lines 54 to 59:

def _unpack_pos_dict(data):
    """Deserialize pos_dict from gzip-compressed pickle bytes."""
    buf = io.BytesIO(data)
    with gzip.GzipFile(fileobj=buf, mode="rb") as gz:
        raw = gz.read()
    return pickle.loads(raw)

Trainer.load reads the outer checkpoint with torch.load(..., weights_only=True) at line 367. That flag restricts the outer pickle to primitive types and tensors. A bytes value is a primitive, so the inner gzip stream passes through the outer allowlist without inspection. Line 378 then hands those bytes to _unpack_pos_dict:

checkpoint = torch.load(filename, lambda storage, loc: storage, weights_only=True)
...
dicts_version = checkpoint.get('dicts_version', _DICTS_VERSION_LEGACY)
if dicts_version == _DICTS_VERSION_POS:
    self.pos_dict = _unpack_pos_dict(checkpoint['dicts'])

A pickle stream stores a callable by its global name, with the arguments to call it with, and pickle.loads looks the callable up and calls it. A malicious stream can name any callable, including os.system. The advisory's proof of concept builds the stream with a __reduce__ method that returns (os.system, (command,)), and the command runs when _unpack_pos_dict executes.

The outer weights_only=True is not a defence here. The unsafe operation is the inner pickle.loads, which receives data that the outer loader treated as an opaque byte string. The advisory notes that this is separate from the earlier weights_only=False fallback fixed under CVE-2026-54499.

How is it exploited?

  1. The attacker builds a .pt checkpoint whose dicts_version is 2 and whose dicts value is a gzip-compressed pickle. The pickle's __reduce__ returns a callable and its arguments, such as os.system and a command.
  2. The victim's application constructs a pipeline with lemma_model_path pointing at that file, or loads it from a shared registry or model directory the attacker can write to.
  3. torch.load(..., weights_only=True) accepts the outer checkpoint. The dicts value is a bytes object, so the outer allowlist does not look inside it.
  4. _unpack_pos_dict decompresses the bytes and calls pickle.loads. The pickle stream invokes the callable during deserialization.
  5. The command runs as the user that loads the model, before the pipeline processes any text.

The advisory includes a minimal proof of concept. Its payload class is small enough to show the mechanism:

class Payload:
    def __reduce__(self):
        return (os.system, ("id > /tmp/stanza_lemma_poc_pwned",))

The advisory reports that the payload ran against Stanza 1.14.0, PyTorch 2.13.0, and CPython 3.13.7, and that the marker file recorded the command output. The PoC calls _unpack_pos_dict directly and also establishes the path through stanza.Pipeline by source review.

How does the attack flow?

A caller-influenced checkpoint carries a nested pickle past the outer weights_only allowlist and into an unrestricted pickle.loads, which runs the attacker's command as the loading user.

flowchart LR A["Attacker supplies a crafted .pt checkpoint"] --> B["Pipeline loads lemma_model_path"] B --> C["torch.load weights_only=True passes the bytes"] C --> D["_unpack_pos_dict gunzips the inner stream"] D --> E["pickle.loads calls __reduce__"] E --> F["Command runs as the loading user"]

What can an attacker do?

An attacker who can supply the checkpoint runs arbitrary code with the privileges of the process that loads it. In a notebook, that is the notebook user. In a service, it is the service account. In a CI job, it is the build user with access to the repository and its secrets.

The code runs during pipeline construction, before the application processes a single document. It can read the model files, the datasets the job can reach, environment variables, cloud credentials, and API tokens. It can also write to the filesystem or start a process that outlives the load.

In a CI job that builds a model artifact, the load can run before the tests, read the CI token from the environment, and push to the repository. A model-hosting service exposes the same primitive to anyone who can upload a checkpoint, even when its inference API requires authentication.

The conditions are the same as in "Am I affected?": Stanza 1.14.0, a checkpoint from an attacker-influenced path, and the ability to place or replace that file. The CVSS vector is local with user interaction, CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H, because the victim must load the file. The attacker does not need credentials to the Stanza installation.

No exploitation in the wild is known. The advisory contains a confirmed end-to-end proof of concept, and the finder tested it against the released package, PyTorch 2.13.0, and CPython 3.13.7.

Why does the fix work?

The 1.15.0 fix removes pickle from the dictionary path. The release commit b21e1c9b47b4fccff21d3b0a6c0687a19410c2f0 changes the serializer to orjson and the deserializer to orjson.loads:

 def _pack_pos_dict(pos_dict):
-    raw = pickle.dumps(pos_dict, protocol=4)
+    raw = orjson.dumps(pos_dict)
     ...
 def _unpack_pos_dict(data):
-    return pickle.loads(raw)
+    return orjson.loads(raw)

The same commit introduces a new format version and rejects the old one:

-_DICTS_VERSION_LEGACY = 1   # old (word_dict, composite_dict) tuple
-_DICTS_VERSION_POS    = 2   # new {pos: {word: lemma}}, gzip-pickled bytes
+_DICTS_VERSION_LEGACY     = 1   # old (word_dict, composite_dict) tuple
+_DICTS_VERSION_POS_PICKLE = 2   # new {pos: {word: lemma}}, gzip+pickle bytes (security hole, replaced by v3)
+_DICTS_VERSION_POS        = 3   # new {pos: {word: lemma}}, gzip+json bytes
...
 if dicts_version == _DICTS_VERSION_POS:
     self.pos_dict = _unpack_pos_dict(checkpoint['dicts'])
+elif dicts_version == _DICTS_VERSION_POS_PICKLE:
+    raise ValueError(
+        f"Lemmatizer model {filename!r} uses a pickle-based dict format (v2) "
+        "that is no longer supported for security reasons. "
+        "Please re-download the model or run convert_lemma_dict.py to upgrade it to v3."
+    )

orjson.loads parses JSON data into dictionaries, lists, strings, and numbers. It cannot name a callable or reconstruct an arbitrary object, so a crafted dictionary cannot execute code. Rejecting version 2 prevents an attacker from using the old pickle format against a patched installation.

The fix is complete for the demonstrated path. It changes both the writer and the reader, and it rejects old checkpoints rather than trying to sanitize them. The cost is that existing version 2 custom models must be re-downloaded or converted with convert_lemma_dict.py.

How do I check if I was compromised?

No campaign indicators are published for this flaw. The advisory's proof of concept writes a marker file only to prove execution, so a marker path is not a production indicator.

Check the model supply chain instead:

  • List every .pt file that a Stanza pipeline loads, and compare its origin with your model registry or download log. A file with no recorded origin is a lead.
  • Review recent changes to lemma_model_path values and to any shared model directory. A new or replaced checkpoint is the delivery mechanism.
  • Review CI and notebook logs for model downloads from hosts other than Stanza's model repository. The default pipeline downloads from the project's own URLs.
  • Look for processes, files, or outbound connections that appeared when a model was loaded. The PoC runs a shell command, so the child process may appear in audit logs.

If a malicious checkpoint was loaded, treat the host as compromised. Rotate every secret the loading process could read, including cloud keys, API tokens, and Hugging Face tokens. Rebuild the environment from a known-good image rather than deleting a single file.

Timeline

The fix landed 14 days after 1.14.0, then waited 64 days for a release.

  • 2026-07-15: Stanza 1.14.0 is released with the version 2 gzip-pickle dictionary format.
  • 2026-07-29: The fix commit replaces pickle with orjson and adds the version 3 format.
  • 2026-10-01: Stanza 1.15.0 is released with the fix.
  • 2026-10-23: The CVE identifier is reserved.
  • 2026-11-03: The GitHub advisory and the CVE record are published.

The fix landed 14 days after the vulnerable release but waited 64 days for the 1.15.0 release, so users had no fixed version for more than two months.
Figure 1: Fixed in 14 days, shipped 64 days later. Source: Stanza release tags, fix commit and GitHub advisory, checked November 4, 2026.

FAQ

Is every Stanza version affected?

No. Only 1.14.0 writes and reads the vulnerable version 2 format. Releases up to 1.13.0 use the legacy tuple format, and 1.15.0 and later use the JSON version 3 format.

Does weights_only=True protect me?

No. The outer torch.load call is restricted, but the dicts field is a bytes object that the outer allowlist does not inspect. The unsafe call is the inner pickle.loads inside _unpack_pos_dict.

Is this the same as CVE-2026-54499?

No. CVE-2026-54499 covered a weights_only=False fallback in an earlier load path. This flaw stays inside the weights_only=True path and abuses the nested byte stream.

Do I need to re-download my models?

Yes for version 2 checkpoints. Stanza 1.15.0 rejects them with a ValueError that points to convert_lemma_dict.py. Re-download the default models, or convert a custom model before loading it.

Was the flaw exploited in the wild?

No exploitation is publicly known as of November 4, 2026. The advisory includes a confirmed end-to-end proof of concept, and the CVE is not in CISA KEV.

How risky is a shared model directory?

It is code execution for every account that can write to it. A user who can replace a .pt file can run code as every user or service that later loads that file, so treat the directory as a privileged code path.

Find vulnerable Stanza installs with Ostorlab

Ostorlab's software composition analysis and SBOM inventories the Python packages in your applications and flags the versions your manifests and lock files actually resolve. Use it to find Stanza 1.14.0 and the model-loading code paths that need the 1.15.0 upgrade.

References

  • GitHub, "Stanza: RCE via pickle deserialization in the lemmatizer" (GHSA-gh9r-c94j-cmp5, published November 3, 2026). https://github.com/advisories/GHSA-gh9r-c94j-cmp5 (checked November 4, 2026)
  • CVE Program, "CVE-2026-115878" (published November 3, 2026). https://www.cve.org/CVERecord?id=CVE-2026-115878 (checked November 4, 2026)
  • Stanford NLP, "Patch a security hole by using orjson to encode/decode the lemma dictionary instead of pickle" (commit b21e1c9b, authored July 29, 2026). https://github.com/stanfordnlp/stanza/commit/b21e1c9b47b4fccff21d3b0a6c0687a19410c2f0 (checked November 4, 2026)
  • Stanford NLP, "Use orjson for the lemma dictionary" (pull request 1649). https://github.com/stanfordnlp/stanza/pull/1649 (checked November 4, 2026)
  • Stanford NLP, "Stanza 1.15.0" (released October 1, 2026). https://github.com/stanfordnlp/stanza/releases/tag/v1.15.0 (checked November 4, 2026)
  • Stanford NLP, "stanza/models/lemma/trainer.py at v1.14.0" (source at the vulnerable tag). https://github.com/stanfordnlp/stanza/blob/v1.14.0/stanza/models/lemma/trainer.py (checked November 4, 2026)
  • PyPI Stats, "stanza" (weekly downloads, checked November 4, 2026). https://pypistats.org/packages/stanza (checked November 4, 2026)
  • CISA, "Known Exploited Vulnerabilities Catalog" (feed checked November 4, 2026). https://www.cisa.gov/known-exploited-vulnerabilities-catalog (checked November 4, 2026)