VYPR
High severityNVD Advisory· Published Feb 11, 2026· Updated Feb 12, 2026

Arbitrary File Read in Keras via HDF5 External Datasets

CVE-2026-1669

Description

Arbitrary file read in the model loading mechanism (HDF5 integration) in Keras versions 3.0.0 through 3.13.1 on all supported platforms allows a remote attacker to read local files and disclose sensitive information via a crafted .keras model file utilizing HDF5 external dataset references.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Arbitrary file read via HDF5 external dataset references in Keras model loading (3.0.0-3.13.1) allows remote attacker to disclose local files.

Vulnerability

Overview

CVE-2026-1669 describes an arbitrary file read vulnerability in the model loading mechanism of the Keras deep learning library, specifically in its HDF5 integration. The flaw affects Keras versions 3.0.0 through 3.13.1. When loading a specially crafted .keras or .weights.h5 file, the software fails to properly validate HDF5 dataset references. An attacker can embed external dataset links that point to arbitrary local files on the victim's system, causing the Keras loading routines to read and expose the contents of those files [1][2][3].

Exploitation

Mechanics

To exploit this vulnerability, an attacker must convince a victim to load a malicious model file using model.load_weights() or tf.keras.models.load_model(). The crafted file exploits HDF5's "external storage" and "ExternalLink" features, which allow one HDF5 file to reference data stored in another file or location. When the victim loads the weights, Keras reads data from the external references inadvertently [2]. Notably, Keras's built-in "safe mode" only guards against deserialization of_object_ objects, not weight I/O, so the protection does not block this attack vector [2]. The flaw arises because the HDF5 binding code did not verify that loaded datasets were not external links before consuming their data [1][4].

Impact

A successful exploit allows an attacker to read any file on the host system that the Keras process has permission to read. This includes sensitive files such as /etc/passwd, /etc/hostname, SSH private keys, configuration files, or other secrets. The leaked data can be observed through model outputs (e.g., bias values in a Dense layer) or by inspecting newly saved model artifacts that incorporate the exfiltrated content [2]. The CVSS vector is not yet assigned by NVD, but the advisory categorizes this as CWE-200 (Exposure of Sensitive Information) and CWE-73 (External Control of File Name or Path) [2][3].

Mitigation

The issue was patched in Keras commit 8a37f9dadd8e23fa4ee3f537eeb6413e75d12553, which adds validation functions _verify_group and _verify_dataset in the H5IOStore class. The _verify_dataset function checks that a dataset is not an external link and raises a ValueError if so [4]. This fix was merged via Pull Request #22057 [1]. Users should update to Keras 3.14.0 or later (the version containing the fix). There is no workaround beyond not loading models from untrusted sources, which is generally recommended anyway.

AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
kerasPyPI
>= 3.13.0, < 3.13.23.13.2
kerasPyPI
>= 3.0.0, < 3.12.13.12.1

Affected products

2
  • Keras Team/Kerasllm-fuzzy
    Range: >=3.0.0, <=3.13.1
  • Google/Kerasv5
    Range: 3.0.0

Patches

1
8a37f9dadd8e

