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.