Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

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)
}