Skip to content

Resolve paths with realpath to prevent symlink traversal during archive extraction#23015

Merged
hertschuh merged 1 commit into
keras-team:masterfrom
LinZiyuu:fix-get-file-symlink-traversal
Jun 2, 2026
Merged

Resolve paths with realpath to prevent symlink traversal during archive extraction#23015
hertschuh merged 1 commit into
keras-team:masterfrom
LinZiyuu:fix-get-file-symlink-traversal

Conversation

@LinZiyuu

@LinZiyuu LinZiyuu commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Summary

keras.utils.get_file(extract=True) and keras.utils.extract_archive validate each
TAR member against the extraction directory using resolve_sub_path, which relies on
resolve_path:

def resolve_path(path):
    return os.path.realpath(os.path.abspath(path))

os.path.abspath collapses .. segments lexically (as text) before any symlink
is resolved. For a member named link/../x, the .. cancels the link component
textually, so the subsequent realpath never traverses the symlink. The containment
check then concludes the member stays inside the extraction directory, while
tarfile.extractall writes it to the real location reached by following the
symlink — outside the directory.

As a result, an archive containing an in-bounds symlink link -> . plus a regular
file link/../<name> writes <name> into the parent of the extraction directory
(and chaining symlinks reaches deeper paths). Writing outside the extraction directory
during archive extraction is the issue this validation is meant to prevent.

Fix

Use os.path.realpath(path) directly. realpath already returns an absolute path and
resolves symlinks component by component, so .. is applied after the symlink is
resolved and resolve_sub_path correctly rejects members that escape the base
directory. For paths without a symlink-then-.. sequence the result is unchanged.

Reproduction (before this PR)

import io, os, tarfile, tempfile, keras
work = tempfile.mkdtemp(); cache = os.path.join(work, "cache"); os.makedirs(cache)
tp = os.path.join(work, "evil.tar.gz")
with tarfile.open(tp, "w:gz") as tf:
    sl = tarfile.TarInfo("ln"); sl.type = tarfile.SYMTYPE; sl.linkname = "."
    tf.addfile(sl)
    data = b"escaped\n"
    fi = tarfile.TarInfo("ln/../ESCAPED"); fi.size = len(data)
    tf.addfile(fi, io.BytesIO(data))
keras.utils.get_file(fname="a", origin="file://"+tp, extract=True,
                     cache_dir=cache, cache_subdir="datasets")
# before: cache/datasets/ESCAPED exists (outside cache/datasets/a)
# after:  the member is skipped; nothing is written outside the extraction dir

Tests

Added FilterSafePathsTest.test_file_routed_through_symlink_is_rejected. It fails on the
previous implementation and passes with this change. All existing file_utils tests
continue to pass.

Contributor agreement

  • I am a human, and not a bot.
  • I will be responsible for responding to review comments in a timely manner.
  • I will work with the maintainers to push this PR forward until submission.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates resolve_path in file_utils.py to use os.path.realpath(path) directly, avoiding os.path.abspath which lexically collapses .. before symlinks are resolved. It also adds a test to verify that files routed through symlinks are rejected. The reviewer identified a critical security vulnerability where this check can still be bypassed if the generator is eagerly evaluated before symlinks are written to disk. The reviewer recommended tracking symlinks in-memory during filtering to ensure robust path traversal protection.

