GitHub Advisoryadvisory · crates.io
GHSA-hjqx-j4rw-g7xv zip: Arbitrary file write outside the extraction directory, because ZipArchive::extract validates symlink targets lexically.
HIGH
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
References
- https://github.com/zip-rs/zip2/security/advisories/GHSA-hjqx-j4rw-g7xv web
- https://nvd.nist.gov/vuln/detail/CVE-2026-115908 advisory
- https://github.com/zip-rs/zip2/commit/09c030eab56e8db8fb3c1c33f554407b30bad8fa web
- https://github.com/zip-rs/zip2 package
- https://github.com/zip-rs/zip2/releases/tag/v9.0.0 web