Oct 29, 2026·10 min read·0 visits
Versions 7.2.0 through 8.6.0 (and 9.0.0 pre-releases) of the Rust zip crate validate extracted symlink targets lexically rather than resolving intermediate links on disk. An attacker who knows the target extraction directory path can use a two-hop symlink chain (or a single link abusing stripped drive/UNC prefixes on Windows) to write or overwrite arbitrary files outside the destination directory.
CVE-2026-115908 (GHSA-hjqx-j4rw-g7xv) is a high-severity path traversal and link-following vulnerability in the Rust zip library (zip-rs/zip2) affecting versions >= 7.2.0 and < 9.0.0. Because ZipArchive::extract and ZipArchive::extract_unwrapped_root_dir validate symbolic link targets solely via lexical prefix comparisons rather than resolving intermediate filesystem links, an attacker who knows or can guess the absolute extraction path can construct multi-hop symlink chains that escape the destination directory, enabling arbitrary file creation and overwrite with the privileges of the extracting process.
The zip crate (maintained in the zip-rs/zip2 repository) is a widely deployed library in the Rust ecosystem for reading and writing ZIP archives. To simplify archive unpacking, the library exposes high-level extraction methods: ZipArchive::extract and ZipArchive::extract_unwrapped_root_dir. These methods iterate through archive entries and materialize directories, regular files, and symbolic links onto the host filesystem within a caller-specified destination directory.
In versions >= 7.2.0 and < 9.0.0, these extraction routines contain a link-following and path traversal vulnerability classified under CWE-59 (Improper Link Resolution Before File Access), CWE-61 (UNIX Symbolic Link Following), and CWE-22 (Path Traversal). The flaw is a regression introduced in commit 46dc29cda6749736ddb1ec502aff06a65ffe54f8 (released in v7.2.0), which inadvertently broke the directory containment defenses originally implemented to address CVE-2025-29787.
When unpacking an archive containing symbolic links, the extraction engine validates link targets using a purely lexical prefix comparison via safe_prepare_path (src/read/zipfile.rs) and simplified_components (src/path.rs). It does not verify where symlinks point at creation time, nor does it resolve intermediate symbolic links along the path when subsequent files are extracted.
Consequently, an attacker who knows or can predict the absolute filesystem path of the extraction directory can craft a ZIP archive with a multi-hop symlink chain. While the outer symlink appears lexically contained within the destination path, resolving its intermediate components redirects file writes outside the extraction root. On Windows systems where the process holds SeCreateSymbolicLinkPrivilege, the flaw also permits a single-hop escape due to discarded drive and UNC path prefixes.
The root cause of CVE-2026-115908 resides in the interaction between safe_prepare_path in src/read/zipfile.rs, simplified_components in src/path.rs, and make_symlink in src/read/mod.rs. Prior to creating or writing to a target path during extraction, safe_prepare_path checks whether the path traverses any existing symbolic links and attempts to verify that those links remain inside base_path.
However, the validation relies on comparing normalized path component vectors via simplified_components(&target).starts_with(&simplified_components(base_path)). This design contains three distinct structural flaws that enable containment bypasses.
First, make_symlink creates symbolic links on disk without validating whether their targets resolve within the destination directory. An archive can therefore plant an arbitrary outward-pointing symlink (for example, b -> /home/victim) inside the destination directory. As long as no subsequent entry directly writes through b in a way that fails the shallow lexical check, the link remains on disk.
Second, safe_prepare_path inspects only the immediate target string of a symlink without recursively resolving intermediate components on the filesystem. If an archive creates a second symlink a -> <dest>/b/sub, the lexical check compares the component tokens of <dest>/b/sub against <dest>. Because <dest>/b/sub starts with the tokens of <dest>, validation succeeds. When the extractor subsequently opens a/payload for writing, the operating system kernel resolves a to <dest>/b/sub, encounters the previously created symlink b, and follows it outside <dest>.
Third, simplified_components was originally built to sanitize relative archive entry names and explicitly discards Component::Prefix (Windows drive letters such as C: or UNC shares \\server\share) and Component::RootDir (/ or \). On Windows, if a symlink target specifies a different volume or UNC share whose directory hierarchy mirrors the normal components of base_path, simplified_components strips the volume prefix and treats the target as enclosed within base_path.
In the vulnerable versions (>= 7.2.0, < 9.0.0), safe_prepare_path validated symlinks using lexical component slicing in src/read/zipfile.rs:
// Vulnerable validation logic in src/read/zipfile.rs
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"))?,
)
{
return Err(invalid!("Symlink target escapes the destination directory"));
}Because simplified_components returns Option<Vec<&OsStr>> containing only Component::Normal segments (resolving .. purely in-memory without querying filesystem metadata), it is blind to on-disk symlinks and Windows volume prefixes.
Commit 09c030eab56e8db8fb3c1c33f554407b30bad8fa (and merge commit 2b1c4244a6f42906c8b3f21a358bf2321d0baaef for v9.0.0) replaces this mechanism with an iterative, filesystem-aware resolution engine in src/path.rs named resolve_enclosed:
// Patched resolution logic in src/path.rs
pub(crate) fn resolve_enclosed(
base: &Path,
start: &Path,
components: impl IntoIterator<Item = OsString>,
create_dirs: bool,
) -> ZipResult<PathBuf> {
debug_assert!(start.starts_with(base));
let mut current = start.to_path_buf();
let mut queue: VecDeque<OsString> = components.into_iter().collect();
let mut hops = 0usize;
while let Some(component) = queue.pop_front() {
if component.is_empty() || component == OsStr::new(".") {
continue;
}
// Prevent parent directory traversal above the extraction root
if component == OsStr::new("..") {
if current == base {
return Err(invalid!("Path escapes the destination directory"));
}
current.pop();
continue;
}
current.push(&component);
// Query actual filesystem metadata without following the link yet
let meta = match fs::symlink_metadata(¤t) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if create_dirs && !queue.is_empty() {
make_writable_dir_all(¤t)?;
}
continue;
}
Err(e) => return Err(e.into()),
};
if !meta.is_symlink() {
continue;
}
// Enforce symlink recursion depth limit (MAX_SYMLINK_HOPS = 40)
hops += 1;
if hops > MAX_SYMLINK_HOPS {
return Err(invalid!("Extraction followed a symlink too deep"));
}
// Read symlink target and resolve every component through the queue
let target = fs::read_link(¤t)?;
current.pop();
let rest: PathBuf = if target.is_absolute() {
let Some(rest) = strip_base_prefix(base, &target) else {
return Err(invalid!("Symlink target escapes the destination directory"));
};
current = base.to_path_buf();
rest.to_path_buf()
} else {
target
};
for c in rest.components().rev() {
match c {
Component::Prefix(_) | Component::RootDir => {
return Err(invalid!("Invalid symlink target path"));
}
Component::CurDir => (),
Component::ParentDir => queue.push_front(OsString::from("..")),
Component::Normal(s) => queue.push_front(s.to_os_string()),
}
}
}
debug_assert!(current.starts_with(base));
Ok(current)
}The patch addresses the root cause across several dimensions:
current is inspected with fs::symlink_metadata. When a symlink is encountered, its target is read via fs::read_link, decomposed into components, and pushed onto the front of queue. Intermediate symlinks are therefore expanded and validated step-by-step.make_symlink in src/read/mod.rs now calls resolve_enclosed before creating the symlink on disk. Even if an outward-pointing symlink is never traversed by subsequent archive entries, extraction aborts immediately.MAX_SYMLINK_HOPS = 40 constant prevents denial-of-service via circular symlink chains (matching the Linux kernel MAXSYMLINKS limit).strip_base_prefix and strip_equivalent_prefix helpers properly equate verbatim prefixes (\\?\C:\) with standard disk prefixes (C:\) on the same volume while rejecting mismatched drive letters, UNC paths, drive-relative paths (C:foo), and root-relative paths (\foo).> [!NOTE]
> While resolve_enclosed resolves symlink hops in user space before opening the final path, residual Time-of-Check to Time-of-Use (TOCTOU) race conditions remain theoretically possible if a separate local process concurrently mutates the extraction directory during unpacking. In standard single-extractor threat models, the fix is complete.
Exploiting CVE-2026-115908 on Unix-like systems requires two conditions: the target application must call ZipArchive::extract or ZipArchive::extract_unwrapped_root_dir on an attacker-supplied archive, and the attacker must know or accurately guess the absolute path of the extraction destination directory (<dest>). In many server, CI/CD, and container environments, extraction paths follow predictable conventions (such as /tmp/extract, /app/uploads, or /home/runner/work/...).
To execute the two-hop symlink escape, the attacker constructs a ZIP archive containing three ordered entries:
b): A symbolic link named b whose target points to the external directory the attacker wishes to modify (for example, /home/victim).a): A symbolic link named a whose target is <dest>/b/.ssh. When safe_prepare_path inspects a, it checks whether <dest>/b/.ssh starts with <dest>. Because the prefix matches lexically, the check passes.a/authorized_keys): A regular file entry written through a. The OS resolves a to <dest>/b/.ssh, traverses b to /home/victim/.ssh, and overwrites /home/victim/.ssh/authorized_keys.The official fix commit includes regression tests in tests/extract_symlink.rs that demonstrate this exact attack vector:
#[cfg(not(miri))]
#[test]
fn overwrite_existing_ssh_authorized_keys() -> zip::result::ZipResult<()> {
use std::fs::{create_dir, create_dir_all, read_to_string, write};
use std::io::{Cursor, Write};
use tempfile::TempDir;
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipWriter};
const LEGIT_KEY: &str = "ssh-ed25519 AAAA...LEGIT victim@host\n";
let tmp = TempDir::with_prefix("read__overwrite_existing_ssh_authorized_keys")?;
let tmp_path = tmp.path().canonicalize()?;
let dest = tmp_path.join("dest");
create_dir(&dest)?;
// Target file outside the extraction directory
let home = tmp_path.join("home_victim");
let authorized_keys = home.join(".ssh/authorized_keys");
create_dir_all(home.join(".ssh"))?;
write(&authorized_keys, LEGIT_KEY)?;
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
// Step 1: 'b' points outside <dest> to the victim's home directory
writer.add_symlink("b", home.to_str().unwrap(), options)?;
// Step 2: 'a' is lexically prefixed with <dest>, bypassing simplified_components
writer.add_symlink("a", dest.join("b/.ssh").to_str().unwrap(), options)?;
// Step 3: Write attacker payload through 'a'
writer.start_file("a/authorized_keys", options)?;
writer.write_all(b"ssh-ed25519 AAAA...ATTACKER attacker@evil\n")?;
let mut reader = writer.finish_into_readable()?;
// On vulnerable versions (>= 7.2.0, < 9.0.0), extraction succeeds and overwrites authorized_keys.
// On patched v9.0.0, extract(&dest) returns an error and leaves the file intact.
assert!(reader.extract(&dest).is_err());
assert_eq!(read_to_string(&authorized_keys)?, LEGIT_KEY);
Ok(())
}On Windows targets, if the extracting process runs with Administrator privileges or inside an environment with Developer Mode enabled (granting SeCreateSymbolicLinkPrivilege), an attacker can also achieve a single-hop escape. Because simplified_components strips Component::Prefix and Component::RootDir, a symlink targeting D:\dest\sub or \\attacker\share\dest\sub matches a destination directory of C:\dest, redirecting writes across volumes or over SMB.
CVE-2026-115908 is rated High severity with a CVSS v3.1 base score of 7.4 (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H). The Attack Complexity is scored as High (AC:H) because successful exploitation requires the attacker to know or guess the absolute destination path on the host filesystem (or requires the target Windows process to hold SeCreateSymbolicLinkPrivilege). No authentication (PR:N) or user interaction (UI:N) is required beyond submitting a malicious ZIP archive to an application that extracts it.
The primary impact is High Integrity (I:H) and High Availability (A:H). An attacker can create new files or overwrite existing files anywhere on the filesystem where the extracting process has write permissions. Depending on the privileges of the service unpacking the archive, arbitrary file write frequently leads to remote code execution (RCE).
Common escalation paths from arbitrary file write to code execution include overwriting user SSH keys (~/.ssh/authorized_keys), planting startup scripts (~/.bashrc, ~/.profile), writing cron jobs (/etc/cron.d/), replacing application binaries or shared libraries, or modifying configuration files and web server templates.
Confidentiality Impact is rated as None (C:N) in the base CVSS vector because the vulnerability occurs during extraction (writing archive contents to disk) rather than archive creation. However, if an application subsequently serves or exposes the extracted directory contents back to the user (for example, a web service that unpacks a ZIP and allows browsing the extracted files), the attacker can leave an outward-pointing symlink on disk (since make_symlink previously did not validate targets at creation) and read arbitrary host files through the application interface.
Development teams using the zip crate should upgrade immediately to version 9.0.0 or later, which incorporates commit 09c030eab56e8db8fb3c1c33f554407b30bad8fa to resolve intermediate symlinks and validate link targets upon creation.
Update Cargo.toml to require version 9.0.0 or higher and regenerate Cargo.lock:
[dependencies]
zip = "^9.0.0"If immediate upgrading to 9.0.0 is blocked by breaking API changes across major versions, apply the following mitigations:
ZipArchive::extract and ZipArchive::extract_unwrapped_root_dir with a custom extraction loop.archive.by_index(i)), validate paths using file.enclosed_name(), and explicitly skip or reject any entry where file.is_symlink() returns true.ZipArchive::extract must be used temporarily on Unix systems, extract into a cryptographically random temporary directory (tempfile::TempDir) so the attacker cannot predict the <dest> prefix required to pass the lexical check, and strip all symlinks from the directory before moving files to their final location.SeCreateSymbolicLinkPrivilege.CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
zip (zip2) zip-rs | >= 7.2.0, < 9.0.0 | 9.0.0 |
| Attribute | Detail |
|---|---|
| Vulnerability ID | CVE-2026-115908 / GHSA-hjqx-j4rw-g7xv |
| Affected Component | zip crate (zip-rs/zip2) >= 7.2.0, < 9.0.0 |
| Weakness Class | CWE-59 (Link Following) / CWE-22 (Path Traversal) / CWE-61 (Symlink Following) |
| CVSS v3.1 Score | 7.4 High (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H) |
| EPSS Score | 0.432% (37.51st Percentile) |
| Impact | Arbitrary file creation and overwrite outside extraction root |
| Exploit Status | PoC Available (Official regression test suite) |
| CISA KEV Status | Not Listed |
The software attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource outside the restricted directory (also mapped to CWE-22 and CWE-61).
CVE-2026-115907 is an uncontrolled resource consumption vulnerability in the Rust zip crate (versions 5.1.0 through 8.6.0, and 9.0.0 pre-releases) when compiled with the optional legacy-zip feature flag. The vulnerability resides in the legacy decompression decoders—Shrink, Reduce, and Implode—which directly pass an archive-declared 64-bit uncompressed size from a ZIP64 extended information extra field to Vec::reserve() before validating the compressed data stream. By supplying a malformed ZIP archive with a declared uncompressed size exceeding isize::MAX (such as u64::MAX), an unauthenticated attacker can trigger an unhandled capacity overflow panic in the Rust standard library allocator, terminating the calling thread or aborting the entire process.
Twisted versions up to and including 26.4.0 are vulnerable to an unauthenticated remote denial-of-service attack via the HTTP/2 Rapid Reset technique (a variant of CVE-2023-44487). The HTTP/2 connection handler in twisted.web processes pipelined HEADERS and RST_STREAM frames sequentially within the same TCP segment. Because the request dispatch and synchronous rendering occur upon receiving the StreamEnded event before the subsequent StreamReset event is evaluated, an attacker can force the server to expend CPU cycles rendering responses while immediately evicting stream state to bypass concurrent stream limits.
An inefficient algorithmic complexity vulnerability exists in the npm package decode-uri-component prior to version 0.5.1. When processing malformed URI strings containing invalid percent-escape sequences, the library falls back to a custom decoder that collects distinct percent-encoded runs into a dictionary and executes a full-string regular expression replacement for each entry. Supplying a payload with thousands of distinct percent-encoded sequences alongside a malformed escape triggers quadratic O(K * L) execution time, starving the single-threaded Node.js event loop and resulting in an unauthenticated Denial of Service.
Open Neural Network Exchange (ONNX) versions 1.4.0 prior to 1.22.0 contain a null pointer dereference (CWE-476) and a signed-to-unsigned integer conversion error (CWE-195) in the shape inference functions for the Scan operator. When processing an untrusted ONNX model, the functions ScanInferenceFunction, ScanInferenceFunction_opset8, and ScanInferenceFunction_opset9 fail to validate the presence and value of the required num_scan_inputs attribute. Omitting the attribute results in a direct null pointer dereference that crashes the host process via SIGSEGV, while supplying a negative value triggers an integer wrap-around during type narrowing that leads to an unhandled allocation failure. Notably, models containing a negative num_scan_inputs value bypass standard validation via onnx.checker.check_model().
CVE-2026-91403 is a critical cryptographic trust validation flaw (CWE-327) in the Java Secure Socket Extension (JSSE) component of Oracle Java SE, Oracle GraalVM, and OpenJDK builds. Triggered by the compromise of private signing keys for eight intermediate Certificate Authorities operated by eMudhra (emSign), unpatched Java runtime environments implicitly trust fraudulent TLS and code-signing certificates issued by these compromised intermediates. Because Java disables online certificate revocation checking (OCSP/CRL) by default, an unauthenticated network attacker in an Adversary-in-the-Middle (AiTM) position can intercept, decrypt, and modify encrypted TLS communications or bypass code signature verification without triggering security exceptions.
Open Neural Network Exchange (ONNX) prior to version 1.22.0 contains a memory exhaustion vulnerability (CWE-789) in its Python tensor unpacking helpers and C++ model checker. Specifically, the _unpack_2bit() and _unpack_4bit() functions in onnx.numpy_helper unconditionally resize unpacked NumPy arrays to match attacker-controlled TensorProto.dims metadata without verifying that the underlying raw_data or int32_data buffers contain a corresponding number of packed bits. Furthermore, onnx.checker.check_tensor() previously bypassed length verification for sub-byte data types. Processing an untrusted ONNX model with packed sub-byte tensors (UINT2, INT2, UINT4, INT4, or FLOAT4E2M1) allows an unauthenticated attacker to trigger disproportionate memory allocations from minimal payloads, resulting in an out-of-memory (OOM) crash and denial of service.