- PoC for Huntr Report β NumPy Compatibility Regression Can Lead to Fail-Open Integrations by Reporting Zero Issues for Unscanned Malicious NPY/NPZ Files
- Why this is security-relevant
- 1. The regression, precisely bounded
- 2. A/B proof: this is a regression, not "never worked"
- 3. JSON / structured output
- 4. Mixed-directory scan (more realistic than a single isolated file)
- Impact and honest scope
- Environment
- Files in this repository
- Reproduction
- Suggested fixes
- Why this is security-relevant
PoC for Huntr Report β NumPy Compatibility Regression Can Lead to Fail-Open Integrations by Reporting Zero Issues for Unscanned Malicious NPY/NPZ Files
Target: ModelScan (ProtectAI) β github.com/protectai/modelscan, the scanning tool huntr's own Model File Vulnerability program names as the reference defense ("Unique methods to bypass our existing model scanning tools"). Reported here through the NPY / NPZ huntr targets, since the concrete demonstration is a malicious .npy/.npz file that the tool fails to flag.
CWE: CWE-755 (Improper Handling of Exceptional Conditions), secondary CWE-693 (Protection Mechanism Failure)
Affected: modelscan==0.8.8 (current, latest on PyPI) with numpy>=2.3.0 (current, latest is 2.4.6). modelscan's own pyproject.toml declares numpy = ">=1.24.3" with no upper bound, so this combination is what a completely standard pip install modelscan produces today.
Why this is security-relevant
This is not simply a NumPy compatibility bug. ModelScan's entire purpose is to tell a user or a pipeline whether a model file is safe to load. The failure happens inside a security control, and the control's own output actively reports a clean result:
summary.total_issuesis0.scanned.total_scannedis0(nothing was actually scanned).- A separate
errorsarray records the real cause.
A workflow, script, or reviewer that checks whether issues were reported (total_issues == 0 in JSON, or reads the prominent "No issues found! π" summary line in the console output) without separately checking errors/scanned or the process exit code can reasonably conclude the file is safe, when in fact it was never scanned at all. See "Impact and honest scope" below for exactly what is and is not proven about how likely that is.
1. The regression, precisely bounded
ModelScan's NumPy/pickle scanner (modelscan/tools/picklescanner.py, scan_numpy()) calls a private NumPy internal function, numpy.lib.format._check_version(), to validate the .npy header before deciding whether the array is object-dtype (and therefore needs pickle-opcode scanning). This function was removed from NumPy's public-ish internals at a specific version boundary, found here by direct binary search across released versions:
| NumPy version | numpy.lib.format._check_version |
|---|---|
| 1.24.3 β 2.2.0 | Present β scanner works |
| 2.3.0 and later (including current 2.4.6) | Absent β scanner raises AttributeError and never completes |
modelscan's declared dependency (numpy >= 1.24.3, no ceiling) does not prevent installing the broken combination.
2. A/B proof: this is a regression, not "never worked"
The identical PoC file (evil_bypass.npy, produced by generate_poc.py) was scanned against both environments, changing only the installed NumPy version:
With NumPy 2.2.0 (compatible):
Scanning .../evil_bypass.npy using modelscan.scanners.NumpyUnsafeOpScan model scan
--- Summary ---
Total Issues: 1
- CRITICAL: 1
--- CRITICAL ---
Unsafe operator found:
- Description: Use of unsafe operator 'system' from module 'nt'
EXIT_CODE=1
With NumPy 2.4.6 (current):
Error encountered from scanner modelscan.scanners.NumpyUnsafeOpScan with path .../evil_bypass.npy:
module 'numpy.lib.format' has no attribute '_check_version'
--- Summary ---
No issues found! π
--- Errors ---
Error 1: module 'numpy.lib.format' has no attribute '_check_version'
EXIT_CODE=3
Full captured output for both: raw_output_compatible_numpy.txt, raw_output_current_numpy.txt.
This confirms ModelScan does correctly detect this exact payload when its dependency is compatible -- the finding is specifically about the current, unpinned-numpy state of the tool, not a claim that ModelScan never had NPY/NPZ detection capability.
3. JSON / structured output
{
"summary": {
"total_issues_by_severity": {"LOW": 0, "MEDIUM": 0, "HIGH": 0, "CRITICAL": 0},
"total_issues": 0,
"scanned": {"total_scanned": 0}
},
"issues": [],
"errors": [
{"category": "MODEL_SCAN", "description": "module 'numpy.lib.format' has no attribute '_check_version'", "source": "evil_bypass.npy"}
]
}
Full output: raw_output_json.txt, scan_result.json. total_issues: 0 and scanned.total_scanned: 0 sit in summary, structurally separate from errors. This is verified, structured evidence that a consumer of this JSON has to specifically check the errors array (or the process exit code) to learn the scan did not complete -- checking total_issues alone is not sufficient to conclude the file was scanned and found clean.
4. Mixed-directory scan (more realistic than a single isolated file)
A directory containing one benign file (clean_weights.npy) and one malicious file (model_weights.npy, same payload as evil_bypass.npy) was scanned together:
Error encountered ... clean_weights.npy: module 'numpy.lib.format' has no attribute '_check_version'
Error encountered ... model_weights.npy: module 'numpy.lib.format' has no attribute '_check_version'
--- Summary ---
No issues found! π
--- Errors ---
Error 1: ...
Error 2: ...
EXIT_CODE=3
Full output: raw_output_mixed_directory.txt. Behavior is identical to the single-file case: both files fail to scan for the same reason, and the summary banner is unchanged.
Impact and honest scope
- Exit code: non-zero (
2or3depending on scenario) in every case tested here. A CI/CD gate that checks the process exit code for zero is not bypassed at that level -- this was directly tested, not assumed. - JSON
total_issuesfield:0, structurally separate from theerrorsarray, in every case tested here. This is a real, reproducible machine-readable field that would read as "clean" to any consumer that checks it without also checkingerrorsorscanned.total_scanned. - Console summary: the "No issues found! π" line is the first and most prominent output in every case tested here, with the actual error only visible in a separate section below it.
- This report does not claim that any specific real-world integration only checks
total_issues, or that CI gates are broadly defeated -- that was not tested and is not asserted. The claim is scoped to what was directly reproduced: NPY/NPZ scanning silently fails to complete on the current supported NumPy range, and the tool's own summary output (both console and JSON) can lead to fail-open behavior in integrations that rely onsummary.total_issueswithout checking reported scan errors.
Environment
modelscan==0.8.8
numpy==2.4.6 (broken) / numpy==2.2.0 (compatible, for the A/B test)
Python 3.11.15
Windows 11 Home 10.0.26200 (Build 26200)
Files in this repository
| File | Purpose |
|---|---|
generate_poc.py |
Builds evil_bypass.npy, evil_bypass.npz, and clean_weights.npy. |
evil_bypass.npy, evil_bypass.npz |
The PoC malicious files (harmless payload -- only creates a local marker file). |
clean_weights.npy |
Benign file used in the mixed-directory test. |
mixed_scan_dir/ |
The mixed-directory scenario (one clean, one malicious file). |
raw_output_current_numpy.txt |
Console output, single file, numpy 2.4.6 (broken). |
raw_output_compatible_numpy.txt |
Console output, single file, numpy 2.2.0 (compatible) -- the A/B proof. |
raw_output_json.txt, scan_result.json |
JSON structured output, numpy 2.4.6 (broken). |
raw_output_mixed_directory.txt |
Console output, mixed directory, numpy 2.4.6 (broken). |
Reproduction
pip install "modelscan==0.8.8" numpy psutil
python generate_poc.py
# Broken (current numpy):
modelscan -p evil_bypass.npy
modelscan scan -p evil_bypass.npy -r json -o scan_result.json
modelscan -p mixed_scan_dir
# A/B: install a compatible numpy and repeat the first command
pip install "numpy==2.2.0"
modelscan -p evil_bypass.npy
Suggested fixes
- Stop relying on the private/internal
numpy.lib.format._check_version; use NumPy's public API for.npyheader validation, or vendor the minimal parsing logic needed. - Cap the
numpydependency range inpyproject.tomlto versions actually tested against, until the above is fixed. - When a scanner raises an exception, do not present a clean "No issues found" summary; surface unscanned/errored files as a distinct, non-clean top-level state (both in the console banner and in the JSON
summary), separate from "scanned and found zero issues." - Add a CI regression test that scans a known-malicious
.npy/.npzfixture against the full supported NumPy version range and assertstotal_issues > 0, not just that the process doesn't crash.