GitHub Advisoryadvisory · crates.io

GHSA-hjqx-j4rw-g7xv zip: Arbitrary file write outside the extraction directory, because ZipArchive::extract validates symlink targets lexically.

HIGH Published 2026-10-29 Modified 2026-10-29 Official page ↗
Aliases
CVE-2026-115908
Ecosystem
crates.io
Packages
zip
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H
Weaknesses
CWE-22, CWE-59, CWE-61
Reviewed
2026-10-29

Affected packages

• crates.io zip: >= 7.2.0, < 9.0.0 → fixed in 9.0.0

Description

Summary • Verified version: zip `9.0.0-pre3` • Affected versions: `7.2.0` and later • Precondition: The attacker knows (or can guess) the absolute path of the extraction destination The validation applied to symlinks is purely lexical. As a result, an attacker can write arbitrary files outside the extraction directory through a symlink. Exploitation requires the attacker to know (or guess) the absolute path of the destination, but this is realistic in practice: CI workspaces, `/opt/app`, a fixed Downloads path, and so on. > Impact extends further on Windows (verified on real hardware for both the separate-drive and UNC-share cases): > Because `simplified_components` discards the `Prefix` component, paths like `D:\` or a UNC path `\\attacker\share\` pass the `starts_with` check. > As a result, `outpath.push` gets replaced onto a different drive/remote share, so a single symlink is enough — no intermediate symlink chain is required. > That said, creating a symlink on Windows requires `SeCreateSymbolicLinkPrivilege` (administrator rights or Developer Mode enabled). > Real-hardware verification (Windows 10 VM (19045.2604), `tests/poc_win.rs`): > With the extraction target set to a temporary directory under `C:`, both of the following paths returned `Ok(())` from `extract()` and successfully wrote outside the extraction target. > In both cases `extract()` returned no error (`Ok(())`), and it was confirmed that the contents of `pwned.txt` matched the attacker-specified value. Relationship to CVE-2025-29787 This is not a duplicate of the existing CVE-2025-29787 (2025-03); rather, the protection introduced for that CVE was subsequently lost. Timeline (git history, UTC) | commit | Date | Description | | ---------- | ---------- | ---------------------------------------------------------------------------------------------------------------- | | `d4539d6b` | 2025-02-28 | Initial fix for CVE-2025-29787. Canonicalizes output paths and symlink targets and verifies they descend from the destination | | `0199ac2c` | 2025-03-15 | Before the v2.3.0 release, the above is replaced with a lexical check based on `simplified_components` | | `v2.3.0` | 2025-03-16 | Released as the CVE fix. What actually shipped is the lexical check | | `d5cf4b55` | 2026-01-18 | Changes how `simplified_components` handles absolute paths, from rejecting them to silently stripping them (released in `v7.2.0`) | Why v2.3.0 was not affected, and how the protection was lost The `safe_prepare_path` in v2.3.0 performs essentially the same lexical check as the current code. However, `simplified_components` at that time rejected any path containing an absolute component, so an absolute symlink target was never accepted in the first place and this attack path did not work. ```rust // v2.3.0: reject absolute paths Component::Prefix(_) | Component::RootDir => return None, // Current: ignore the fact that the path is absolute Utf8WindowsComponent::Prefix(_) | Utf8WindowsComponent::RootDir => (), ``` `d5cf4b55` was intended to make archives containing absolute path names extractable, but as a side effect absolute symlink targets now pass the lexical check, which silently removed the protection against CVE-2025-29787. Verification The following three cases were compared using the same build (`9.0.0-pre3`): | Payload / condition | Result | | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Original PoC for CVE-2025-29787 (relative symlink `parent -> ../`) | Blocked with `Err(InvalidArchive("Invalid symlink target path"))` (the CVE fix itself is effective) | | PoC in this report (two-stage symlink using absolute paths) | Succeeds with `Ok(())`, writing outside the destination | | PoC in this report, with the `d5cf4b55` change reverted to `return None` | Blocked with `Err(InvalidArchive("Invalid symlink target path"))` | The first row shows that the fix for the CVE itself still works, confirming that this report is not a duplicate. The third row confirms that this vulnerability originates from the change in `d5cf4b55`. Differences from CVE-2025-29787 • Stricter precondition: This issue requires the absolute path of the destination to be known (`AC:H`). The CVE worked with a relative `../` and had no such precondition. • Narrower API surface: This issue affects only `ZipArchive::extract` (and `extract_unwrapped_root_dir`). `ZipStreamReader::extract` is not affected, because `external_attributes` in the local header is `0` and therefore `is_symlink()` never returns true (the CVE affected both APIs). Details Lexical validation of the path string The symlink validation in `src/read/zipfile.rs: L290-345` is lexical. `src/read/zipfile.rs: fn safe_prepare_path` (L316-321): ```rust if !crate::path::simplified_components(&target) .ok_or(invalid!("Invalid symlink target path"))? .starts_with( &crate::path::simplified_components(base_path) .ok_or(invalid!("Invalid base path"))?, ) ``` `simplified_components` discards the information that a path is absolute (drive prefix / root), normalizes it lexically, and then merely performs a prefix comparison between the two results. It never looks at how the path actually resolves on the file system. Consequently, the classic Zip Slip vector using `..` is blocked, but paths that involve symlink resolution are not. `src/path.rs: fn simplified_components` (L9~26): ```rust /// Simplify a path by removing the prefix and parent directories and only return normal components pub(crate) fn simplified_components(input: &Path) -> Option> { let mut out = Vec::new(); let input_str = input.to_str()?; for component in Utf8WindowsPath::new(input_str).components() { match component { // Skip prefix and root directory components instead of rejecting the entire path // This allows extraction of ZIP files with absolute paths, similar to other ZIP tools Utf8WindowsComponent::Prefix(_) | Utf8WindowsComponent::RootDir => (), Utf8WindowsComponent::ParentDir => { out.pop()?; } Utf8WindowsComponent::Normal(s) => out.push(OsStr::new(s)), Utf8WindowsComponent::CurDir => (), } } Some(out) } ``` Insufficient validation of `target` Once the path check passes, `src/read/zipfile.rs: fn safe_prepare_path` (L345) performs `outpath.push(target)`, but whether any intermediate component of `target` is itself a symlink is never checked. In addition, `make_symlink`, which is called from `extract_internal`, delegates to `make_symlink_impl` and only validates that the archive-supplied `target` is valid UTF-8. There is no check that `target` stays inside the destination, so the symlink is created as-is. `src/read/mod.rs: fn extract_internal` (L396-406): ```rust [cfg(any(unix, windows))] if file.is_symlink() { let mut target = Vec::with_capacity(file.size() as usize); file.read_to_end(&mut target)?; drop(file); make_symlink(&outpath, &target, &self.shared.files)?; continue; } else if file.is_dir() { make_writable_dir_all(&outpath)?; continue; } ``` `src/read/mod.rs: fn make_symlink_impl` (L57-90): ```rust [cfg(unix)] pub(crate) fn make_symlink_impl( outpath: &Path…

Related

Alias · CVE (1)

  • CVE-2026-115908 zip is a zip library for Rust that supports reading and writing simple ZIP files. vulnerability

Weakness · CWE (3)

  • CWE-22 Improper Limitation of a Pathname to a Restricted Directory weakness
  • CWE-59 Improper Link Resolution Before File Access weakness
  • CWE-61 UNIX Symbolic Link (Symlink) Following weakness

References