You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

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

  1. Save a benign ndarray to establish the archive layout and obtain the .npy member name from schema.json:
    import numpy as np, skops.io as sio
    sio.dump(np.arange(5, dtype="f8"), "benign.skops")
    
  2. Craft a 128-byte .npy consisting 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,)}
  3. Rebuild the .skops zip with ZIP_STORED (uncompressed) using the original schema.json plus this tiny .npy under the same member name. Resulting file: 556 bytes, every member STORED.
  4. Call skops.io.load("evil.skops") with default trusted=None. numpy attempts to allocate 7.28 TiB for shape (1000000000000,) / float64 and raises numpy._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 .npy header (numpy.lib.format.read_magic / read_array_header_*) and reject the array if prod(shape) * dtype.itemsize exceeds the number of bytes actually available in the zip member (or a configurable ceiling). numpy stores no compression, so for a well-formed .npy the 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 .skops zip decompression bomb class (that relies on DEFLATE inflation ratio; here all members are STORED/uncompressed and the amplification is numpy-shape metadata in the _numpy.py construct path).
  • Distinct from skops arbitrary-code-execution / trusted= bypass reports β€” no untrusted type is involved and get_untrusted_types() returns []; the default trusted numpy.ndarray path 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.
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support