Do not allow external links in HDF5 files. (#22057)

https://github.com/keras-team/kerashertschuhJan 28, 2026via ghsa
3 files changed · +53 66
  • keras/src/saving/file_editor.py+7 1 modified
    @@ -509,9 +509,15 @@ def _extract_weights_from_store(self, data, metadata=None, inner_path=""):
                 # ------------------------------------------------------
     
                 # Skip any objects that are not proper datasets
    -            if not hasattr(value, "shape") or not hasattr(value, "dtype"):
    +            if not isinstance(value, h5py.Dataset):
                     continue
     
    +            if value.external:
    +                raise ValueError(
    +                    "Not allowed: H5 file Dataset with external links: "
    +                    f"{value.external}"
    +                )
    +
                 shape = value.shape
                 dtype = value.dtype
     
    
  • keras/src/saving/saving_lib.py+46 47 modified
    @@ -796,7 +796,8 @@ def _load_state(
                 try:
                     saveable.load_own_variables(weights_store.get(inner_path))
                 except Exception as e:
    -                failed_saveables.add(id(saveable))
    +                if failed_saveables is not None:
    +                    failed_saveables.add(id(saveable))
                     error_msgs[id(saveable)] = saveable, e
                     failure = True
             else:
    @@ -807,7 +808,8 @@ def _load_state(
                 try:
                     saveable.load_assets(assets_store.get(inner_path))
                 except Exception as e:
    -                failed_saveables.add(id(saveable))
    +                if failed_saveables is not None:
    +                    failed_saveables.add(id(saveable))
                     error_msgs[id(saveable)] = saveable, e
                     failure = True
             else:
    @@ -855,7 +857,7 @@ def _load_state(
         if not failure:
             if visited_saveables is not None and newly_failed <= 0:
                 visited_saveables.add(id(saveable))
    -        if id(saveable) in failed_saveables:
    +        if failed_saveables is not None and id(saveable) in failed_saveables:
                 failed_saveables.remove(id(saveable))
                 error_msgs.pop(id(saveable))
     
    @@ -1035,6 +1037,25 @@ def __bool__(self):
             # will mistakenly using `__len__` to determine the value.
             return self.h5_file.__bool__()
     
    +    def _verify_group(self, group):
    +        if not isinstance(group, h5py.Group):
    +            raise ValueError(
    +                f"Invalid H5 file, expected Group but received {type(group)}"
    +            )
    +        return group
    +
    +    def _verify_dataset(self, dataset):
    +        if not isinstance(dataset, h5py.Dataset):
    +            raise ValueError(
    +                f"Invalid H5 file, expected Dataset, received {type(dataset)}"
    +            )
    +        if dataset.external:
    +            raise ValueError(
    +                "Not allowed: H5 file Dataset with external links: "
    +                f"{dataset.external}"
    +            )
    +        return dataset
    +
         def _get_h5_file(self, path_or_io, mode=None):
             mode = mode or self.mode
             if mode not in ("r", "w", "a"):
    @@ -1094,15 +1115,19 @@ def get(self, path):
             self._h5_entry_group = {}  # Defaults to an empty dict if not found.
             if not path:
                 if "vars" in self.h5_file:
    -                self._h5_entry_group = self.h5_file["vars"]
    +                self._h5_entry_group = self._verify_group(self.h5_file["vars"])
             elif path in self.h5_file and "vars" in self.h5_file[path]:
    -            self._h5_entry_group = self.h5_file[path]["vars"]
    +            self._h5_entry_group = self._verify_group(
    +                self._verify_group(self.h5_file[path])["vars"]
    +            )
             else:
                 # No hit. Fix for 2.13 compatibility.
                 if "_layer_checkpoint_dependencies" in self.h5_file:
                     path = path.replace("layers", "_layer_checkpoint_dependencies")
                     if path in self.h5_file and "vars" in self.h5_file[path]:
    -                    self._h5_entry_group = self.h5_file[path]["vars"]
    +                    self._h5_entry_group = self._verify_group(
    +                        self._verify_group(self.h5_file[path])["vars"]
    +                    )
             self._h5_entry_initialized = True
             return self
     
    @@ -1134,25 +1159,15 @@ def __len__(self):
         def keys(self):
             return self._h5_entry_group.keys()
     
    -    def items(self):
    -        return self._h5_entry_group.items()
    -
    -    def values(self):
    -        return self._h5_entry_group.values()
    -
         def __getitem__(self, key):
    -        value = self._h5_entry_group[key]
    +        value = self._verify_dataset(self._h5_entry_group[key])
             if (
                 hasattr(value, "attrs")
                 and "dtype" in value.attrs
                 and value.attrs["dtype"] == "bfloat16"
             ):
                 value = np.array(value, dtype=ml_dtypes.bfloat16)
    -        elif (
    -            hasattr(value, "shape")
    -            and hasattr(value, "dtype")
    -            and not isinstance(value, np.ndarray)
    -        ):
    +        elif not isinstance(value, np.ndarray):
                 value = np.array(value)
             return value
     
    @@ -1355,25 +1370,25 @@ def _switch_h5_file(self, filename, mode):
             self._get_h5_group(self._h5_entry_path)
     
         def _restore_h5_file(self):
    -        """Ensure the current shard is the last one created.
    -
    -        We use mode="a" to avoid truncating the file during the switching.
    -        """
    +        """Ensure the current shard is the last one created."""
             if (
                 pathlib.Path(self.h5_file.filename).name
                 != self.current_shard_path.name
             ):
    -            self._switch_h5_file(self.current_shard_path.name, mode="a")
    +            mode = "a" if self.mode == "w" else "r"
    +            self._switch_h5_file(self.current_shard_path.name, mode=mode)
     
         # H5 entry level methods.
     
         def _get_h5_group(self, path):
             """Get the H5 entry group. If it doesn't exist, return an empty dict."""
             try:
                 if not path:
    -                self._h5_entry_group = self.h5_file["vars"]
    +                self._h5_entry_group = self._verify_group(self.h5_file["vars"])
                 else:
    -                self._h5_entry_group = self.h5_file[path]["vars"]
    +                self._h5_entry_group = self._verify_group(
    +                    self._verify_group(self.h5_file[path])["vars"]
    +                )
                 self._h5_entry_initialized = True
             except KeyError:
                 self._h5_entry_group = {}
    @@ -1392,33 +1407,17 @@ def __len__(self):
             return total_len
     
         def keys(self):
    -        keys = set(self._h5_entry_group.keys())
    +        keys = []
    +        current_shard_keys = list(self._h5_entry_group.keys())
             for filename in self.current_shard_filenames:
                 if filename == self.current_shard_path.name:
    -                continue
    -            self._switch_h5_file(filename, mode="r")
    -            keys.update(self._h5_entry_group.keys())
    +                keys += current_shard_keys
    +            else:
    +                self._switch_h5_file(filename, mode="r")
    +                keys += list(self._h5_entry_group.keys())
             self._restore_h5_file()
             return keys
     
    -    def items(self):
    -        yield from self._h5_entry_group.items()
    -        for filename in self.current_shard_filenames:
    -            if filename == self.current_shard_path.name:
    -                continue
    -            self._switch_h5_file(filename, mode="r")
    -            yield from self._h5_entry_group.items()
    -        self._restore_h5_file()
    -
    -    def values(self):
    -        yield from self._h5_entry_group.values()
    -        for filename in self.current_shard_filenames:
    -            if filename == self.current_shard_path.name:
    -                continue
    -            self._switch_h5_file(filename, mode="r")
    -            yield from self._h5_entry_group.values()
    -        self._restore_h5_file()
    -
         def __getitem__(self, key):
             if key in self._h5_entry_group:
                 return super().__getitem__(key)
    
  • keras/src/saving/saving_lib_test.py+0 18 modified
    @@ -1319,24 +1319,6 @@ def test_sharded_h5_io_store_basics(self):
             for key in ["a", "b"]:
                 self.assertIn(key, vars_store.keys())
     
    -        # Items.
    -        for key, value in vars_store.items():
    -            if key == "a":
    -                self.assertAllClose(value, a)
    -            elif key == "b":
    -                self.assertAllClose(value, b)
    -            else:
    -                raise ValueError(f"Unexpected key: {key}")
    -
    -        # Values.
    -        for value in vars_store.values():
    -            if backend.standardize_dtype(value.dtype) == "float32":
    -                self.assertAllClose(value, a)
    -            elif backend.standardize_dtype(value.dtype) == "int32":
    -                self.assertAllClose(value, b)
    -            else:
    -                raise ValueError(f"Unexpected value: {value}")
    -
         def test_sharded_h5_io_store_exception_raised(self):
             temp_filepath = Path(os.path.join(self.get_temp_dir(), "store.h5"))
     
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

8

News mentions

0

No linked articles in our index yet.