-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## `safe_mode` is a check on one `class_name`.\n\nThe decoder for a `.keras` archive's `config.json` is `deserialize_keras_object` in `keras/src/saving/serialization_lib.py`. In v3.8.0, the function's safe_mode branch is verbatim:\n\n```python\nif config[\"class_name\"] == \"__lambda__\":\n if safe_mode:\n raise ValueError(\n \"Requested the deserialization of a `lambda` object. \"\n \"This carries a potential risk of arbitrary code execution \"\n \"and thus it is disallowed by default. If you trust the \"\n \"source of the saved model, you can pass `safe_mode=False` \"\n \"to the loading function in order to allow `lambda` loading, \"\n \"or call `keras.config.enable_unsafe_deserialization()`.\"\n )\n return python_utils.func_load(inner_config[\"value\"])\n```\n\nThe check fires when, and only when, the config's `class_name` is the literal string `\"__lambda__\"`. That marker is produced by exactly one place in the codebase, `Lambda._serialize_function_to_config` in `keras/src/layers/core/lambda_layer.py`, and only when the function being wrapped has `__name__ == \"\"`. Any other callable, including a `def`-defined function, a class instance with `__call__`, or any importable Python function, never produces the marker on save and never triggers the check on load.\n\nForty lines later, in the same function:\n\n```python\n# Below: classes and functions.\nmodule = config.get(\"module\", None)\nregistered_name = config.get(\"registered_name\", class_name)\n\nif class_name == \"function\":\n fn_name = inner_config\n return _retrieve_class_or_fn(\n fn_name,\n registered_name,\n module,\n obj_type=\"function\",\n full_config=config,\n custom_objects=custom_objects,\n )\n\n# ...\n\ncls = _retrieve_class_or_fn(\n class_name,\n registered_name,\n module,\n obj_type=\"class\",\n full_config=config,\n custom_objects=custom_objects,\n)\n```\n\n`module` and `class_name` come straight from `config.json`. `_retrieve_class_or_fn` is called with both. The `safe_mode` parameter is not threaded into the call, and the function's body does not consult `safe_mode` either. The same `safe_mode` value that raises on `__lambda__` is in scope here and is read by no statement that runs.\n\n## The fork lives in the Lambda layer's own serializer.\n\nIn `keras/src/layers/core/lambda_layer.py`, v3.8.0:\n\n```python\ndef _serialize_function_to_config(self, fn):\n if isinstance(fn, types.LambdaType) and fn.__name__ == \"\":\n code, defaults, closure = python_utils.func_dump(fn)\n return {\n \"class_name\": \"__lambda__\",\n \"config\": {\n \"code\": code,\n \"defaults\": defaults,\n \"closure\": closure,\n },\n }\n elif callable(fn):\n return serialization_lib.serialize_keras_object(fn)\n```\n\nBoth branches are valid serializations of the function inside a Lambda layer. The branch is selected on `fn.__name__`. Python's `lambda x: ...` syntax produces a function whose `__name__` is `\"\"`; the `if` branch fires, the resulting config carries the `__lambda__` marker, and the loader's safe_mode check applies on the way back in. Python's `def malicious_function(x): ...` produces a function whose `__name__` is `\"malicious_function\"`; the `if` test fails on the name comparison; the function falls through to `serialize_keras_object`, which produces:\n\n```json\n{\n \"module\": \"__main__\",\n \"class_name\": \"function\",\n \"config\": \"malicious_function\",\n \"registered_name\": \"malicious_function\"\n}\n```\n\nNo `__lambda__` marker. On load, the `class_name == \"function\"` branch in `deserialize_keras_object` fires and resolves the name through `_retrieve_class_or_fn`, the path the safe_mode guard does not reach. The Lambda layer is the same Keras object in both cases. The serializer that built its config wrote two different JSON shapes for what is, at the language level, the same callable. One shape is gated. The other is not.\n\nThe Nuclei template chooses the `def` form. It does not need to bypass `safe_mode`. The flag never applied.\n\n```python\n# Excerpted from CVE-2025-1550.yaml's python source field\ndef malicious_function(x):\n result = os.popen(\"cat /etc/passwd\").read()\n print(result)\n return x\n\ninput_layer = Input(shape=(1,))\nlambda_layer = Lambda(malicious_function, name=\"evil_lambda\")(input_layer)\nmodel = Model(inputs=input_layer, outputs=lambda_layer)\nmodel.save(\"/tmp/malicious_model.keras\")\n\nloaded_model = load_model(\"/tmp/malicious_model.keras\")\nloaded_model.predict(np.array([[1.0]]), verbose=0)\n```\n\nThe save writes a `.keras` archive whose `config.json` references the function by module-and-name. The load defaults to `safe_mode=True`. The predict runs the function. The Nuclei runner matches `root:.*:0:0:` in stdout and reports a hit. The template's author did not write a single line of bypass logic.\n\n## The unguarded path resolves any module name to a callable.\n\n`_retrieve_class_or_fn` in v3.8.0 has the following body, after the `custom_objects` lookup and the `keras.*` short-circuit both fail:\n\n```python\n# Otherwise, attempt to retrieve the class object given the `module`\n# and `class_name`. Import the module, find the class.\ntry:\n mod = importlib.import_module(module)\nexcept ModuleNotFoundError:\n raise TypeError(\n f\"Could not deserialize {obj_type} '{name}' because \"\n f\"its parent module {module} cannot be imported. \"\n f\"Full object config: {full_config}\"\n )\nobj = vars(mod).get(name, None)\n\n# Special case for keras.metrics.metrics\nif obj is None and registered_name is not None:\n obj = vars(mod).get(registered_name, None)\n\nif obj is not None:\n return obj\n```\n\nThe arguments arrive from `config.json`. `importlib.import_module` will import any module the loader's `sys.path` can reach. `vars(mod).get(name)` returns any attribute by name. There is no allowlist of modules, no denylist of names, no consultation of `safe_mode`, and no requirement that the resulting object derive from a Keras class.\n\nThe caller, `deserialize_keras_object`, then has:\n\n```python\ncls = _retrieve_class_or_fn(class_name, registered_name, module, ...)\n\nif isinstance(cls, types.FunctionType):\n return cls\nif not hasattr(cls, \"from_config\"):\n raise TypeError(...)\n```\n\nWhen `cls` is a Python function, the deserializer returns it as-is. The \"deserialized object\" is the callable. When the surrounding Lambda layer is later invoked with arguments, it calls the callable with those arguments. The function-type early-return means a class with no `from_config` method, a constraint that would otherwise force the deserializer to instantiate through a Keras-aware path, is not required of the attacker's chosen target. Any callable in any importable module suffices.\n\nThe minimal exploit is a hand-crafted `config.json` inside a renamed-zip `.keras` archive. From the towerofhanoi.it writeup of this CVE:\n\n```json\n{\n \"module\": \"subprocess\",\n \"class_name\": \"run\",\n \"inbound_nodes\": [\n {\n \"args\": [\"/bin/sh\"],\n \"kwargs\": {}\n }\n ]\n}\n```\n\n`_retrieve_class_or_fn(\"run\", _, \"subprocess\", obj_type=\"class\", ...)` runs `importlib.import_module(\"subprocess\")` and `vars(subprocess).get(\"run\", None)`. The result is `subprocess.run`. The function-type early-return hands it back unwrapped. The `inbound_nodes` `args` are forwarded by Keras's graph construction at call time, and `subprocess.run(\"/bin/sh\")` executes. `safe_mode=True` is the default throughout. The flag's branch is never entered, because the marker the flag checks is never present.\n\n## The 3.9.0 patch does not extend `safe_mode`. It adds an allowlist.\n\nThe diff in `_retrieve_class_or_fn`, between v3.8.0 and v3.9.0, is twelve lines:\n\n```diff\n # Otherwise, attempt to retrieve the class object given the `module`\n # and `class_name`. Import the module, find the class.\n- try:\n- mod = importlib.import_module(module)\n- except ModuleNotFoundError:\n- raise TypeError(\n- f\"Could not deserialize {obj_type} '{name}' because \"\n- f\"its parent module {module} cannot be imported. \"\n- f\"Full object config: {full_config}\"\n- )\n- obj = vars(mod).get(name, None)\n-\n- # Special case for keras.metrics.metrics\n- if obj is None and registered_name is not None:\n- obj = vars(mod).get(registered_name, None)\n-\n- if obj is not None:\n- return obj\n+ package = module.split(\".\", maxsplit=1)[0]\n+ if package in {\"keras\", \"keras_hub\", \"keras_cv\", \"keras_nlp\"}:\n+ try:\n+ mod = importlib.import_module(module)\n+ obj = vars(mod).get(name, None)\n+ if obj is not None:\n+ return obj\n+ except ModuleNotFoundError:\n+ raise TypeError(\n+ f\"Could not deserialize {obj_type} '{name}' because \"\n+ f\"its parent module {module} cannot be imported. \"\n+ f\"Full object config: {full_config}\"\n+ )\n```\n\nThe post-patch `_retrieve_class_or_fn` will only call `importlib.import_module(module)` when the first dotted component of `module` is one of `keras`, `keras_hub`, `keras_cv`, or `keras_nlp`. `subprocess`, `os`, `socket`, `pickle`, `posix`, and every other module Python's path can reach no longer resolves through this function. `safe_mode` is not threaded into the function, and the `__lambda__` block above it is unchanged.\n\nThe flag named `safe_mode` is in the same file, in the same function, twenty-three lines higher in v3.9.0 as in v3.8.0. It still raises on `class_name == \"__lambda__\"`. It is still documented in `load_model`'s public signature as the API surface for unsafe lambda deserialization. The control that closes CVE-2025-1550 is on a different surface entirely. The maintainers wrote both controls. They did not write the second control as an extension of the first.\n\n## The guard named the marker. The bug was at the sink.\n\nThis is the shape [Sanitized The Secrets, Not The Sink](/patterns/sanitized-the-secrets-not-the-sink) names. A function applies a guard to one input shape the developer perceived as dangerous and omits the guard on a parallel input shape that flows into the same dangerous interpreter. The `__lambda__` check's error message names the threat by name, \"arbitrary code execution,\" and exists in the same function as the unguarded `importlib.import_module` call. The maintainer who wrote the check understood deserialization-as-code-execution as a category. The check was placed on the input form they imagined, not at the destination that actually fires.\n\n[ASUSTOR's `vpn.cgi`](/posts/asustor-vpn-escapes-credentials-not-server) is the previous exhibit on this pattern at a smaller seam: two of three fields interpolated into a pppd peer config get the escape helper, the third does not, and the third reaches `/bin/sh`. Keras's `deserialize_keras_object` is the same asymmetry on a wider seam. The 3.9.0 fix moves the check from the input form to the destination: `package in {\"keras\", ...}` is destination-first, asking where the resolved callable can come from rather than which marker the wire used. That is the same shift the ASUSTOR fix made, applied to a Python deserializer instead of a CGI handler.\n\nThe 3.9.0 fix also follows the shape [llama.cpp's RPC backend has refused to make](/posts/llama-cpp-cve-2026-34159-deserializer-three-cves-have-not-patched) across three CVEs: a per-call-site guard rather than a refactor of the deserializer itself. Keras's allowlist gates the function that reaches `importlib`. A future deserialization path that reaches an interpreter without going through `_retrieve_class_or_fn` would not be gated by the same allowlist. The flag named `safe_mode` is still in the function. The marker it checks is still `__lambda__`. The next attacker-controlled callable that reaches a sink through a route the patch did not enumerate produces the same primitive.\n\nThe Keras docstring is verbatim accurate. It says `safe_mode` disallows unsafe lambda deserialization. It says nothing else about safety, and it should not be read to.\n\nPoC: [projectdiscovery/nuclei-templates/CVE-2025-1550.yaml](https://github.com/projectdiscovery/nuclei-templates/blob/main/code/cves/2025/CVE-2025-1550.yaml)","closing_line":"","hook_md":"Keras 3's `keras.saving.load_model(filepath)` defaults to `safe_mode=True`. The function's docstring describes the flag verbatim: \"Boolean, whether to disallow unsafe `lambda` deserialization. When `safe_mode=False`, loading an object has the potential to trigger arbitrary code execution.\" Both sentences are accurate. Neither prevents the Nuclei template at `projectdiscovery/nuclei-templates/code/cves/2025/CVE-2025-1550.yaml`, which calls `load_model(path)` with no arguments and executes `os.popen(\"cat /etc/passwd\").read()` during `predict()`. The 3.9.0 patch closes the bug. The patch does not modify `safe_mode`. The flag is in the same function the patch changed, and the patch did not need it.\nThe flag is in the function. The fix is on a different control surface.","post_id":287,"slug":"keras-cve-2025-1550-safe-mode-not-in-the-patch","title":"CVE-2025-1550: The safe_mode Check Is Not in the Patch","type":"initial","unreadable_sentence":"The flag named `safe_mode` is in the same function as the bug. It is not in the patch."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCatzmHAAKCRDeZjl4jgkQ JnKpAQCaBRXLJyQFsY+a1d03whmXvuVXPfYIkPDFSMOUe3C5SQD/bivebQ4uilkw vc+pP+wJ+B3QwC9GD+IJc3xtD1RNYgU= =WAwQ -----END PGP SIGNATURE-----