GitHub Security Advisory: GHSA-m7f4-hrc6-fwg3
Package: skops (PIP)
Severity: HIGH (CVSS 0.0)
CWE: CWE-351: Insufficient Type Distinction
Vulnerable versions: < 0.12.0
Patched in: 0.12.0

Summary: Skops has Inconsistent Trusted Type Validation that Enables Hidden `operator` Methods Execution

Details:
## Summary
An inconsistency in `OperatorFuncNode` can be exploited to hide the execution of untrusted `operator.xxx` 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.

**Note:** This report focuses on `operator.call` as it appears to be the most interesting target, but the same technique applies to other `operator` methods. Moreover, focusing on a specific example is not necessary, the `operator.call` invocation was a zero-effort choice meant solely to demonstrate the issue. The key point is the **inconsistency** that allows a user to approve a type as trusted, while in reality enabling the execution of `operator.xxx`.



## Details

The `OperatorFuncNode` allows calling methods belonging to the `operator` module and included in a trusted list of methods. However, what is returned by `get_untrusted_types` and checked during the `load` call is not exactly the same as what is actually called. Instead, it is something partially controlled by the model author. This means that the user checking the untrusted types can be tricked into thinking something benign is being used, while in reality the `operator.xxx` method is executed.

Let’s look at the implementation of the `OperatorFuncNode`:

```python
# from io/_general.py:618-633
class OperatorFuncNode(Node):
    def __init__(
        self,
        state: dict[str, Any],
        load_context: LoadContext,
        trusted: Optional[Sequence[str]] = None,
    ) -> None:
        super().__init__(state, load_context, trusted)
        self.trusted = self._get_trusted(trusted, [])
        self.children["attrs"] = get_tree(state["attrs"], load_context, trusted=trusted)

    def _construct(self):
        op = getattr(operator, self.class_name)
        attrs = self.children["attrs"].construct()
        return op(*attrs)
```

As you can see, what is called during construction is `operator.class_name`, where `class_name` is the value of the `"__class__"` key in the `schema.json` file of the `model.skops`. However, what is returned by `get_untrusted_types` and checked during `load` is the concatenation of the `__module__` and `__class__` keys. Interestingly, `__module__` is not used in the construction of the `OperatorFuncNode`, allowing an attacker to forge a module name that, when concatenated with the `__class__` name, seems harmless and related to the model being loaded, while actually calling the `operator.class_name` function.

For example, an attacker can create a `schema.json` file with the following content:

```json
{
  "__class__": "call",
  "__module__": "sklearn.linear_model._stochastic_gradient.SGDRegressor",
  "__loader__": "OperatorFuncNode",
  ...
}
```

What is returned by `get_untrusted_types` and checked during `load` is `"sklearn.linear_model._stochastic_gradient.SGDRegressor.call"`, which seems harmless and related to the model being loaded. However, what is actually call