CVE-2025-54413
Description
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.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
skopsPyPI | < 0.12.0 | 0.12.0 |
Patches
20aeca055509dENH harden Method and Operator node audits (#482)
8 files changed · +84 −16
.codecov.yml+1 −1 modified@@ -3,7 +3,7 @@ codecov: branch: main require_ci_to_pass: true notify: - after_n_builds: 12 + after_n_builds: 21 wait_for_ci: true ignore: - "skops/_min_dependencies.py" # This file is not tested, and won't be.
docs/changes.rst+6 −0 modified@@ -11,6 +11,12 @@ skops Changelog v0.12 ----- +- `huggingface_hub` dependency is now optional. :pr:`462` by `Adrin Jalali`_. +- Objects' `__reduce__` is used when the output of it is of the form + `(type, (constructor_args,)` where type is the same as the `type(obj)`. + :pr:`467` by `Adrin Jalali`_. +- `MethodNode` and `OperatorNode` have a hardened audit now, removing certain security + vulnerabilities. :pr:`482` by `Adrin Jalali`_. v0.11 -----
.github/workflows/build-test.yml+1 −1 modified@@ -27,7 +27,7 @@ jobs: ] # Timeout: https://stackoverflow.com/a/59076067/4521646 - timeout-minutes: 15 + timeout-minutes: 30 steps: # The following two steps are workarounds to retrieve the "real" commit
pyproject.toml+2 −0 modified@@ -90,6 +90,8 @@ filterwarnings = [ "ignore:The ExtraTreesQuantileRegressor or classes from which it inherits use `_get_tags` and `_more_tags`:FutureWarning", # BaseEstimator._validate_data deprecation warning in sklearn 1.6 #TODO can be removed when a new release of quantile-forest is out "ignore:`BaseEstimator._validate_data` is deprecated in 1.6 and will be removed in 1.7:FutureWarning", + # This comes from matplotlib somehow + "ignore:'mode' parameter is deprecated and will be removed in Pillow 13:DeprecationWarning", ] addopts = "--cov=skops --cov-report=term-missing --doctest-modules"
skops/io/_audit.py+4 −3 modified@@ -2,7 +2,7 @@ import io from contextlib import contextmanager -from typing import Any, Dict, Generator, List, Optional, Sequence, Type, Union +from typing import Any, Dict, Generator, Iterable, List, Optional, Sequence, Type, Union from ._protocol import PROTOCOL from ._utils import LoadContext, get_module, get_type_paths @@ -39,7 +39,7 @@ def check_type(module_name: str, type_name: str, trusted: Sequence[str]) -> bool return module_name + "." + type_name in trusted -def audit_tree(tree: Node) -> None: +def audit_tree(tree: Node, trusted: Iterable[str] | None) -> None: """Audit a tree of nodes. A tree is safe if it only contains trusted types. @@ -54,7 +54,8 @@ def audit_tree(tree: Node) -> None: UntrustedTypesFoundException If the tree contains an untrusted type. """ - unsafe = tree.get_unsafe_set() + trusted = trusted or set() + unsafe = tree.get_unsafe_set() - set(trusted) if unsafe: raise UntrustedTypesFoundException(unsafe)
skops/io/_general.py+30 −4 modified@@ -509,13 +509,15 @@ def method_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: # dependent on a specific instance of an object. # It stores the state of the object the method is bound to, # and prepares both to be persisted. + owner = obj.__self__ + func_name = obj.__func__.__name__ res = { - "__class__": obj.__class__.__name__, + "__class__": owner.__class__.__name__, "__module__": get_module(obj), "__loader__": "MethodNode", "content": { - "func": obj.__func__.__name__, - "obj": get_state(obj.__self__, save_context), + "func": func_name, + "obj": get_state(owner, save_context), }, } return res @@ -529,13 +531,32 @@ def __init__( trusted: Optional[Sequence[str]] = None, ) -> None: super().__init__(state, load_context, trusted) + obj = get_tree(state["content"]["obj"], load_context, trusted=trusted) + if self.module_name != obj.module_name or self.class_name != obj.class_name: + raise ValueError( + f"Expected object of type {self.module_name}.{self.class_name}, got" + f" {obj.module_name}.{obj.class_name}. This is probably due to a" + " corrupted or a malicious file." + ) self.children = { - "obj": get_tree(state["content"]["obj"], load_context, trusted=trusted), + "obj": obj, "func": state["content"]["func"], } # TODO: what do we trust? self.trusted = self._get_trusted(trusted, []) + def get_unsafe_set(self) -> set[str]: + res = super().get_unsafe_set() + obj_node = self.children["obj"] + res.add( + obj_node.module_name # type: ignore + + "." + + obj_node.class_name # type: ignore + + "." + + self.children["func"] + ) + return res + def _construct(self): loaded_obj = self.children["obj"].construct() method = getattr(loaded_obj, self.children["func"]) @@ -658,6 +679,11 @@ def __init__( trusted: Optional[Sequence[str]] = None, ) -> None: super().__init__(state, load_context, trusted) + if self.module_name != "operator": + raise ValueError( + f"Expected module 'operator', got {self.module_name}. This is probably" + " due to a corrupted or a malicious file." + ) self.trusted = self._get_trusted(trusted, []) self.children["attrs"] = get_tree(state["attrs"], load_context, trusted=trusted)
skops/io/_persist.py+2 −2 modified@@ -148,7 +148,7 @@ def load(file: str | Path, trusted: Optional[Sequence[str]] = None) -> Any: schema = json.loads(input_zip.read("schema.json")) load_context = LoadContext(src=input_zip, protocol=schema["protocol"]) tree = get_tree(schema, load_context, trusted=trusted) - audit_tree(tree) + audit_tree(tree, trusted=trusted) instance = tree.construct() return instance @@ -188,7 +188,7 @@ def loads(data: bytes, trusted: Optional[Sequence[str]] = None) -> Any: schema = json.loads(zip_file.read("schema.json")) load_context = LoadContext(src=zip_file, protocol=schema["protocol"]) tree = get_tree(schema, load_context, trusted=trusted) - audit_tree(tree) + audit_tree(tree, trusted=trusted) instance = tree.construct() return instance
skops/io/tests/test_audit.py+38 −5 modified@@ -1,15 +1,26 @@ import io import json +import operator import re from contextlib import suppress from zipfile import ZipFile import pytest from sklearn.linear_model import LogisticRegression +from sklearn.preprocessing import FunctionTransformer from skops.io import dumps, get_untrusted_types from skops.io._audit import Node, audit_tree, check_type, get_tree, temp_setattr -from skops.io._general import DictNode, JsonNode, ObjectNode, dict_get_state +from skops.io._general import ( + DictNode, + JsonNode, + MethodNode, + ObjectNode, + OperatorFuncNode, + dict_get_state, + method_get_state, + operator_func_get_state, +) from skops.io._utils import LoadContext, SaveContext, get_state, gettype @@ -46,26 +57,26 @@ def test_audit_tree_untrusted(): "Untrusted types found in the file: ['test_audit.CustomType']." ), ): - audit_tree(node) + audit_tree(node, None) # there shouldn't be an error with trusted=everything node = DictNode(state, LoadContext(None, -1), trusted=["test_audit.CustomType"]) - audit_tree(node) + audit_tree(node, None) untrusted_list = get_untrusted_types(data=dumps(var)) assert untrusted_list == ["test_audit.CustomType"] # passing the type would fix it. node = DictNode(state, LoadContext(None, -1), trusted=untrusted_list) - audit_tree(node) + audit_tree(node, None) def test_audit_tree_defaults(): # test that the default types are trusted var = {"a": 1, 2: "b"} state = dict_get_state(var, SaveContext(None, 0, {})) node = DictNode(state, LoadContext(None, -1), trusted=None) - audit_tree(node) + audit_tree(node, None) @pytest.mark.parametrize( @@ -170,3 +181,25 @@ def test_format_json_node(inp, expected): state = get_state(inp, SaveContext(None)) node = JsonNode(state, LoadContext(None, -1)) assert node.format() == expected + + +def test_method_node_invalid_state(): + # Test that MethodNode raises a ValueError if the state is invalid. + # The __class__ and __module__ should match what's inside the content. + var = FunctionTransformer().fit + state = method_get_state(var, SaveContext(None, 0, {})) + state["content"]["obj"]["__class__"] = "foo" + load_context = LoadContext(None, -1) + + with pytest.raises(ValueError, match="Expected object of type"): + MethodNode(state, load_context, trusted=None) + + +def test_operator_func_node_invalid_state(): + var = operator.methodcaller("fit") + state = operator_func_get_state(var, SaveContext(None, 0, {})) + state["__module__"] = "foo" + load_context = LoadContext(None, -1) + + with pytest.raises(ValueError, match="Expected module 'operator'"): + OperatorFuncNode(state, load_context, trusted=None)
ab61439ba35bVulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-4v6w-xpmh-gfgpghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-54413ghsaADVISORY
- drive.google.com/drive/folders/1bmVV18mnPbWy21hVYgf51yVJpf78vtB_nvdWEB
- github.com/io-no/CVE-Reports/tree/main/CVE-2025-54413ghsaWEB
- github.com/skops-dev/skops/commit/0aeca055509dfb48c1506870aabdd9e247adf603nvdWEB
- github.com/skops-dev/skops/releases/tag/v0.12.0nvdWEB
- github.com/skops-dev/skops/security/advisories/GHSA-4v6w-xpmh-gfgpnvdWEB
- github.com/skops-dev/skops/security/advisories/GHSA-m7f4-hrc6-fwg3nvdWEB
News mentions
0No linked articles in our index yet.