high

CVE

CVE-2026-12481

CWE

CWE-502

Affected Surface

  • PyPI package `keras` in the 3.14.x line, with public source evidence for 3.14.0 and 3.14.1
  • Inference services, notebooks, model-conversion jobs, and internal ML tooling that deserialize attacker-controlled Keras `Lambda` configs
  • Applications that call `keras.layers.deserialize(config)`, `keras.models.clone_model(model)`, or `keras.layers.Lambda.from_config(config)` on lower-trust input
  • Teams that rely on Keras safe mode as the only boundary between untrusted model content and Python execution

CVE-2026-12481 matters because it breaks a boundary Keras users are explicitly told to trust: safe-mode deserialization of Lambda layers. The July 3 disclosure shows that in the vulnerable 3.14.x logic, an unset safe_mode value can collapse into a falsey value rather than a fail-closed default. Once that happens, Keras stops treating a serialized lambda as dangerous and passes attacker-controlled bytecode into the loader path that rebuilds Python function objects.

That is not a “we parsed some weird model metadata” bug. It is a bytecode-materialization bug in a package that sits inside notebooks, model registries, inference services, CI conversion jobs, and internal ML tooling. If your application accepts or clones lower-trust Keras model content, this belongs on the same review list as any other deserialization-driven code execution surface.

Affected package and version boundary

The affected package is:

  • keras on PyPI

The public advisory text names 3.14.0, but the publicly visible version boundary is a little subtler than that.

PackageEcosystemHighest-confidence affected versions from public artifactsHighest-confidence fixed version from public artifacts
kerasPyPI3.14.0, 3.14.13.15.0

Why this boundary is defensible:

  • the NVD entry names keras-team/keras version 3.14.0
  • the public v3.14.1 source still contains the vulnerable fallback safe_mode = safe_mode or serialization_lib.in_safe_mode()
  • the public v3.15.0 source replaces that line with a fail-closed effective_safe_mode calculation
  • PyPI shows 3.15.0 as the latest release line available at publication time

So while the imported advisory metadata in NVD currently carries a generic affected <= latest shape, the source-level evidence points to 3.14.0 and 3.14.1 as the exposed public PyPI releases and 3.15.0 as the first clearly remediating line.

The practical exposure condition is narrower than “any environment with Keras installed,” but it is still common:

  • a service or notebook loads Keras model content from users, partners, or shared storage
  • code calls keras.layers.deserialize(config) or keras.models.clone_model(model) on lower-trust input
  • tooling or internal automation invokes Lambda.from_config() directly without wrapping it in SafeModeScope(True)
  • teams assume the default safe_mode behavior is enough to block lambda bytecode from becoming executable Python

The vulnerable code path is short and direct

The 3.14.1 source shows the core bug clearly:

@classmethod
def from_config(cls, config, custom_objects=None, safe_mode=None):
    safe_mode = safe_mode or serialization_lib.in_safe_mode()
    fn_config = config["function"]
    if (
        isinstance(fn_config, dict)
        and "class_name" in fn_config
        and fn_config["class_name"] == "__lambda__"
    ):
        cls._raise_for_lambda_deserialization(safe_mode)
        inner_config = fn_config["config"]
        fn = python_utils.func_load(
            inner_config["code"],
            defaults=inner_config["defaults"],
            closure=inner_config["closure"],
        )
        config["function"] = fn

There are two security-significant details here:

  1. safe_mode is derived with Python’s truthiness semantics rather than a strict is not None test.
  2. If the guard does not fire, the code immediately reconstructs the serialized lambda through python_utils.func_load(...).

The NVD wording calls out the exact bad state: safe_mode is None outside a SafeModeScope context, and the implementation treats that unset state as if safety were effectively off. _raise_for_lambda_deserialization() only blocks when it sees a truthy value:

@staticmethod
def _raise_for_lambda_deserialization(safe_mode):
    if safe_mode:
        raise ValueError(...)

So the dangerous flow is:

safe_mode=None
  -> in_safe_mode() returns an unset / falsey value
  -> _raise_for_lambda_deserialization() does not raise
  -> func_load() rebuilds the attacker-controlled function

That is exactly the sort of “default-deny became falsey-deny” logic slip that turns a guardrail into a bypass.

Why func_load() matters: this is executable bytecode, not harmless config

The follow-on loader path in the same Keras release shows what happens next:

def func_load(code, defaults=None, closure=None, globs=None):
    ...
    raw_code = codecs.decode(code.encode("ascii"), "base64")
    code = marshal.loads(raw_code)
    if globs is None:
        globs = globals()
    return python_types.FunctionType(
        code, globs, name=code.co_name, argdefs=defaults, closure=closure
    )

This is why the bug is meaningful. Once the Lambda guard is bypassed, Keras does not merely preserve opaque data. It:

  1. base64-decodes serialized code bytes
  2. unmarshals them with marshal.loads(...)
  3. wraps the resulting code object in types.FunctionType(...)