Comment on lines 43 to +49
def resolve_path(path):
return os.path.realpath(os.path.abspath(path))
# Use `realpath` (which also makes the path absolute). Do NOT wrap it
# in `abspath`: abspath collapses `..` lexically before symlinks are
# resolved, so `dir/link/../x` becomes `dir/x` without following
# `link`. That lets a member written through an in-bounds symlink
# escape the `resolve_sub_path` containment check.
return os.path.realpath(path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Security Vulnerability: Fragile Symlink Resolution during Eager Evaluation

While using os.path.realpath(path) directly instead of wrapping it in os.path.abspath resolves the lexical collapse issue when the symlink exists on disk, this approach remains highly fragile and vulnerable to path traversal under common usage patterns.

The Problem

The containment check relies on the physical filesystem state to resolve symlinks. However, during archive extraction, filter_safe_tarinfos is a generator. If a user or wrapper eagerly evaluates this generator (e.g., by calling list(filter_safe_tarinfos(...)) or pre-filtering members before extraction), none of the symlinks have been written to disk yet.

In this scenario:

  1. os.path.realpath is called on base_dir/link/../escaped.txt.
  2. Since link does not exist on disk yet, os.path.realpath falls back to lexical resolution and collapses link/...
  3. The path resolves to base_dir/escaped.txt, which is deemed safe and accepted.
  4. When extraction actually runs, the symlink link is created first, and the subsequent extraction of link/../escaped.txt traverses the symlink and writes outside the extraction directory.

The Solution

To make this completely robust and immune to eager evaluation or out-of-order processing, we should track the symlinks defined in the archive in-memory and resolve the member paths against these tracked symlinks before performing the containment check.

Here is how filter_safe_tarinfos can be securely implemented:

def filter_safe_tarinfos(members, base_dir):
    base_dir = resolve_path(base_dir)
    resolved_symlinks = {}
    for finfo in members:
        # Resolve the member's path against any symlinks we've seen so far in the archive
        name = finfo.name
        components = name.split("/")
        resolved = []
        for c in components:
            if not c or c == ".":
                continue
            if c == "..":
                if resolved and resolved[-1] != "..":
                    resolved.pop()
                else:
                    resolved.append("..")
                continue
            resolved.append(c)
            current_path = "/".join(resolved)
            if current_path in resolved_symlinks:
                target = resolved_symlinks[current_path]
                resolved.pop()
                for tc in target.split("/"):
                    if not tc or tc == ".":
                        continue
                    if tc == "..":
                        if resolved and resolved[-1] != "..":
                            resolved.pop()
                        else:
                            resolved.append("..")
                        continue
                    resolved.append(tc)
        resolved_name = "/".join(resolved)

        valid_path = False
        if finfo.issym() or finfo.islnk():
            if is_link_in_dir(finfo, base_dir):
                valid_path = True
                if finfo.issym():
                    resolved_symlinks[name] = finfo.linkname
                yield finfo
        elif resolve_sub_path(base_dir, resolved_name) is not None:
            valid_path = True
            yield finfo
        if not valid_path:
            warnings.warn(
                "Skipping invalid path during archive extraction: "
                f"'{finfo.name}'.",
                stacklevel=2,
            )

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Keras this generator is consumed lazily, not via list(...). extract_open_archive passes it straight to tarfile.extractall, which iterates members and extracts each one before pulling the next. So by the time the generator checks a member routed through an in-bounds symlink (the symlink necessarily appears earlier in the archive), that symlink is already on disk and os.path.realpath follows it — the escape is rejected. That is exactly what the added test covers.

The bypass you describe requires the generator to be fully materialized before any extraction begins, which Keras never does. If you'd prefer defense-in-depth against external callers that might eagerly evaluate it, in-memory symlink tracking could be added as a follow-up; I've kept this PR to the minimal root-cause fix.

Comment on lines +212 to +232
def test_file_routed_through_symlink_is_rejected(self):
"""A member routed through an in-bounds symlink via `..` must not
escape the base dir.

The symlink (``link`` -> ``.``) is itself within the base dir, but a
member named ``link/../escaped.txt`` resolves through it to the parent
directory. The containment check must reject it (regression: a lexical
``..`` collapse would cancel ``link/..`` without ever following the
symlink).
"""
os.symlink(".", os.path.join(self.base_dir, "link"))
with tarfile.open(self.tar_path, "w") as tar:
tar.add(
self.target_path,
arcname=os.path.join("link", "..", "escaped.txt"),
)
with tarfile.open(self.tar_path, "r") as tar:
members = list(
file_utils.filter_safe_tarinfos(tar.getmembers(), self.base_dir)
)
self.assertEqual(members, [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Test Setup Limitation

Notice that in this test, you had to manually create the symlink on disk before calling filter_safe_tarinfos:

os.symlink(".", os.path.join(self.base_dir, "link"))

This is because filter_safe_tarinfos currently relies on the physical filesystem to resolve symlinks. In a real-world scenario, when a user or library eagerly evaluates the generator (e.g., list(filter_safe_tarinfos(...))) before extraction begins, the symlink does not exist on disk yet, and the containment check would be bypassed.

If we implement the in-memory symlink tracking solution suggested in file_utils.py, this test can be run without manually creating the symlink on disk beforehand, making it a true representation of the extraction process.

@codecov-commenter

codecov-commenter commented Jun 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.68%. Comparing base (19796fa) to head (4ab48b1).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #23015   +/-   ##
=======================================
  Coverage   84.68%   84.68%           
=======================================
  Files         464      464           
  Lines       68627    68627           
  Branches    11272    11272           
=======================================
  Hits        58117    58117           
  Misses       7572     7572           
  Partials     2938     2938           
Flag Coverage Δ
keras 84.48% <100.00%> (ø)
keras-cpu 83.76% <100.00%> (ø)
keras-gpu 69.37% <100.00%> (ø)
keras-jax 58.31% <100.00%> (ø)
keras-numpy 53.66% <100.00%> (ø)
keras-openvino 59.44% <100.00%> (ø)
keras-tensorflow 59.65% <100.00%> (ø)
keras-torch 59.00% <100.00%> (ø)
keras-tpu 57.12% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@hertschuh hertschuh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix!

Comment thread keras/src/utils/file_utils.py Outdated
Comment on lines +44 to +48
# Use `realpath` (which also makes the path absolute). Do NOT wrap it
# in `abspath`: abspath collapses `..` lexically before symlinks are
# resolved, so `dir/link/../x` becomes `dir/x` without following
# `link`. That lets a member written through an in-bounds symlink
# escape the `resolve_sub_path` containment check.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove comment. This won't really make sense outside of this PR and the context can be found by using "Blame" and looking at the PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — removed the comment. The rationale now lives in the commit message (findable via Blame).

Comment thread keras/src/utils/file_utils_test.py Outdated
Comment on lines +213 to +221
"""A member routed through an in-bounds symlink via `..` must not
escape the base dir.

The symlink (``link`` -> ``.``) is itself within the base dir, but a
member named ``link/../escaped.txt`` resolves through it to the parent
directory. The containment check must reject it (regression: a lexical
``..`` collapse would cancel ``link/..`` without ever following the
symlink).
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove comment. This won't really make sense outside of this PR and the context can be found by using "Blame" and looking at the PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — collapsed it to a single-line docstring matching the other tests in this class.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — removed.

`resolve_path` wrapped `realpath` with `abspath`, which collapses `..`
lexically before symlinks are resolved. For an archive member named
`link/../x`, the `..` cancels the `link` component as text, so `realpath`
never follows the symlink; `resolve_sub_path` then judges the member to be
inside the extraction directory while `tarfile.extractall` writes it through
the real symlink to a location outside it.

A TAR with an in-bounds symlink `link -> .` plus a file `link/../<name>`
therefore writes `<name>` outside the extraction directory (chainable to
deeper paths) via `get_file(extract=True)` / `extract_archive`.

Use `os.path.realpath(path)` directly: it returns an absolute path and
follows symlinks component by component, so `..` is applied after the symlink
is resolved and the containment check is correct.
@LinZiyuu LinZiyuu force-pushed the fix-get-file-symlink-traversal branch from 8679897 to 4ab48b1 Compare June 2, 2026 00:29
@LinZiyuu LinZiyuu requested a review from hertschuh June 2, 2026 01:08

@hertschuh hertschuh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@google-ml-butler google-ml-butler Bot added kokoro:force-run ready to pull Ready to be merged into the codebase labels Jun 2, 2026
@hertschuh hertschuh merged commit 9867df4 into keras-team:master Jun 2, 2026
13 checks passed
@google-ml-butler google-ml-butler Bot removed awaiting review ready to pull Ready to be merged into the codebase kokoro:force-run labels Jun 2, 2026
@LinZiyuu

LinZiyuu commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Hi @hertschuh,
Thanks for merging this! This PR fixes the vulnerability I reported on huntr. Whenever you have a moment, could you validate the report on the platform? Link: https://huntr.com/bounties/4315b670-239b-40ce-b6fa-5d78e95d7b1c

Happy to provide any additional details if needed.

hertschuh added a commit that referenced this pull request Jun 25, 2026
* Version bump to 3.12.3

* Fix: KerasFileEditor eject HDF5 ExternalLink/SoftLink groups (#22899)

* KerasFileEditor: reject HDF5 ExternalLink/SoftLink groups

`_extract_weights_from_store` walked the HDF5 tree with raw subscripts
(`data[key]`, `value["vars"]`). When the input file is attacker-supplied,
either of those subscripts transparently follows an `h5py.ExternalLink`
or `h5py.SoftLink` group into another HDF5 file on the host. The
attacker-controlled payload can be as small as ~944 bytes and contains
no Python code, so it bypasses any static-analysis-based defense.

After the link is followed, `editor.weights_dict` is populated from the
linked file. A subsequent `editor.save(...)` — part of the documented
edit workflow — writes those tensors back out as regular datasets,
turning the editor into a full cross-file disclosure / exfiltration
channel for any HDF5 file (e.g. another user's weights checkpoint on a
shared training cluster, an `~/.cache/huggingface/...` cached model,
etc.) that the victim process can read.

PR #22057 (the original CVE-2026-1669 fix) already added a
dataset-level `value.external` check to this same method, but only
covered `Dataset` external storage. The matching `ExternalLink` /
`SoftLink` guard on `Group` children — which is what `safe_get_h5_group`
in `saving_lib` was written to provide — was not applied here. Apply
it now:

  * Before subscripting `data[key]`, fetch the link class via
    `data.get(..., getclass=True, getlink=True)` and raise `ValueError`
    on `ExternalLink` / `SoftLink`. This mirrors `safe_get_h5_group`
    but handles the case where the child can be a `Dataset` rather
    than a `Group`.
  * Resolve `value["vars"]` through `saving_lib.safe_get_h5_group` so
    a nested link named `vars` cannot be used to redirect the editor.

Keras's own `save_model` / `save_weights` never emits `ExternalLink` or
`SoftLink`, so legitimate weight files are unaffected — only attacker-
crafted payloads are rejected.

Two regression tests cover both link kinds.

Reported via huntr:
https://huntr.com/bounties/d5688f27-73af-4853-8855-0c3a45c0f290

* KerasFileEditor: trim review comments

Shorten the ExternalLink/SoftLink guard comment, restore the original
"vars" recursion comment, and drop the added test docstrings/comments
per review. No behavior change.

* Fix: load_weights reject HDF5 ExternalLink/SoftLink on legacy .h5 dispatcher (#22900)

* load_weights: route legacy `.h5` `model_weights` through safe_get_h5_group

`saving_api.py` was the only remaining raw `f["model_weights"]`
subscript on the legacy HDF5 weight-loading path. h5py transparently
follows `h5py.ExternalLink` / `h5py.SoftLink` on plain subscripts, so
a ~1 KB attacker-supplied `.h5` whose `model_weights` group is an
`ExternalLink` redirects `keras.saving.load_weights(model, "x.h5")`
(equivalently `model.load_weights("x.h5")`) into any HDF5 file the
victim process can read. The contents land in the victim's
in-process model and become trivially exfiltrable via
`model.get_weights()`, `model.save(...)`, or inference.

This is the same vulnerability class as CVE-2026-1669 and is a
missed spot relative to PR #22057 and #22801, which already routed
the equivalent accesses in `saving_lib.py`, `file_editor.py`, and
`legacy/saving/legacy_h5_format.py` through `safe_get_h5_group`
(introduced specifically for this purpose). `safe_mode` does not
gate this path — it only guards Python-object deserialization.

The fix is a one-line change here: use `saving_lib.safe_get_h5_group`
at this dispatcher access, matching what `legacy_h5_format` already
does for its own `f["model_weights"]` read.

Keras's own `save_model` / `save_weights` never emits
`ExternalLink` / `SoftLink`, so legitimate `.h5` weight files are
unaffected; only attacker-crafted payloads are rejected.

Reported via huntr:
https://huntr.com/bounties/d5688f27-73af-4853-8855-0c3a45c0f290

* load_weights: trim review comments

Remove the safe_get_h5_group dispatcher comment and the added test
docstrings/comments per review. No behavior change.

* Fix: reject hard-link tar members whose name escapes the extraction directory (#22973)

* Reject hard links whose name escapes the extraction directory

filter_safe_tarinfos / is_link_in_dir validated only the target
(info.linkname) of a hard-link tar member, never the link's own
destination (info.name). A crafted tar with a hard-link member whose
name is an absolute path or contains "../" therefore passed the filter
and was materialized outside the extraction directory by
tarfile.extractall, allowing arbitrary file write/overwrite via
keras.utils.get_file(extract=True) / extract_archive on Python < 3.12
(where tarfile's data filter is not applied).

Validate info.name for hard links the same way regular files and
symbolic links are already validated, completing the tar-extraction
path-traversal hardening for the hard-link case.

* Address review: shorten hard-link comment and drop redundant test

Condense the is_link_in_dir hard-link comment to a single line per
reviewer request; context lives in the PR description and blame.

Remove FilterSafePathsTest.test_hardlink_name_traversal_skipped: the
hardlink_name_out parameterized case in IsLinkInDirTest already covers
rejection of a hard link whose name escapes base_dir.

* Fix: reject HDF5 shape-bomb datasets in the main load_model/load_weights path (#22975)

* Reject HDF5 datasets that declare far more data than is stored

safe_get_h5_dataset materialized an h5py dataset sized to its declared
shape without checking how much data is actually stored on disk. A
crafted .keras/.weights.h5 with a chunked, compressed, fill-value-only
dataset can declare an enormous shape (e.g. petabytes) while occupying a
few kilobytes, forcing load_model/load_weights to attempt a huge
allocation and OOM-kill the process (CWE-789 / CWE-409).

Reject datasets whose declared in-memory size both exceeds a floor and
exceeds the on-disk storage size by a large factor. Genuine arrays (even
compressed) stay well within this bound; shape/decompression bombs do
not. This extends the KerasFileEditor-only size guard to the main
load_model/load_weights path.

* Address review: 4 GiB floor, readable sizes, drop redundant test

- Raise _H5_DATASET_BOMB_FLOOR_BYTES from 1 GiB to 4 GiB for consistency
  with the KerasFileEditor fix (PR #21880).
- Format the declared/stored byte counts in the error with
  readable_memory_size so PiB-scale values are legible.
- Remove SafeGetH5DatasetTest.test_allows_regular_dataset; normal dataset
  loading is already covered by the existing round-trip tests.

* Fix: KerasFileEditor reject HDF5 virtual datasets (#22976)

* Fix: KerasFileEditor reject HDF5 virtual datasets

KerasFileEditor._extract_weights_from_store reads each leaf dataset with a
raw value[()]. It already rejects group-level ExternalLink/SoftLink (#22899)
and Dataset external storage (#22057, the CVE-2026-1669 fix), but never
checks value.is_virtual. An HDF5 Virtual Dataset is a HardLink with
external=None, so it passes both guards, yet h5py resolves its source mapping
on read and returns bytes from another HDF5 file the victim process can read.
The leaked tensors surface via editor.weights_dict / weights_summary() and
are re-emitted as plain datasets by editor.save(), reproducing the
CVE-2026-1669 cross-file disclosure channel via virtual storage instead of
links.

saving_lib.safe_get_h5_dataset already rejects virtual datasets; this path
did not route through it. Add the matching is_virtual check inline.
Regression test covers the VDS case.

* Address review comments

- file_editor.py: drop the explanatory comment and include
  current_inner_path in the virtual-dataset rejection message so the
  user sees which dataset triggered it (Rule 140 / contextual error).
- file_editor_test.py: reuse a single self.get_temp_dir() for both
  fixture files; rename victim_private.weights.h5 -> other.weights.h5
  and vds_payload.weights.h5 -> virtual.weights.h5 (and the matching
  *_fpath locals) to use neutral names.

* Reject ZIP archive members that declare far more data than is stored (#23010)

`_load_model_from_fileobj` reads `config.json`, `model.weights.h5` and the
shard map from the `.keras` archive without bounding the decompressed size.
The `config.json` read is the first thing load_model does, before any other
validation and regardless of `safe_mode`. A crafted `.keras` can store a few
KB on disk yet declare a multi-gigabyte member, so loading it allocates memory
(or extracts to disk) for the declared size and the process is OOM-killed or
fills the disk (CWE-409).

Add `_reject_zip_bomb` (rejects a member whose declared size both exceeds a
4 GiB floor -- matching the HDF5 dataset guard for CVE-2026-0897 -- and exceeds
its on-disk size by more than 100x) and a `_safe_zip_read` that checks then
reads. Apply it to:

- `config.json` and the shard map, via `_safe_zip_read`;
- `model.weights.h5`, via `_reject_zip_bomb` placed *before* the
  in-memory/extract-to-disk/on-the-fly `try/except` -- the bare `except` falls
  back to reading the weights on the fly, so a check inside it would be
  swallowed and the bomb still read; checking up front covers all three paths.

Keras writes archive members uncompressed (ratio ~1:1) and DEFLATE cannot
exceed ~1032:1, so genuine artifacts -- including arbitrarily large legitimate
models -- are unaffected.

Co-authored-by: hertschuh <1091026+hertschuh@users.noreply.github.com>

* Resolve paths with realpath to prevent symlink traversal in extraction (#23015)

`resolve_path` wrapped `realpath` with `abspath`, which collapses `..`
lexically before symlinks are resolved. For an archive member named
`link/../x`, the `..` cancels the `link` component as text, so `realpath`
never follows the symlink; `resolve_sub_path` then judges the member to be
inside the extraction directory while `tarfile.extractall` writes it through
the real symlink to a location outside it.

A TAR with an in-bounds symlink `link -> .` plus a file `link/../<name>`
therefore writes `<name>` outside the extraction directory (chainable to
deeper paths) via `get_file(extract=True)` / `extract_archive`.

Use `os.path.realpath(path)` directly: it returns an absolute path and
follows symlinks component by component, so `..` is applied after the symlink
is resolved and the containment check is correct.

* Reject npz weight members that declare far more data than is stored (#23016)

`NpzIOStore.get` returns `self.contents[path].tolist()`. Accessing
`self.contents[path]` makes NumPy allocate an array sized to the `.npy`
header's declared shape before any stored data is validated, so a tiny npz
member can declare an enormous shape and drive an unbounded allocation.

`keras.saving.load_model` selects the npz reader purely from the archive file
list (`model.weights.npz` present and `model.weights.h5` absent), so an
attacker who supplies the `.keras` file controls this path: a ~2 KB file can
force an arbitrarily large allocation (clean `MemoryError`, or an OOM kill for
a shape tuned near available RAM whose zero-filled member compresses heavily).
`safe_mode` does not gate weight I/O.

Peek each member's `.npy` header and reject it when the declared array size
exceeds both a 4 GiB floor and 100x the bytes actually stored, before the
array is materialized.

Co-authored-by: hertschuh <1091026+hertschuh@users.noreply.github.com>

* Validate DiskIOStore asset paths stay within the working directory (#23017)

`DiskIOStore.make`/`get` joined a caller-supplied asset path onto the working
directory (and `make` created it) without verifying that it stays inside that
directory. This is the one asset I/O path in the save/load pipeline that
skips the containment already applied to archive extraction
(`filter_safe_tarinfos`/`filter_safe_zipinfos`) and Orbax asset writes
(`_write_nested_dict_to_dir`), both of which use `resolve_sub_path`.

Apply the same `resolve_sub_path` check in `make`/`get`/`has_path` so an asset
path that escapes the working directory is rejected. Normal nested asset paths
are unaffected.

* 🛡️ Sentinel: [HIGH] Fix insecure deserialization in dataset utilities (#23026)

* security: set allow_pickle=False for np.load in dataset utilities

np.load defaults to allow_pickle=False for security reasons, to prevent
insecure deserialization vulnerabilities that could lead to arbitrary
code execution if a maliciously crafted `.npz` file is loaded.

Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily
for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`).
This commit removes the explicit `allow_pickle=True` argument for those datasets,
enhancing the security of the application.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* security: set allow_pickle=False for np.load in dataset utilities

np.load defaults to allow_pickle=False for security reasons, to prevent
insecure deserialization vulnerabilities that could lead to arbitrary
code execution if a maliciously crafted `.npz` file is loaded.

Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily
for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`).
This commit removes the explicit `allow_pickle=True` argument for those datasets,
enhancing the security of the application.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* security: set allow_pickle=False for np.load in dataset utilities

np.load defaults to allow_pickle=False for security reasons, to prevent
insecure deserialization vulnerabilities that could lead to arbitrary
code execution if a maliciously crafted `.npz` file is loaded.

Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily
for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`).
This commit removes the explicit `allow_pickle=True` argument for those datasets,
enhancing the security of the application.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* security: set allow_pickle=False for np.load in dataset utilities

np.load defaults to allow_pickle=False for security reasons, to prevent
insecure deserialization vulnerabilities that could lead to arbitrary
code execution if a maliciously crafted `.npz` file is loaded.

Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily
for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`).
This commit removes the explicit `allow_pickle=True` argument for those datasets,
enhancing the security of the application.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* security: set allow_pickle=False for np.load in dataset utilities

np.load defaults to allow_pickle=False for security reasons, to prevent
insecure deserialization vulnerabilities that could lead to arbitrary
code execution if a maliciously crafted `.npz` file is loaded.

Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily
for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`).
This commit removes the explicit `allow_pickle=True` argument for those datasets,
enhancing the security of the application.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* security: set allow_pickle=False for np.load in dataset utilities

np.load defaults to allow_pickle=False for security reasons, to prevent
insecure deserialization vulnerabilities that could lead to arbitrary
code execution if a maliciously crafted `.npz` file is loaded.

Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily
for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`).
This commit removes the explicit `allow_pickle=True` argument for those datasets,
enhancing the security of the application.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* security: set allow_pickle=False for np.load in dataset utilities

np.load defaults to allow_pickle=False for security reasons, to prevent
insecure deserialization vulnerabilities that could lead to arbitrary
code execution if a maliciously crafted `.npz` file is loaded.

Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily
for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`).
This commit removes the explicit `allow_pickle=True` argument for those datasets,
enhancing the security of the application.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>

* 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load` (#23034)

* 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load`

🚨 Severity: LOW / ENHANCEMENT
💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes.
🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded.
🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`.
✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load`

🚨 Severity: LOW / ENHANCEMENT
💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes.
🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded.
🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`.
✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load`

🚨 Severity: LOW / ENHANCEMENT
💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes.
🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded.
🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`.
✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load`

🚨 Severity: LOW / ENHANCEMENT
💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes.
🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded.
🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`.
✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load`

🚨 Severity: LOW / ENHANCEMENT
💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes.
🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded.
🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`.
✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load`

🚨 Severity: LOW / ENHANCEMENT
💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes.
🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded.
🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`.
✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load`

🚨 Severity: LOW / ENHANCEMENT
💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes.
🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded.
🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`.
✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load`

🚨 Severity: LOW / ENHANCEMENT
💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes.
🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded.
🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`.
✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

* 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load`

🚨 Severity: LOW / ENHANCEMENT
💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes.
🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded.
🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`.
✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions.

Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>

* Make Lambda and TorchModuleWrapper from_config fail closed when safe_mode is unset (#23048)

* Make Lambda and TorchModuleWrapper from_config fail closed when safe_mode is unset

`Lambda.from_config` computed `safe_mode = safe_mode or in_safe_mode()`, which
is `None` (falsy) when the layer is deserialized outside of a `SafeModeScope`
and without an explicit `safe_mode` -- for example a direct
`keras.Model.from_config(...)` / `keras.Sequential.from_config(...)` call, or a
layer config that omits the `"module"` key and is routed through the legacy
deserializer. In that case the guard did not fire and the marshalled lambda
bytecode was loaded via `func_load`, allowing arbitrary code execution.
`TorchModuleWrapper.from_config` had the same problem with its `torch.load()`
pickle (`if in_safe_mode():`).

Treat an unset safe mode as safe (fail closed), matching the handling already
used by `TFSMLayer.from_config`: deserialization is refused unless safe mode is
explicitly disabled via `safe_mode=False`, `SafeModeScope(False)`, or
`keras.config.enable_unsafe_deserialization()`. `load_model` and
`model_from_json` are unaffected -- they already establish a `SafeModeScope`.

Adds regression tests for both layers and updates the `TorchModuleWrapper`
round-trip test to disable safe mode explicitly.

* Add `safe_mode` argument to `TorchModuleWrapper.from_config`

Follow the `Lambda.from_config` / `TFSMLayer.from_config` pattern: accept `custom_objects` and `safe_mode` keyword arguments. When `safe_mode` is unset, fall back to the ambient `SafeModeScope`, treating an unset scope as safe (fail closed). Update the test to exercise the explicit `safe_mode=False` argument alongside the ambient-scope path.

* Use `filter="data"` in `TarFile.extractall` on supported versions of Python 3.10 and 3.11. (#23108)

While the security feature of `filter="data"` was introduced in Python 3.12, it was also backported to Python >= 3.10.12 and Python >= 3.11.4.

* Add to the list of APIs that should not be part of a reloaded model. (#23115)

Some APIs make no sense in the context of reloading a model and present a security risk.

Renamed the set and generalized the error message.

* Fix saving tests for r3.12 compatibility

* remove the unrelated test in this branch

---------

Co-authored-by: Ziyu Lin <104151270+LinZiyuu@users.noreply.github.com>
Co-authored-by: hertschuh <1091026+hertschuh@users.noreply.github.com>
Co-authored-by: Rakesh Iyer <70032655+rni418@users.noreply.github.com>
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants