# skops DoS: default-trusted `NdArrayNode` subclass-cast gadget (`numpy.bytes_` / `numpy.void(int)`) **Target:** [`skops`](https://github.com/skops-dev/skops) — PyPI `skops==0.14.0` (latest release) **Component:** `skops/io/_numpy.py` — `NdArrayNode._construct` **Class:** Uncontrolled resource consumption / committed-memory DoS (CWE-789 / CWE-400) **Impact:** A ~400-byte `.skops` file drives an unbounded, fully-committed (zero-filled, non-lazy) heap allocation on `skops.io.load()`, using **only the default trust set** (no `trusted=` argument). The pre-load audit users are told to run — `skops.io.get_untrusted_types` — reports the file as **completely safe** (empty untrusted set). **Verified environment:** skops 0.14.0, numpy 2.5.1, scipy 1.18.0, CPython 3.13, Linux x86-64 (real PyPI install, no source patches). --- ## Root cause `NdArrayNode._construct` (`skops/io/_numpy.py`), for `type == "numpy"`: ```python def _construct(self): # Dealing with a regular numpy array, where dtype != object if self.type == "numpy": content = np.load(self.children["content"], allow_pickle=False) if f"{self.module_name}.{self.class_name}" != "numpy.ndarray": content = gettype(self.module_name, self.class_name)(content) # <-- gadget return content ``` For **any** class other than `numpy.ndarray`, skops **imports the attacker-named class** (`gettype(module, class)`) and **calls it with the loaded array**. The only gate is `is_self_safe()` — the class must appear in the node's default-trusted list, set in `NdArrayNode.__init__`: ```python self.trusted = self._get_trusted( trusted, [np.ndarray] + NUMPY_DTYPE_TYPE_NAMES # type: ignore ) ``` `NUMPY_DTYPE_TYPE_NAMES` (`skops/io/_trusted_types.py`) is the set of 24 public numpy scalar type names, **auto-trusted with no `trusted=` argument**: ``` ['numpy.bool', 'numpy.bytes_', 'numpy.clongdouble', 'numpy.complex128', 'numpy.complex64', 'numpy.datetime64', 'numpy.float16', 'numpy.float32', 'numpy.float64', 'numpy.int16', 'numpy.int32', 'numpy.int64', 'numpy.int8', 'numpy.longdouble', 'numpy.longlong', 'numpy.object_', 'numpy.str_', 'numpy.timedelta64', 'numpy.uint16', 'numpy.uint32', 'numpy.uint64', 'numpy.uint8', 'numpy.ulonglong', 'numpy.void'] ``` The **intended** semantics of `NUMPY_DTYPE_TYPE_NAMES` is safe dtype **casting** of the loaded array. But two members — `numpy.bytes_` and `numpy.void` — are **not dtype casters when handed an integer**. `np.load` can return a 0-d integer scalar; `numpy.bytes_(n)` and `numpy.void(n)` then behave like `bytes(n)` / `void(n)` and allocate a **raw buffer of `n` bytes**: - `numpy.bytes_(n) == bytes(n)` — a fully **committed, zero-filled** buffer of `n` bytes, with **no `2**31` cap**. - The integer `n` is fully attacker-controlled via the `.npy` scalar value. `bytes_` / `void` being int-buffer constructors — rather than dtype casters — is an unintended amplification gadget inside the auto-trusted set. **Compounding factor — the safety audit is blind to it.** `skops.io.get_untrusted_types` walks the same node tree. `NdArrayNode`'s only child (for `type=="numpy"`) is a trusted `BytesIO`, and its class (`numpy.bytes_`) is in the default-trusted list, so the audit returns an **empty** untrusted set. A defender who runs the documented `get_untrusted_types` pre-flight check before `load()` gets a green light on a malicious file. --- ## PoC Craft a `.skops` zip (skops files are zips) with: - `schema.json` = `{"__class__":"bytes_","__module__":"numpy","__loader__":"NdArrayNode","type":"numpy","file":"payload.npy",...}` - `payload.npy` = `np.save(np.int64(N))` (a 0-d int64 scalar) At `skops.io.load()`, `NdArrayNode._construct` runs `numpy.bytes_(np.int64(N))`, committing **N bytes** of real RSS. `N` is uncapped; an attacker sets `N = 10**12` (1 TB) or larger to force an OOM process kill / node crash. Files in this repo: - `poc_third_bug.py` — primary PoC (crafts file, runs `get_untrusted_types`, then `load`, measures RSS). - `poc_controls.py` — negative controls + constructor probe + uncapped confirmation. --- ## Captured evidence (verbatim, real execution) Environment: **skops 0.14.0, numpy 2.5.1, scipy 1.18.0**, CPython 3.13, Linux x86-64. No `trusted=` argument. ### Primary PoC — `numpy.bytes_`, N = 3,000,000,000 ``` == file bytes: 405 == get_untrusted_types: [] == loaded: numpy.bytes_ len 3000000000 == committed RSS delta MB: 2861 ``` A **405-byte** file commits **2861 MB** of real (zero-filled, non-lazy) RSS — a ~7.4-million-x amplification — while the documented `get_untrusted_types` audit reports the file as fully safe (`[]`). ### Constructor probe (arg = `np.int64(2147483647)`) ``` bytes_ int2G: OK itemsize/nbytes=(2147483647, 2147483647) dRSS_MB=2048 void int2G: OK itemsize/nbytes=(2147483647, 2147483647) dRSS_MB=0 ``` `numpy.bytes_(int)` commits the full buffer (2048 MB); `numpy.void(int)` allocates the same `nbytes` but lazily (2 GB-capped variant). ### Uncapped confirmation — no `2**31` cap ``` np.bytes_(3000000000) len 3000000000 dRSS_MB 812 np.bytes_(10000000000) len 10000000000 dRSS_MB 9535 ``` `len` grows linearly with the attacker-supplied `N` past `2**31` and past `2**32` — `numpy.bytes_(n)` behaves as `bytes(n)` with no cap. (RSS deltas here are peak-since-process-start via `ru_maxrss`, hence non-monotonic ordering; the load-in-a-fresh-process delta is the 2861 MB figure above.) ### Negative control 1 — legitimate `int64` cast (allocation is NOT from `np.load` or the file) ``` NEG1 legit int64, value 3000000000 -> loaded numpy.int64 np.int64(3000000000) RSS delta MB: 0 ``` An identical file with `__class__="int64"` (a legitimate dtype cast) loads `np.int64(3000000000)` with **0 MB** RSS delta — proving the allocation comes specifically from the `bytes_`/`void` constructor gadget, not from `np.load` or the `.npy` payload. ### Negative control 2 — untrusted class is correctly rejected (trust mechanism works in general) ``` NEG2 untrusted os.system NdArrayNode -> get_untrusted_types: ['os.system'] ``` An `NdArrayNode` with `__module__="os", __class__="system"` is correctly flagged (`['os.system']`) — proving the default-trust mechanism is enforced in general, and that `numpy.bytes_` / `numpy.void` slip through **specifically** because they are members of the auto-trusted `NUMPY_DTYPE_TYPE_NAMES` set. --- ## Why this is distinct from the two already-covered skops DoS bugs This is a **separate code path and a separate trusted type** from the two other skops memory-DoS issues: 1. **Zip decompression bomb** (zip inflation ratio) — attacks the zip container layer. 2. **`.npy` shape-metadata allocation** — `np.load` allocating a large array from an oversized *shape header* in the `.npy` file. Here, `np.load` returns a **tiny 0-d scalar** — the `.npy` payload is small and its shape header is trivial. The amplification is the **`gettype(module, class)(content)` subclass-cast** calling `numpy.bytes_(int)` / `numpy.void(int)` to allocate a raw buffer. Different trigger, different trusted type, different mechanism. --- ## Suggested fix - Do not route non-`ndarray` numpy classes through a bare `gettype(...)(content)` call. For `NdArrayNode` restrict the constructed type to genuine dtype casting (e.g. `content.astype(dtype)` for scalar dtypes) rather than calling the type object with the loaded value. - Remove `numpy.bytes_` and `numpy.void` from the auto-trusted dtype set, or special-case them so an **integer** argument cannot be interpreted as a buffer length. - Bound the reconstructed object size against the on-disk payload size during audit, so `get_untrusted_types` / `load` cannot be tricked by a scalar-driven buffer allocation. --- ## Dedup / prior-art note - Distinct from CVE-class skops issues around the zip decompression bomb and the `.npy` shape-header allocation (see "Why this is distinct" above): different code path (`_construct` subclass-cast), different trusted type (`numpy.bytes_` / `numpy.void`), different mechanism (int→raw-buffer constructor). - No public advisory/CVE was found describing the `NdArrayNode` `gettype(...)(content)` subclass-cast as a committed-memory amplification gadget, nor the fact that `get_untrusted_types` reports such a file as safe. - Reachable with the **default** trust set — no `trusted=` bypass required.