Your Keras Model File Is Executable Code, Not Data
safe_mode keeps getting bypassed because the format was built to run whatever the config names.
Everyone who has spent time hardening an ML pipeline knows the pickle sermon by heart. torch.load calls __reduce__, __reduce__ runs whatever the attacker wrote, and a checkpoint pulled from a random Hugging Face repo becomes remote code execution the instant you deserialize it. The lesson most teams took away was simple: stop shipping pickles. Move to .h5, move to .keras, move to formats that "just store weights." That lesson is comforting, widely repeated, and wrong in a way that keeps producing CVEs.
The uncomfortable detail — resurfaced this week by the AIsbom project and grounded in a year of real advisories — is that a Keras model config can carry a marshalled Python code object. Not a pickle. Not a weight tensor. A serialized chunk of CPython bytecode, sitting inside a JSON-ish config, waiting for a loader to hand it back to the interpreter.
Why a "config" contains bytecode
The mechanism is boring, which is exactly why it's dangerous. Keras lets you define a layer as an arbitrary Python callable through Lambda. That's a genuinely useful feature — a quick Lambda(lambda x: x * 2) beats writing a full layer subclass. But a lambda is code, and code has to survive being written to disk. Keras solves this the only way it can: it runs the callable through marshal, stores the resulting code object in the model config, and reconstitutes it on load.
So the "safe" format ships executable bytecode by design. This isn't a bug report against Keras; it's documented behavior. The bug is in everyone's mental model, where .keras and .h5 got filed under "data" instead of "code."
Marshalled bytecode is arguably worse to defend against than pickle, for a subtle reason the AIsbom writeup nails: the obvious way to inspect a marshalled blob is to unmarshal it, and marshal.loads on untrusted input is itself unsafe. The scanner and the victim reach for the same dangerous primitive. Any tool that wants to flag these payloads has to classify them from header bytes without ever calling the deserializer — which is what the new release does, and what most naive "just scan the model" pipelines don't.
safe_mode was supposed to fix this. It keeps not fixing this.
Keras did respond. Since 2.13, safe_mode=True is the default, and it blocks deserialization of Lambda layers carrying marshalled code. If the story ended there, this would be a footnote. Instead, safe_mode has become one of the most reliably bypassed security controls in the ML tooling world, and the CVE trail tells the story better than any threat model:
- CVE-2025-1550: You don't even need a Lambda layer. Hand-edit
config.jsoninside the.kerasarchive to name an arbitrary Python module and function with arguments, andload_modelinvokes it — withsafe_mode=True, with no call to the model, on load alone. Fixed in Keras 3.9 (March 2025) by restricting imports to Keras's own modules. - CVE-2025-8747: That import restriction gets bypassed by reusing internal Keras functionality as a gadget chain. JFrog's researchers pointed at
keras.utils.get_file, which will happily download an attacker-chosen URL into an attacker-chosen path — say, your~/.ssh/directory. - CVE-2025-9905: Load a legacy HDF5 model and
safe_modeis silently ignored. No warning, no error, full execution. - CVE-2026-12481: The guard conflates
safe_mode=None(unset, should default to deny) withsafe_mode=False(explicitly off), so passingNonesails straight past the check.
Four bypasses, four different root causes, one pattern: safe_mode is an allowlist bolted onto a deserializer that was designed to reconstruct arbitrary Python objects. Every patch narrows one path while the underlying capability — "the config can name code to run" — stays intact. This is the textbook signature of a design that treats security as a filter instead of a boundary.
The format-swap advice was never the fix
Here's the editorial line, and I'll defend it: telling developers to "switch off pickle" was security theater dressed as best practice. The threat was never the pickle opcode format specifically; it was the decision to make deserialization and code loading the same operation. .keras, .h5, ONNX (external-data paths that read arbitrary files, custom operator domains), and GGUF (embedded Jinja templates you can't safely render because of known sandbox escapes) all reproduce that decision in their own dialect.
The one format that actually broke the pattern is safetensors, and it's worth understanding why it's different rather than cargo-culting it as the next magic extension. Safetensors stores tensors and nothing else — no callables, no config, no code path from file to interpreter. That's the real fix: not a safer serializer, but a format that structurally cannot express executable content. The catch developers hit in practice is that weights alone don't reconstruct a model. You still need the architecture, and the moment that architecture description can name layers, functions, or custom objects, you've quietly re-opened the door safetensors closed.
What to actually do on Monday
Concrete, in priority order:
- Treat every model artifact as untrusted code, not data. If you wouldn't
curl | basha stranger's script, don'tload_modeltheir.kerasin your training environment. That reframing does more than any single patch. - Load untrusted models in a sandbox — a container with no network egress, no credentials mounted, a throwaway user,
seccompon. Assumesafe_modewill be bypassed, because the record says it will be. - Upgrade, but don't trust the upgrade. Keras 3.9+ closes 1550 and its known descendants; nothing closes the next
get_file-style gadget nobody's published yet. Patching is necessary and insufficient. - Scan without deserializing. Header-byte inspection, not
marshal.loadsorpickle.load, is the only safe way to classify a blob you don't trust. Any scanner that opens the payload to check the payload is part of the attack surface. - Pin provenance. Hashes and signatures on model artifacts, the same way you'd pin a dependency. A model from an untraceable Hugging Face upload deserves exactly the scrutiny of an unsigned binary from an unknown vendor — which is to say, run it nowhere you care about.
The pickle era taught the ML world that model files can be weapons. The Keras safe_mode saga is teaching a harder second lesson: swapping the file extension doesn't disarm them. As long as a model's architecture can name code, loading a model is running a program — and the only durable defense is to run it like you'd run any other untrusted program.
Sources & further reading
- Your Keras model config can contain a marshalled Python code object — dev.to
- Is TensorFlow Keras Safe Mode Actually Safe? Bypassing safe_mode to Achieve Arbitrary Code Execution — jfrog.com
- Arbitrary Code Execution via Crafted Keras Config for Model Loading (CVE-2025-1550) — github.com
- Keras vulnerable to CVE-2025-1550 bypass via reuse of internal functionality (CVE-2025-8747) — github.com
- CVE-2025-9905 - Bypassing Keras safe_mode via Legacy HDF5 File Format — github.com
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 0
No comments yet
Be the first to weigh in.