| --- |
| license: apache-2.0 |
| tags: |
| - security |
| - proof-of-concept |
| - modelscan |
| --- |
| |
| # PoC β ModelScan's NumPy scanner is dead on numpy β₯ 2.0 |
|
|
| **Security research artefact for a huntr Model File Vulnerability report. Not a |
| usable model. Do not load this file outside a disposable sandbox.** |
|
|
| ## What is in this repo |
|
|
| `embeddings.npy` β 431 bytes, sha256 |
| `c021a6fb90c674253a10cebfca50e16cb723fabf7e6b3ae39370fc47dc2dfa7f` |
|
|
| An ordinary object-dtype NumPy array. `.npy` stores object arrays by embedding a |
| pickle stream after the header, so `np.load(..., allow_pickle=True)` unpickles β |
| and executes β it. **The payload is inert**: it creates `/tmp/huntr_exec/NPY` and |
| nothing else. It is a stand-in for arbitrary code. |
|
|
| ## The issue |
|
|
| `modelscan/tools/picklescanner.py:228-237`: |
|
|
| ```python |
| elif magic == np.lib.format.MAGIC_PREFIX: |
| # .npy file |
| version = np.lib.format.read_magic(stream) |
| np.lib.format._check_version(version) # removed in numpy 2.0 |
| _, _, dtype = np.lib.format._read_array_header(stream, version) # also removed |
| |
| if dtype.hasobject: |
| return scan_pickle_bytes(model, settings, scan_name, True, stream.tell()) |
| else: |
| return ScanResults([], [], []) |
| ``` |
|
|
| Both `_check_version` and `_read_array_header` are **private** numpy helpers and |
| were removed in numpy 2.0. On numpy β₯ 2 the first call raises `AttributeError`. |
| ModelScan catches it, records it under `--- Errors ---`, and still prints: |
|
|
| ``` |
| --- Summary --- |
| No issues found! π |
| ``` |
|
|
| The exception is raised **before** the `dtype.hasobject` branch, so every `.npy` |
| file is affected β benign and malicious alike. The NumPy scanner is effectively |
| disabled. |
|
|
| `pyproject.toml` declares `numpy = ">=1.24.3"` with no upper bound, so a fresh |
| `pip install modelscan` resolves numpy 2.x and ships this state by default. |
|
|
| ## Reproduction |
|
|
| Same modelscan 0.8.8, same file, only numpy differs: |
|
|
| ```bash |
| # numpy 1.x β scanner works |
| pip install modelscan "numpy<2" |
| modelscan -p embeddings.npy |
| # Total Issues: 1 |
| # - Severity: CRITICAL |
| # - Description: Use of unsafe operator 'exec' from module 'builtins' |
| |
| # numpy 2.x β scanner is dead |
| pip install modelscan "numpy>=2" |
| modelscan -p embeddings.npy |
| # No issues found! π |
| # --- Errors --- |
| # module 'numpy.lib.format' has no attribute '_check_version' |
| ``` |
|
|
| Confirming the file really executes (run in a sandbox): |
|
|
| ```python |
| import numpy as np |
| np.load("embeddings.npy", allow_pickle=True) |
| # -> /tmp/huntr_exec/NPY now exists |
| ``` |
|
|
| A benign `np.save("x.npy", np.arange(10))` triggers the same `AttributeError`, |
| which confirms the failure is universal rather than payload-specific. |
|
|
| ## Measured results |
|
|
| | modelscan | numpy | verdict | |
| |---|---|---| |
| | 0.8.8 | 1.26.4 | **Total Issues: 1 β CRITICAL** | |
| | 0.8.8 | 2.4.6 | **No issues found** (scanner raised) | |
|
|
| ## Impact |
|
|
| This is not an evasion an attacker has to craft β it is a silent regression that |
| disables an entire scanner class by default. Anyone who installed ModelScan after |
| numpy 2.0 shipped (June 2024) has had zero `.npy` coverage while the tool |
| reported success. The `--- Errors ---` section is easy to miss, and CI normally |
| keys on the exit status or issue count rather than on stderr noise. |
|
|
| ## Scope note |
|
|
| Executing a malicious `.npy` still requires the loading code to pass |
| `np.load(..., allow_pickle=True)`; numpy has defaulted that to `False` since |
| 1.16.5. So this is not unauthenticated RCE on its own. It is a complete failure |
| of the control that exists to catch exactly the `allow_pickle=True` case, which |
| remains common for object arrays, legacy datasets and research pipelines. |
|
|
| ## Suggested fix |
|
|
| Stop calling numpy private APIs β `numpy.lib.format.read_array_header_1_0` / |
| `read_array_header_2_0` are public, or parse the dtype from the header dict. |
| Separately: cap the dependency (`numpy>=1.24.3,<3`), and make a scanner that |
| raises during a scan produce a failing or inconclusive result instead of being |
| folded into "No issues found". |
|
|