Views
No views yet
GHSA-36rr-ww3j-vrjv) closed the
safe_mode bypass on load_model_from_hdf5 only. Three other public
Keras 3 APIs reach Lambda.from_config through a legacy code path that
never installs a SafeModeScope. The same payload that the original
CVE addressed runs unimpeded through Sequential.from_config,
Model.from_config, and keras.utils.legacy.deserialize_keras_object
on a Python process whose only setup is import keras. Master HEAD
re-verified on 2026-05-26 (PRs #22180, #22728, #22800 since the fix; none
touches the vulnerable path).1git lfs install # if not already
2git clone https://huggingface.co/Joxstal/keras-cve-2025-9905-incomplete-fix-poc
3cd keras-cve-2025-9905-incomplete-fix-poc
4bash run_phase4.shOK: 3 witnesses observed (C3 sequential, C4 functional, C1 layer)python:3.11-slim image (pinned by sha256
digest), runs the payload generator once, then runs each of the three
victim scripts with --network=none. Three pwned_phase4_* witness
files are dropped on the host filesystem, one per vulnerable entry point.
Total wall time on a typical laptop: ~80 seconds for the first run,
~30 seconds on subsequent runs (Docker layer cache hit).poc/phase4/
in the source repository):Dockerfile.minimal # python:3.11-slim, pinned versions
build_malicious_json.py # payload generator
victim_sequential.py # C3: keras.Sequential.from_config
victim_functional.py # C4: keras.Model.from_config
victim_legacy_deserialize.py # C1: keras.utils.legacy.deserialize_keras_object
run_phase4.sh # orchestrator (one-command repro)
malicious_sequential.json # 902 B payload for C3
malicious_functional.json # 1270 B payload for C4
malicious_layer.json # 588 B payload for C1
phase4.log # full execution log (23 KB)
pwned_phase4_* # 3 witness files from 2026-05-10 run
README.md # this filepoc/phase4/run_phase4.sh reflect
the original source layout; on this HF repository, drop the poc/phase4/
prefix.Repository:keras-team/keras. Drafted 2026-05-10 by Jo Stamand from reproduction artifacts inpoc/phase4/(commit-pinned to Kerasb491c860, tagv3.11.3).
keras 3.11.3 Lambda RCE: incomplete fix for CVE-2025-9905keras on PyPI.3.11.3 (commit
b491c860fc2750e2b6006b55358d3251dbb4a9f0, tag v3.11.3). The vulnerable
code paths exist on master as of this writing and on all Keras 3.x
releases that ship the legacy serialization module, including the lines
patched for CVE-2025-9905. Anything >= 3.0.0 should be treated as
candidate-vulnerable pending the fix proposed in section 6.CVE-2025-9905, advisory GHSA-36rr-ww3j-vrjv. Fix on master: PR
#21602, commit ac5c97fb57c36fad4da14eaec04f981d3ad6bdb9.
Squash-merge into release branch r3.11: PR #21607, commit
b491c860 (tag v3.11.3).| Path | Function | Lines on v3.11.3 | Issue |
|---|---|---|---|
keras/src/legacy/saving/serialization.py | deserialize_keras_object | 414 to 549 | no safe_mode parameter, no SafeModeScope installed at entry |
keras/src/legacy/saving/saving_utils.py | model_from_config | 23 to 90 | no safe_mode parameter, delegates to the legacy deserialize above |
keras/src/models/sequential.py | Sequential.from_config | 353 to 383 | no safe_mode parameter, dispatches to model_from_config for layer configs that lack the modern "module" field |
keras/src/models/functional.py | functional_from_config | 463 to 631 | same branching pattern as Sequential.from_config |
keras/api/utils/legacy/__init__.py | re-export of deserialize_keras_object | (whole file) | public entry point for vector C1 |
keras/src/layers/core/lambda_layer.py | Lambda.from_config and _raise_for_lambda_deserialization | 183 to 232 (method); 170 (helper) | the gate the official fix relies on; only fires when an enclosing SafeModeScope is set to True, which none of the three APIs above installs |
AV:L/AC:L/AT:P/PR:N/UI:A/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H.load_model_from_hdf5. The present report identifies three additional
public entry points that ignore the same safe_mode protection because
they reach Lambda.from_config through a legacy deserialization path
that the CVE fix did not patch. These are not a variant of the original
CVE: they are the unaddressed scope of the same fix on three distinct,
documented, public APIs.keras.Sequential.from_config(config), the inverse of
Sequential.get_config(); keras.Model.from_config(config), the same
role for Functional and subclassed models; and
keras.utils.legacy.deserialize_keras_object(config, module_objects=...),
re-exported in the public namespace under both keras.utils.legacy and
keras.legacy.saving. None is marked @deprecated in v3.11.3, none is
hidden behind a private prefix, and the Keras 3 documentation describes
them as the supported way to materialize a model or a single object from
a serialized configuration.import keras produces by default: no call to
keras.config.enable_unsafe_deserialization(), no safe_mode=False
keyword passed to any function, no environment variable, no global flag,
no SafeModeScope(False). A Python process that has only ever called
import keras and then one of the three public APIs above, on a file the
attacker controls, is fully exploited.legacy_h5_format.load_model_from_hdf5). The same payload Lambda flows
unimpeded through three other public APIs that bypass that loader. This
is not a variant of the CVE: it is the unaddressed scope of the same fix.SafeModeScope go unenforced on
these three APIs is the asymmetry between the modern and legacy
deserialization paths.serialization_lib.SafeModeScope is a context manager that writes a
boolean into keras.src.backend.common.global_state under the key
safe_mode_saving. While the scope is open,
serialization_lib.in_safe_mode() returns that boolean to any caller,
and Lambda.from_config reads it:1safe_mode = safe_mode or serialization_lib.in_safe_mode()
2cls._raise_for_lambda_deserialization(safe_mode)safe_mode=True default parameter to
legacy_h5_format.load_model_from_hdf5 and opens a
SafeModeScope(safe_mode) before calling model_from_config on the
serialized config. With that scope open, the Lambda.from_config gate
above fires and raises before python_utils.func_load(code) runs. This
is correct for the call path that goes through load_model_from_hdf5,
which is what CVE-2025-9905 covers.keras/src/saving/serialization_lib.py:deserialize_keras_object)
installs the same scope at line 730 of v3.11.3, before dispatching to
cls.from_config(inner_config). Any sub-deserialization that re-enters
the legacy serializer for a particular layer (because the layer config
lacks the modern "module" field) still finds in_safe_mode() == True
and is correctly blocked. This is why keras.models.model_from_json is
protected by default; case A of poc/phase3/test_c2_model_from_json.py
confirms the raise.deserialize_keras_object in
keras/src/legacy/saving/serialization.py:414 takes no safe_mode
parameter and installs no scope. Its protection has always been
extrinsic: it depends on whoever called it having installed a
SafeModeScope upstream. CVE-2025-9905 added exactly one such installer,
in one loader. The three APIs flagged in section 2 reach
Lambda.from_config through that unprotected legacy path; once there,
in_safe_mode() returns None, falsy, and the gate is skipped.model_from_json (modern path
posts a scope) and accepted when delivered as a JSON file passed to
Sequential.from_config (legacy path posts no scope). Any integrator
who reaches the three flagged APIs on user-controlled JSON, with default
Keras state, exposes the user to arbitrary code execution.Lambda.from_config itself: the
output_shape and arguments fields go through the same legacy
deserialization path at lines 200, 219, and 229 of
lambda_layer.py. A defense that closes only the main function field
without closing those auxiliary fields leaves three sub-vectors open..h5 model
file with a malicious Lambda, and the victim opens it with the modern
loader keras.models.load_model(path). The fix changes the default of
safe_mode in load_model_from_hdf5 to True, propagates that value to
a SafeModeScope opened before the call to model_from_config, and lets
Lambda.from_config raise at the inner gate.Sequential.from_config (vector C3 of
our enumeration, confirmed by poc/phase3/c3.log and reproduced in
poc/phase4/).Model.from_config (vector C4,
confirmed by poc/phase3/c4.log and poc/phase4/).keras.utils.legacy.deserialize_keras_object on a
user-supplied dict (vector C1, confirmed by poc/phase3/c1.log and
poc/phase4/).model_from_json on default state) is protected by the modern
deserializer and is included here only as a control: it shows that the
asymmetry is real, the documented threat model is reasonable, and the bug
is in three specific public APIs rather than in the threat model itself.Sequential model from a JSON configuration file. They
contain no exotic flags, no monkey-patches, no opt-outs, no calls to
enable_unsafe_deserialization. The malicious payload lives entirely
inside the input file.victim_sequential.py:1import json
2import keras
3
4with open("/work/malicious_sequential.json") as f:
5 config = json.load(f)
6
7model = keras.Sequential.from_config(config)
8print("loaded:", model.name, [l.name for l in model.layers])victim_sequential.py:victim_functional.py uses keras.Model.from_config(config)
on malicious_functional.json.victim_legacy_deserialize.py calls
keras.utils.legacy.deserialize_keras_object(config, module_objects=...)
on malicious_layer.json and then evaluates the returned layer on a
one-element tensor to force invocation.keras
installed. The generator script build_malicious_json.py
runs keras.src.utils.python_utils.func_dump on a Python lambda whose
body invokes os.system("touch /work/pwned_phase4_<sha>"). The
resulting base64-encoded marshalled bytecode is embedded in a Keras
layer configuration of the standard shape:1{
2 "class_name": "Lambda",
3 "config": {
4 "name": "malicious_lambda_p4_layer",
5 "function": {
6 "class_name": "__lambda__",
7 "config": {
8 "code": "<base64 marshalled bytecode>\n",
9 "defaults": null,
10 "closure": null
11 }
12 }
13 }
14}malicious_layer.json), 902 bytes for the Sequential variant
(malicious_sequential.json), and 1270 bytes for the Functional
variant (malicious_functional.json).eval(f"lambda x: ...") step so that the marshalled code carries the
path as a LOAD_CONST and the defaults / closure slots are both
None. This makes the JSON round-trip trivial: there is no tuple that
becomes a list across serialization. The script asserts both invariants
before writing the file.docker build
needs network access for pip install; subsequent docker run
invocations use --network=none.bash run_phase4.shDockerfile.minimal, 1.1 KB:
python:3.11-slim pinned by sha256 digest plus pinned
keras==3.11.3, tensorflow==2.19.0, h5py==3.12.1,
numpy==1.26.4). The build step asserts
keras.__version__ == '3.11.3'.pwned_phase4_* artifacts from previous runs to avoid
false positives.build_malicious_json.py inside the container, with
--network=none and the host directory mounted as
/work. This writes the three .json files; witness SHA suffixes
are regenerated from os.urandom(16) on each run, so the SHAs will
differ from those recorded in section 4.4 below.victim_*.py scripts inside the same image,
each with --network=none. The host directory receives one
pwned_phase4_<sha> witness file per successful RCE.phase4.log (23 KB). Reproduction interval:
2026-05-10T00:16:59Z to 2026-05-10T00:18:21Z (82 seconds total: ~50
seconds for the initial Docker build, ~17 seconds for the
build_malicious_json.py step that generates the three JSON
payloads, and ~5 seconds per victim run).pwned_phase4_286441063318 (victim_sequential.py, C3,
created during from_config)
pwned_phase4_37efab46f9c0 (victim_functional.py, C4,
created during from_config)
pwned_phase4_39ac034242cf (victim_legacy_deserialize.py, C1,
created after layer(x))phase4.log:===== step 2 : victim_sequential | --network=none =====
loaded: malicious_seq_phase4 ['malicious_lambda_p4_seq']
[victim_sequential] exit code = 0
===== step 2 : victim_functional | --network=none =====
loaded: malicious_func_phase4 ['in_l_p4', 'mal_l_p4']
[victim_functional] exit code = 0
===== step 2 : victim_legacy_deserialize | --network=none =====
loaded: Lambda malicious_lambda_p4_layer
called: shape = (1, 1)
[victim_legacy_deserialize] exit code = 0model.predict is called. The
build-symbolic-graph step inside Sequential.from_config and
Model.from_config invokes the Lambda with a sentinel tensor, which
is enough to fire the payload. There is no need to call the model.layer(x) call.
keras.utils.legacy.deserialize_keras_object instantiates the Lambda
layer but does not invoke it; the attacker needs the integrator to
use the layer at least once. The integrator does not need to wrap it
in a model.docker run invocations have --network=none. The witness
files appear because the lambda calls os.system("touch ...") on the
bind-mounted /work directory, not because of any network call.OK: 3 witnesses observed (C3 sequential, C4 functional, C1 layer)os.urandom(16)), but the
three vulnerable entry points all fired again on a fresh container.keras.Model.from_config(json.load(f)). An attacker with write
access to the configuration artifact (a compromised CI token, a
team member with limited code-review responsibilities, a
misconfigured bucket policy) gets arbitrary code execution on the
step that rebuilds the model. The artifact itself is small (under
2 KB) and looks like a normal Keras model config.keras.utils.legacy.deserialize_keras_object(config, module_objects=...) to materialize the layer. The plugin author
treats the input as opaque configuration data. The user is then
exploited by their own plugin when the supplied dict contains a
__lambda__ payload.keras.Sequential.from_config(config) to rebuild the model before
benchmarking. The framework does not install a SafeModeScope
itself, because it assumes (reasonably, by the documentation) that
the public API is safe by default. The framework user opens a
configuration file received over email or downloaded from a project
page and obtains RCE.keras.utils.legacy.deserialize_keras_object
on a single layer dict), the exploited code path requires that the
returned layer be invoked at least once on a tensor before the payload
fires. This is the normal usage pattern for a deserialized layer: a
plugin or framework that materializes a Lambda from user input does so
in order to call it (the entire point of materializing a layer is to
add it to a model or apply it to a tensor). The C1 vector is therefore
not a theoretical curiosity; it is exploitable whenever a deserialized
layer is wired into an inference path or a custom training loop, which
is the documented purpose of the function. Even when the prevalence of
direct calls on user-supplied layer dicts is low in absolute terms, the
gate that should block this payload is the same Lambda.from_config
gate that the CVE fix relies on. The bug is in the gate not firing, not
in the prevalence of callers.load_model_from_hdf5). The present report covers three public APIs
that all reach the same Lambda primitive through unprotected paths.
This affects:SafeModeScope before calling into the legacy
deserializer.import keras does not block these three APIs.
The SafeModeScope is installed on the path through
load_model_from_hdf5 and on the modern deserializer, and on no other
path that reaches Lambda.from_config.exec(attacker_controlled_string) in the victim process
with full access to:touch /work/pwned_phase4_<sha>) is a benign
indicator of execution. Replacing it with arbitrary Python is one line
of source code in the payload.safe_mode parameter defaulting
to True and install a serialization_lib.SafeModeScope(safe_mode)
before any nested cls.from_config call:keras/src/legacy/saving/serialization.py:414 (function
deserialize_keras_object). Add safe_mode=True to the
signature and wrap the call to cls.from_config(cls_config, ...)
in a with serialization_lib.SafeModeScope(safe_mode):.keras/src/legacy/saving/saving_utils.py:23 (function
model_from_config). Add safe_mode=True to the signature and
propagate it to the call into deserialize_keras_object.keras/src/models/sequential.py:353 (method
Sequential.from_config). Add safe_mode=True and propagate it
to both branches (the modern dispatch and the legacy dispatch via
model_from_config).keras/src/models/functional.py:463 (function
functional_from_config) and the wrapping
Functional.from_config / Model.from_config. Same pattern.keras.utils.legacy.deserialize_keras_object (declared in
keras/api/utils/legacy/__init__.py). It should not be a thin
alias for the unsafe internal function; it should be a wrapper
that opens SafeModeScope(True) by default and forwards
safe_mode as a keyword.__lambda__ marker from the legacy
deserialization path entirely. The marshalled-bytecode primitive has no
legitimate use case in 2026 era code, and removing it would close the
class of vulnerability rather than gating it.81575e6, 2026-02-19) improves named callable resolution in
legacy serialization. Does not touch safe_mode, SafeModeScope, or
the __lambda__ bytecode deserialization path.6672dcb, 2026-04-24) adds structural validation in
Sequential.from_config and model_from_config. Validates shape, not
safety; the vulnerable legacy dispatch is unchanged.bac7b6d, 2026-04-29) improves loop detection in
Functional.from_config. Does not introduce a SafeModeScope.safe_mode parameter, opens a
SafeModeScope, modifies the __lambda__ bytecode deserialization
path, or rejects the well-formed payload format used here. The
vulnerable code paths exist on master HEAD as of report submission.GHSA-36rr-ww3j-vrjv (CVE-2025-9905):
https://github.com/advisories/GHSA-36rr-ww3j-vrjvHunting Vulnerabilities in Keras Model Deserialization (blog.huntr.com).Sequential.from_config,
Model.from_config, keras.utils.legacy.deserialize_keras_object) that
reach Lambda.from_config on the legacy deserialization path without
installing the SafeModeScope that CVE-2025-9905 relies on, with
exploitation reproduced on v3.11.3 in the default state of the library.keras.models.model_from_json in default state (the
modern deserializer installs the scope, so the gate fires and the
payload is rejected); any code path that depends on
keras.config.enable_unsafe_deserialization() or on an explicit
safe_mode=False keyword (these are documented opt-outs, not bugs).model.get_config() and the input shape that
from_config is documented to consume), and it is exchanged between
processes as a model file in the workflows enumerated in section 5.1.desktop-linux.python:3.11-slim@sha256:a5b427ace4900267d93db34138e512325c6fa6af84ad5e4ed5f3b36258cc4142.keras==3.11.3, tensorflow==2.19.0, h5py==3.12.1,
numpy==1.26.4.--network=none on all four docker run invocations
(one for payload generation, three for victim execution)./work inside the
container. No other host path is visible.os.urandom(16). Witnesses from different runs do not
collide. The witnesses recorded for the reproduction in this report
are:pwned_phase4_286441063318 (vector C3, Sequential.from_config)
pwned_phase4_37efab46f9c0 (vector C4, Model.from_config)
pwned_phase4_39ac034242cf (vector C1, legacy.deserialize_keras_object)touch call creates an empty file); the
existence and timestamp of the file is what the witness establishes.AV:L/AC:L/AT:P/PR:N/UI:A/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
(base score 8.7, High), the official FIRST.org calculator pre-filled link is:| Axis | Value | Reason |
|---|---|---|
| AV (Attack Vector) | Local | Attacker delivers a configuration file to the victim's local process. |
| AC (Attack Complexity) | Low | No specialized knowledge required; payload format is documented Keras JSON. |
| AT (Attack Requirements) | Present | Victim must load the configuration file via one of the three APIs. |
| PR (Privileges Required) | None | Attacker needs no prior access to the victim's process. |
| UI (User Interaction) | Active | Victim actively calls the load function on the malicious file. |
| VC / VI / VA (Vulnerable system impact) | High / High / High | Arbitrary Python execution in victim process. |
| SC / SI / SA (Subsequent system impact) | High / High / High | Same Python execution can pivot into network, credentials, model artifacts. |