YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
- skops DoS: default-trusted
NdArrayNodesubclass-cast gadget (numpy.bytes_/numpy.void(int))- Root cause
- PoC
- Captured evidence (verbatim, real execution)
- Primary PoC β
numpy.bytes_, N = 3,000,000,000 - Constructor probe (arg =
np.int64(2147483647)) - Uncapped confirmation β no
2**31cap - Negative control 1 β legitimate
int64cast (allocation is NOT fromnp.loador the file) - Negative control 2 β untrusted class is correctly rejected (trust mechanism works in general)
- Primary PoC β
- Why this is distinct from the two already-covered skops DoS bugs
- Suggested fix
- Dedup / prior-art note
- Root cause
skops DoS: default-trusted NdArrayNode subclass-cast gadget (numpy.bytes_ / numpy.void(int))
Target: 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":
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__:
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 ofnbytes, with no2**31cap.- The integer
nis fully attacker-controlled via the.npyscalar 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, runsget_untrusted_types, thenload, 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:
- Zip decompression bomb (zip inflation ratio) β attacks the zip container layer.
.npyshape-metadata allocation βnp.loadallocating a large array from an oversized shape header in the.npyfile.
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-
ndarraynumpy classes through a baregettype(...)(content)call. ForNdArrayNoderestrict 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_andnumpy.voidfrom 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/loadcannot 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
.npyshape-header allocation (see "Why this is distinct" above): different code path (_constructsubclass-cast), different trusted type (numpy.bytes_/numpy.void), different mechanism (intβraw-buffer constructor). - No public advisory/CVE was found describing the
NdArrayNodegettype(...)(content)subclass-cast as a committed-memory amplification gadget, nor the fact thatget_untrusted_typesreports such a file as safe. - Reachable with the default trust set β no
trusted=bypass required.