PoC β€” picklescan: .npy files are silently skipped when scanning a directory or a HuggingFace repo

Security research proof-of-concept. Do not load modeldir/weights_obj.npy with numpy.load(..., allow_pickle=True) unless you want the (harmless) marker to run.

What this shows

picklescan supports .npy (scan_bytes() dispatches to scan_numpy()), but the two walkers that iterate over a set of files β€” scan_directory_path() (--path <dir>) and scan_huggingface_model() (--huggingface <repo>) β€” filter candidates against _zip_file_extensions βˆͺ _pickle_file_extensions βˆͺ _pytorch_file_extensions and never consult _numpy_file_extensions. A .npy is dropped with continue before it ever reaches scan_bytes().

Result: the same file, byte for byte, is reported Infected files: 1 / exit 1 when passed directly, and Scanned files: 0 / Infected files: 0 / exit 0 when the directory containing it is scanned. There is no warning, no scan_err, no non-zero exit code β€” a CI job running picklescan --path model_dir/ && deploy passes green.

This is reported as a false negative / coverage gap in the scanner, which picklescan's own SECURITY.md explicitly asks to be reported. It is not claimed as remote code execution.

Files

path size what
modeldir/weights_obj.npy 409 B .npy with dtype=object; the embedded pickle is a REDUCE to builtins.exec that writes a sentinel text file in the current directory. Harmless, local, no network.
ctrl_npz/weights_obj.npz 549 B negative control β€” identical payload as .npz. picklescan does catch this one in directory mode, because .npz is in _zip_file_extensions.
gen_poc.py β€” regenerates both files from scratch
REPRO.sh β€” runs the four measurements below

Reproduce

python3 -m venv venv && ./venv/bin/pip install picklescan==1.0.5 numpy
./venv/bin/python gen_poc.py
bash REPRO.sh

Observed with picklescan 1.0.5, numpy 1.26.4, Python 3.13 (independently confirmed on a second machine with numpy 2.5.1):

run command result
1 picklescan --path modeldir/weights_obj.npy dangerous import 'builtins exec' FOUND Β· Scanned 1 / Infected 1 Β· exit 1
2 picklescan --path modeldir (same file) Scanned 0 / Infected 0 Β· exit 0 ← the bug
3 np.load('modeldir/weights_obj.npy', allow_pickle=True) sentinel file appears β€” the payload the scanner called clean is live
4 picklescan --path ctrl_npz (same payload as .npz) dangerous import 'builtins exec' FOUND Β· Infected 1 Β· exit 1

Runs (2) + (4) together are the point: identical payload, identical invocation mode, opposite verdict, and the only difference is that .npz appears in _zip_file_extensions while .npy appears only in _numpy_file_extensions, which the walkers never read.

Run (3) requires allow_pickle=True, which is not numpy's default (allow_pickle=False since numpy 1.16.3). It is included to show the payload is real, not to claim that any particular downstream project loads untrusted .npy that way.

The --huggingface <repo> path was exercised offline with picklescan.scanner._http_get monkey-patched (no network traffic): the .npy gives scanned_files=0, infected_files=0, and its URL is never even fetched; the same repo serving the .npz gives scanned_files=1, infected_files=1.

Payload safety

The embedded payload is exactly:

exec("open('PICKLESCAN_NPY_FAILOPEN_MARKER.txt','w').write('PICKLESCAN NPY FAIL-OPEN MARKER 0x11111111')")

It writes one text file in the working directory. No network, no persistence, nothing destructive, no absolute paths.

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support