At that point the model payload has crossed from serialized configuration into executable Python behavior.

Keras’s own documentation already signals that Lambda is a dangerous serialization surface:

  • the Lambda docs warn that layers are saved by serializing Python bytecode and are “potentially unsafe”
  • the serialization docs say safe_mode=True is intended to disable unsafe lambda deserialization

CVE-2026-12481 matters because the implementation failed to honor that contract in the unset-safe-mode case.

The affected call sites are more common than they look

The advisory does not limit exposure to full load_model() calls. The public description explicitly names:

  • keras.layers.deserialize(config)
  • keras.models.clone_model(model)
  • direct Lambda.from_config(config) usage

That makes the bug relevant to more than public model upload features. Review these patterns closely:

  • internal model-conversion pipelines that deserialize layer config objects
  • notebook utilities that clone or rewrite models before export
  • ML platform components that normalize user-submitted architectures
  • test harnesses that load shared model fixtures from less-trusted repositories or artifacts
  • background jobs that inspect or transform saved model configs before serving

The highest-risk environments are the ones application security teams often overlook:

  • Jupyter servers with cloud credentials
  • model build workers that can read training datasets or secrets
  • inference control planes that accept uploaded or synchronized model packages
  • CI jobs that benchmark, convert, or re-save third-party Keras models automatically

If an attacker can move a crafted lambda config into one of those processes, the trust boundary is no longer “ML metadata.” It is “Python code running inside the same process that already holds your tokens, data paths, and filesystem access.”

What changed in 3.15.0

The public v3.15.0 source shows the important design correction:

effective_safe_mode = (
    safe_mode
    if safe_mode is not None
    else serialization_lib.in_safe_mode()
)
safe_mode = effective_safe_mode is not False

That is a real security fix, not a cosmetic refactor.

The new behavior does two things the old code did not:

  1. it distinguishes “unset” from “explicitly disabled”
  2. it fails closed by treating anything other than explicit False as safe mode enabled

The accompanying comment in the source is blunt and useful:

# ... treating an unset scope as safe. This fails closed so that
# deserializing a `Lambda` outside of a `SafeModeScope` ... does not
# silently allow arbitrary code execution.

That is the exact security property missing from the 3.14.x implementation.

Scoping and detection

Start by identifying whether affected Keras releases are present:

python3 -m pip show keras
python3 -m pip freeze | rg '^keras=='
uv pip tree | rg '^keras '
poetry show keras

Then find code paths that may deserialize lower-trust model content:

rg -n 'clone_model\\(|Lambda\\.from_config\\(|layers\\.deserialize\\(' .
rg -n 'load_model\\(|deserialize_keras_object\\(' .
rg -n 'safe_mode=False|enable_unsafe_deserialization\\(' .

The review question that matters most is:

Can a less-trusted party influence the config or model artifact that
eventually reaches Lambda deserialization in this process?

Pay special attention to:

  • uploaded .keras model handling
  • shared internal model registries
  • benchmark or evaluation tooling that ingests community models
  • Jupyter notebooks that load models from object storage or Git repositories
  • automated model cloning or graph rewriting in CI

If you discover that untrusted model material was processed through these paths on 3.14.x, treat the host as potentially having executed attacker-supplied Python.

Remediation

The clean fix is straightforward:

  1. Upgrade keras to 3.15.0 or later.
  2. Remove or isolate direct Lambda.from_config() usage on lower-trust input.
  3. Avoid safe_mode=False and avoid globally calling keras.config.enable_unsafe_deserialization() unless you fully trust the model source.
  4. Prefer subclassed layers with explicit get_config() logic over serialized Lambda bytecode wherever you control the model design.
  5. Treat third-party model artifacts as executable content, not as inert data files.

If you cannot upgrade immediately, the safest short-term rule is simple:

untrusted model or config
  -> do not deserialize Lambda bytecode in-process

That may mean refusing models that contain serialized lambdas, moving model inspection into a hardened sandbox, or requiring signed provenance for accepted artifacts.

Response guidance

If an affected 3.14.x environment loaded lower-trust Keras model content:

  1. Assume attacker-controlled Python may have executed in the same process.
  2. Rotate credentials reachable from that host, including cloud tokens, dataset access keys, CI secrets, and repository tokens.
  3. Review notebook, worker, and service logs for model loads, clone operations, and suspicious config ingestion during the exposure window.
  4. Re-audit any internal tooling that uses safe_mode=False or globally disables safe deserialization.
  5. Rebuild or reimage high-value model-processing workers if you cannot confidently bound what executed.

The key AppSec lesson from CVE-2026-12481 is that ML model loaders are code loaders whenever they serialize language-native executable objects. In Keras, Lambda already sat on that boundary. The July 2026 disclosure shows how a single falsey fallback in the guard path was enough to turn “unsafe unless explicitly allowed” into “unsafe unless the caller remembered to set the scope.”

From research to remediation

Check whether this pattern exists in your codebase

Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.

References