guests/static-scan/src/zip.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! A small, strict zip reader for wheels and zip sdists. Heimdall is a
//! defensive sandbox. It reads the central directory, judges every name,
//! refuses to judge an archive whose members overlap (the overlapping-file
//! zip bomb) or whose declared sizes pass the unpack cap, and inflates only
//! the manifests the checks read (stored or deflate). Zip64 and encrypted
//! manifests are not read: the walk is then incomplete, never clean.
use std::io::Read;
use heimdall_scan::Severity;
use crate::archive::{ids, judge_name, link_escapes, shown, wanted, Kept, Walk};
use crate::params::Limits;
const LOCAL: u32 = 0x0403_4b50;
const CENTRAL: u32 = 0x0201_4b50;
const EOCD: u32 = 0x0605_4b50;
/// The largest symlink target read.
const MAX_LINK: u64 = 4096;
pub fn looks_like_zip(b: &[u8]) -> bool {
b.starts_with(b"PK\x03\x04") || b.starts_with(b"PK\x05\x06")
}
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
Some(u16::from_le_bytes(b.get(at..at + 2)?.try_into().ok()?))
}
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
Some(u32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?))
}
struct Member {
raw: Vec<u8>,
flags: u16,
method: u16,
comp: u64,
size: u64,
unix_mode: Option<u32>,
crc: u32,
data: (usize, usize),
}
fn find_eocd(b: &[u8]) -> Option<usize> {
if b.len() < 22 {
return None;
}
let lowest = b.len().saturating_sub(22 + 0xFFFF);
(lowest..=b.len() - 22)
.rev()
.find(|&i| u32_at(b, i) == Some(EOCD))
}
pub fn walk(w: &mut Walk, b: &[u8], limits: &Limits) {
if let Err(why) = walk_inner(w, b, limits) {
w.unreadable = Some(why);
}
}
fn walk_inner(w: &mut Walk, b: &[u8], limits: &Limits) -> Result<(), String> {
let e = find_eocd(b).ok_or("no zip end-of-central-directory record")?;
let total = u16_at(b, e + 10).ok_or("truncated end record")?;
let cd_size = u32_at(b, e + 12).ok_or("truncated end record")?;
let cd_off = u32_at(b, e + 16).ok_or("truncated end record")?;
if total == 0xFFFF || cd_size == 0xFFFF_FFFF || cd_off == 0xFFFF_FFFF {
return Err("zip64 archives are not read".into());
}
if u64::from(total) > limits.max_entries {
w.over(format!(
"{total} archive members (limit {})",
limits.max_entries
));
return Ok(());
}
let cd_off = cd_off as usize;
let cd_end = cd_off
.checked_add(cd_size as usize)
.filter(|&end| end <= e)
.ok_or("the central directory runs past its end record")?;
let mut at = cd_off;
let mut members = Vec::with_capacity(total as usize);
let mut declared: u64 = 0;
for _ in 0..total {
if u32_at(b, at) != Some(CENTRAL) {
return Err("a central directory entry is missing".into());
}
let field = |o: usize| u16_at(b, at + o).ok_or("truncated central entry");
let made_by = field(4)?;
let flags = field(8)?;
let method = field(10)?;
let crc = u32_at(b, at + 16).ok_or("truncated central entry")?;
let comp = u64::from(u32_at(b, at + 20).ok_or("truncated central entry")?);
let size = u64::from(u32_at(b, at + 24).ok_or("truncated central entry")?);
let (n, x, c) = (
field(28)? as usize,
field(30)? as usize,
field(32)? as usize,
);
let ext = u32_at(b, at + 38).ok_or("truncated central entry")?;
let local = u32_at(b, at + 42).ok_or("truncated central entry")? as usize;
let raw = b
.get(at + 46..at + 46 + n)
.ok_or("truncated central entry name")?
.to_vec();
at += 46 + n + x + c;
if at > cd_end {
return Err("a central entry runs past the central directory".into());
}
if comp == 0xFFFF_FFFF || size == 0xFFFF_FFFF {
return Err("zip64 members are not read".into());
}
if u32_at(b, local) != Some(LOCAL) {
return Err(format!("member {} has no local header", shown(&raw)));
}
let ln = u16_at(b, local + 26).ok_or("truncated local header")? as usize;
let lx = u16_at(b, local + 28).ok_or("truncated local header")? as usize;
if b.get(local + 30..local + 30 + ln) != Some(raw.as_slice()) {
w.finding(
ids::ZIP_NAME_MISMATCH,
Severity::Medium,
"a zip member's local name differs from its central-directory name".into(),
&raw,
);
}
let start = local + 30 + ln + lx;
let end = start
.checked_add(comp as usize)
.filter(|&end| end <= cd_off)
.ok_or_else(|| format!("member {} runs past the data area", shown(&raw)))?;
declared = declared.saturating_add(size);
let unix_mode = (made_by >> 8 == 3).then_some(ext >> 16);
members.push(Member {
raw,
flags,
method,
comp,
size,
unix_mode,
crc,
data: (local, end),
});
}
// The central directory holds exactly the declared entries: a lowered
// count must not hide the members after it.
if at != cd_end {
return Err("the central directory holds more than its declared entries".into());
}
let on_disk = u16_at(b, e + 8).ok_or("truncated end record")?;
if on_disk != total {
return Err("the end record's two entry counts differ".into());
}
// Overlapping members (one compressed kernel referenced many times).
let mut spans: Vec<(usize, usize, usize)> = members
.iter()
.enumerate()
.map(|(i, m)| (m.data.0, m.data.1, i))
.collect();
spans.sort();
for pair in spans.windows(2) {
if pair[1].0 < pair[0].1 {
let raw = members[pair[1].2].raw.clone();
w.finding(
ids::ZIP_OVERLAP,
Severity::High,
"zip members overlap (the overlapping-file zip bomb shape)".into(),
&raw,
);
w.over("zip members overlap; the archive is not judged further".into());
return Ok(());
}
}
if declared > limits.max_unpacked_bytes {
w.over(format!(
"the archive declares {declared} unpacked bytes (limit {})",
limits.max_unpacked_bytes
));
}
for m in &members {
let Some(name) = judge_name(w, &m.raw, limits) else {
continue;
};
if name.ends_with('/') {
continue;
}
let ty = m.unix_mode.map(|mode| mode & 0o170000);
if ty == Some(0o120000) {
match read_member(b, m, MAX_LINK) {
Ok(target) => {
if link_escapes(&name, &target) {
w.finding(
ids::LINK_ESCAPE,
Severity::High,
format!(
"an archive link points outside the package root (to {})",
shown(&target)
),
&m.raw,
);
}
}
Err(why) => w.over(format!("link {}: {why}", shown(&m.raw))),
}
}
if matches!(ty, Some(0o020000 | 0o060000 | 0o010000 | 0o140000)) {
w.finding(
ids::SPECIAL_FILE,
Severity::Medium,
"the archive holds a device, FIFO or socket".into(),
&m.raw,
);
}
// Installers write a member's bytes whatever its mode says, so a
// "link" or "device" named like a manifest is still read as one.
if !wanted(&name) {
continue;
}
if m.size > limits.max_manifest_bytes {
w.over(format!(
"manifest {} is {} bytes (limit {})",
shown(&m.raw),
m.size,
limits.max_manifest_bytes
));
continue;
}
match read_member(b, m, limits.max_manifest_bytes) {
Ok(bytes) => w.kept.push(Kept { name, bytes }),
Err(why) => w.over(format!("manifest {}: {why}", shown(&m.raw))),
}
}
Ok(())
}
/// One member's bytes, at most `cap`; exactly its declared size or an error.
fn read_member(b: &[u8], m: &Member, cap: u64) -> Result<Vec<u8>, String> {
if m.flags & 1 != 0 {
return Err("encrypted".into());
}
if m.size > cap {
return Err(format!("{} bytes (limit {cap})", m.size));
}
let local = m.data.0;
let ln = u16_at(b, local + 26).unwrap_or(0) as usize;
let lx = u16_at(b, local + 28).unwrap_or(0) as usize;
let data = &b[local + 30 + ln + lx..m.data.1];
debug_assert_eq!(data.len() as u64, m.comp);
let out = match m.method {
0 => data.to_vec(),
8 => {
let mut out = Vec::with_capacity(m.size as usize);
flate2::read::DeflateDecoder::new(data)
.take(m.size + 1)
.read_to_end(&mut out)
.map_err(|e| format!("bad deflate data: {e}"))?;
out
}
other => return Err(format!("compression method {other} is not read")),
};
if out.len() as u64 != m.size {
return Err(format!(
"unpacks to {} bytes, declares {}",
out.len(),
m.size
));
}
if crc32fast::hash(&out) != m.crc {
return Err("its CRC-32 does not match".into());
}
Ok(out)
}