YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
modelscan: .npz inner-member extension-routing bypass lets a malicious object-array evade all scanners (0 issues, no error) while numpy executes the embedded pickle
Target
- huntr target / package:
protectai/modelscan(ModelScan) - Affected tool & version: modelscan 0.8.8 (latest at time of report; logic unchanged on
main) - Format: NumPy
.npz(zip archive of.npymembers); loader desync againstnumpyNpzFile(reproduced on numpy 2.4.6) - Vulnerability class: Scanner bypass / format-routing desync leading to undetected arbitrary code execution at model-load time (CWE-693 Protection Mechanism Failure + CWE-502 Deserialization of Untrusted Data)
Severity (honest)
High impact, but it is a scanner-evasion bug, not a new parser RCE. modelscan is a security control whose entire purpose is to flag unsafe model files before a victim loads them. This bug makes a fully weaponized .npz come back clean (0 issues) with no error and no warning, while numpy.load(..., allow_pickle=True) still runs the attacker's pickle -> arbitrary code execution in the loader's process. The end-to-end outcome is RCE, achieved by defeating the scanner.
Dollar tier (per the program's format schedule): NumPy .npz/.npy is a pickle-family format, so this sits in the up to $1,500 tier, not the $4k tier. I am calling it that tier honestly rather than inflating it: the novel artifact here is the bypass of modelscan's routing, not a memory-safety/native RCE in a high-value binary format.
Summary
modelscan does descend into .npz archives and scans each inner member (this refutes the older "modelscan skips .npz" assumption). But it decides which scanner to run on each member purely from the member filename's extension. Only a member whose name ends in .npy is routed to the numpy scanner (the one that follows an object-dtype array into its pickle). numpy's NpzFile, in contrast, resolves a key and unpickles its bytes based purely on the content magic b"\x93NUMPY" β the member name does not have to end in .npy.
Therefore, renaming the malicious object-array member off the .npy extension β to arr_0.bin, or to a name with no extension at all β routes it to the wrong scanner (or to none). modelscan reports 0 issues, 0 errors. Yet np.load(npz, allow_pickle=True)[key] still unpickles that member and executes the embedded code.
The attacker controls the member name freely: it is just a zip entry name. A genuine np.savez(f, config=arr) would have named the member config.npy (and modelscan would catch it β see the control). Renaming it to config.bin / config is a one-line zip edit and changes nothing about how numpy loads it.
Root cause (file:line)
Two independent components disagree about how a .npz member is typed:
1. modelscan routes by member-name extension.
modelscan/middlewares/format_via_extension.py:7-15 β the format is derived from the source suffix:
class FormatViaExtensionMiddleware(MiddlewareBase):
def __call__(self, model, call_next):
extension = model.get_source().suffix # <-- member filename extension
formats = [fmt for fmt, exts in self._settings["formats"].items()
if extension in exts]
...
For a .npz, the "source" of each member is "<file>.npz:<member_name>", built while iterating the zip in modelscan/modelscan.py:98-112:
with zipfile.ZipFile(model.get_stream(), "r") as zip:
for file_name in zip.namelist():
with zip.open(file_name, "r") as file_io:
file_name = f"{model.get_source()}:{file_name}" # member name drives .suffix
...
yield Model(file_name, file_io)
The extension->format / extension->scanner tables only map .npy to numpy (modelscan/settings.py:55-58 and :78-82):
"modelscan.scanners.NumpyUnsafeOpScan": {"enabled": True, "supported_extensions": [".npy"]},
...
SupportedModelFormats.NUMPY: [".npy"],
SupportedModelFormats.PYTORCH: [".bin", ".pt", ".pth", ".ckpt"], # ".bin" -> torch scanner
So a member named arr_0.bin is handed to the PyTorch scanner, whose magic check fails -> the member is silently dropped. A member named arr_0 (no suffix) matches no format -> SCAN_NOT_SUPPORTED, also silently dropped. Neither raises an error.
2. numpy loads by content magic, ignoring the member name.
numpy/lib/npyio.py β NpzFile.__getitem__ unpickles whenever the member's first bytes are the numpy magic, regardless of the member's name/extension:
def __getitem__(self, key):
key = self._files[key]
with self.zip.open(key) as bytes:
magic = bytes.read(len(format.MAGIC_PREFIX))
bytes.seek(0)
if magic == format.MAGIC_PREFIX: # b"\x93NUMPY" β content, not name
return format.read_array(bytes, allow_pickle=self.allow_pickle, ...)
else:
return bytes.read()
format.read_array(..., allow_pickle=True) calls pickle.load for an object-dtype array β arbitrary code execution. The member name is never consulted for routing.
The desync: modelscan types the member by NAME; numpy types it by CONTENT. Anything that breaks the name/content agreement is a bypass.
Proof of Concept
Build / assertion
reproduce.py (included) is self-contained and portable. It builds one malicious object-array payload (a 1-element dtype=object array whose element's __reduce__ returns exec(<code>)), then packs identical bytes into three .npz files differing only in the inner member name:
- CASE 1 β member
arr_0.bin-> assert modelscantotal_issues == 0anderrors == [] - CASE 1b β member
arr_0-> assert modelscantotal_issues == 0anderrors == [] - CASE 2 β member
arr_0.npy-> CONTROL: assert modelscantotal_issues >= 1(CRITICAL) - CASE 3 β
np.load(evil, allow_pickle=True)[key]-> assert the payload executed
The payload is benign but writes a marker AND reads the first line of a sensitive file (/etc/passwd on Linux; the Windows hosts file as a fallback) into that marker, to demonstrate real arbitrary-read/exfil capability of the executed code β not merely a "function ran" signal.
Reviewer note on the CONTROL / numpy 2.x: modelscan 0.8.8's numpy scanner calls two helpers that were removed in numpy >= 2.0 (numpy.lib.format._check_version and _read_array_header). On a stock numpy 2.x install, the numpy scanner therefore errors even on a correctly-named .npy member (the plain CLI prints module 'numpy.lib.format' has no attribute '_check_version'). To make the control a working-scanner baseline β one that genuinely catches the payload rather than just erroring on this build β reproduce.py re-exposes those two helper names with pure-numpy equivalents before importing modelscan. The shims restore pre-2.x names only; they change no detection logic. The bypass cases (1 / 1b) do not depend on the shim at all β they are routed away from the numpy scanner entirely, so they read 0 issues with or without it. On numpy 1.x (where modelscan's numpy scanner works natively) no shim is needed; the control flags CRITICAL out of the box and the bypass cases still read 0.
Captured output (numpy 2.4.6, modelscan 0.8.8, via reproduce.py)
numpy: 2.4.6 | modelscan: 0.8.8
sensitive file read by payload: C:\Windows\System32\drivers\etc\hosts
[CASE 1 ] member 'arr_0.bin' (routed to torch scanner by extension)
modelscan: {"total_issues": 0, "by_severity": {"LOW":0,"MEDIUM":0,"HIGH":0,"CRITICAL":0}, "scanned": 0, "errors": [], "issues": []}
[CASE 1b] member 'arr_0' (no extension -> no scanner)
modelscan: {"total_issues": 0, "by_severity": {"LOW":0,"MEDIUM":0,"HIGH":0,"CRITICAL":0}, "scanned": 0, "errors": [], "issues": []}
[CASE 2 ] CONTROL member 'arr_0.npy' (routed to numpy scanner)
modelscan: {"total_issues": 1, "by_severity": {"CRITICAL":1,...}, "scanned": 1,
"issues": [["CRITICAL","exec","builtins","model_control_member.npz:arr_0.npy"]]}
[CASE 3 ] np.load(evil, allow_pickle=True)
keys = ['arr_0.bin']
code executed = True
marker = code-exec pid=33764 secret_first_line=# Copyright (c) 1993-2009 Microsoft Corp.
ASSERT bypass CASE1 (.bin == 0 issues, no error): True (issues=0, errors=0)
ASSERT bypass CASE1b (no-ext == 0 issues, no error): True (issues=0, errors=0)
ASSERT control CASE2 (.npy >= 1 CRITICAL) : True (issues=1)
ASSERT impact CASE3 (numpy executed payload) : True
RESULT: CONFIRMED NPZ MEMBER-NAME BYPASS
The raw modelscan CLI corroborates the silent-skip mechanism (stderr trimmed):
Scanning ...\model_evil_member.npz:arr_0.bin using modelscan.scanners.PyTorchUnsafeOpScan model scan
{"summary": {... "total_issues": 0 ...}, "issues": [], "errors": []}
i.e. modelscan explicitly picks the PyTorch scanner for the .bin member, finds no torch magic, and returns a clean report.
(poc_npz_member_name_bypass.py and poc_v2_api.py are the original PoCs and are included for reference; poc_v2_api.py additionally prints the skipped reasons β MAGIC_NUMBER "Invalid magic number" for .bin, SCAN_NOT_SUPPORTED for the no-extension member β which are surfaced only in the skipped list, not as errors, and do not change total_issues from 0.)
Impact / realistic threat model
modelscan is marketed and used as a pre-load gate for untrusted models pulled from hubs (Hugging Face, model registries, MLOps pipelines, CI scanning of third-party artifacts). The intended workflow is: download model -> modelscan -p model -> if clean, load it.
.npzis a first-class, routinely-distributed NumPy artifact (embeddings, weights, codebooks, label maps, preprocessing state). A consumer who serializes/loads withnp.load(..., allow_pickle=True)β extremely common, and the default in many community loaders β will execute object-array members.- The attacker fully controls inner member names (they are arbitrary zip entry names). Producing the malicious file is a single
zipfile.writestr("config", evil_npy_bytes)or a rename of one member in an otherwise-normalnp.savezoutput. No special tooling, no numpy-internals knowledge required by the attacker. - Result: a file that passes modelscan with 0 issues and no error/warning still achieves arbitrary code execution (and, as shown, arbitrary file read/exfil) the moment the victim loads it. The scanner provides false assurance β arguably worse than no scan, because it green-lights the artifact.
This is a defense-in-depth failure with a direct path to RCE on the victim, gated only by the victim using numpy's standard .npz loader with pickling enabled.
Honest duplicate / scope notes
- Scope: the affected tool is modelscan (protectai/modelscan), which is in huntr scope, and the format is NumPy
.npzβ a standard numpy format, not an out-of-scope serializer. So no special scope confirmation is required for this report. (Flagging per instructions: this is NOT modelaudit/picklescan, and NOT tensorizer/ggml/orbax β those would have needed a scope check; this one does not.) - Dup honesty: "modelscan format/extension confusion" is a known class of weakness, and a generic ".npz isn't scanned" claim has circulated. This report deliberately refutes the lazy version of that claim (modelscan DOES scan
.npzmembers and DOES catch a genuinenp.savezobject array β see the control) and pins the specific, still-present residual: that scanner selection per member is name-driven (FormatViaExtensionMiddleware,.npy-only numpy routing) while numpy loads by content magic, so a member renamed off.npyevades scanning entirely. If the maintainers consider any prior "npz/extension" report to already cover this exact member-rename vector, I'm happy to be told and to defer; I could not find a public report that demonstrates this with a working-scanner control proving it is the routing (not a pre-broken scanner) that is bypassed. - numpy-side caveat (disclosed, not hidden): the end-to-end RCE requires the consumer to call
np.load(..., allow_pickle=True). numpy gates object-array unpickling behindallow_pickleand warns about it; that is numpy behaving as documented and is not itself the bug. The bug being reported is modelscan failing to flag a file it is specifically meant to flag. The control (CASE 2) shows modelscan's own intent is to flag exactly this payload β it just fails to when the member is renamed.
Remediation
The fix belongs in modelscan: stop trusting the member-name extension for .npz members and instead route by content, matching how numpy actually loads.
- Content sniffing for zip members. When iterating a
.npz(or anysupported_zip_extensionsarchive), peek the first bytes of each member. If they equalnumpy.lib.format.MAGIC_PREFIX(b"\x93NUMPY"), force-route the member to the numpy scanner regardless of its filename extension. More generally, derive the scanner from magic bytes, not (or in addition to) the suffix, so a name/content mismatch cannot select a no-op scanner. - For
.npzspecifically: since every legitimate member of a realnp.savezarchive is a.npystream, treat every member of a.npzas a numpy member (or at minimum scan every member that begins with the numpy magic), instead of filtering by.npysuffix. - Fail loud on unhandled members inside a recognized container. A member of a
.npzthat is neither scanned nor explicitly known-safe should raise to a visible warning/error in the summary, not a silentSCAN_NOT_SUPPORTED/MAGIC_NUMBERskip that leavestotal_issues == 0. Silent skips inside a trusted container are exactly what makes this dangerous. - (Independent hardening, also worth fixing) modelscan 0.8.8's numpy scanner is broken on numpy >= 2.0 because it calls removed internals (
numpy.lib.format._check_version,_read_array_header). On a stock numpy 2.x box the numpy scanner errors even on a legitimately-named.npymember, so genuine malicious.npzfiles go uncaught there too. This should be ported to numpy 2.x APIs so the.npypath actually works.
Environment
- modelscan 0.8.8
- numpy 2.4.6 (Python venv
C:\Users\dougi\bb-review\mfvenv) - Reproduced on Windows 11;
reproduce.pyis OS-portable (usestempfile+/etc/passwdon Linux). Exit code 0 == bypass + control + impact all confirmed.