From 6ae2b74470a2b8fd6bd0cf22ff4e7ba1df8ee64a Mon Sep 17 00:00:00 2001 From: Titouan Christophe Date: Wed, 2 Sep 2026 15:16:32 +0200 Subject: [PATCH] package/clamav: add patch for CVE-2026-20243 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Titouan Christophe Signed-off-by: Raphaël Mélotte --- ...rser-robustness-and-scan-coverage-63.patch | 1503 +++++++++++++++++ package/clamav/clamav.mk | 3 + 2 files changed, 1506 insertions(+) create mode 100644 package/clamav/0006-fix-alz-parser-robustness-and-scan-coverage-63.patch diff --git a/package/clamav/0006-fix-alz-parser-robustness-and-scan-coverage-63.patch b/package/clamav/0006-fix-alz-parser-robustness-and-scan-coverage-63.patch new file mode 100644 index 0000000000..3b02ceb880 --- /dev/null +++ b/package/clamav/0006-fix-alz-parser-robustness-and-scan-coverage-63.patch @@ -0,0 +1,1503 @@ +From: "Val S." +Date: Wed, 17 Jun 2026 10:07:43 -0400 +Subject: Fix ALZ parser robustness and scan coverage (#63) + +Malformed ALZ entries could panic or abort archive processing before +payloads and metadata were scanned. Header size claims and decompression +behavior could bypass scan limits or skip valid members, and the deflate +path could inflate oversized output before applying limits. + +Harden ALZ size arithmetic, extract stored, bzip2, and deflate members +under scan budgets, and keep scanning after metadata, extraction, and +later parse errors. Report accurate metadata including file positions +and encryption state. Use bounded flate2 reads for deflate output, drop +the old inflate dependency, and add Rust and clamscan regressions. + +Keep ALZ per-member and archive-total limit accounting separate so +archive totals do not get reported as MaxFileSize for archives with +multiple smaller members. Preserve MaxScanSize exceeds-max reporting for +truncated archive totals and cover the behavior with Rust and clamscan +tests. + +Credit: Yazdan Soltani + +CLAM-2988 + +--- +Upstream: https://github.com/Cisco-Talos/clamav/commit/610c1d1eeb67ced3645c205d62bcd9eb7e2000ed +CVE: CVE-2026-20243 +[Titouan: fix merge conflict with clamav-1.4.3] +Signed-off-by: Titouan Christophe +--- + Cargo.lock | 16 - + libclamav/libclamav.map | 1 + + libclamav_rust/Cargo.toml | 1 - + libclamav_rust/build.rs | 1 + + libclamav_rust/src/alz.rs | 622 +++++++++++++++++++++++++++----- + libclamav_rust/src/scanners.rs | 123 ++++++- + libclamav_rust/src/sys.rs | 6 + + libclamav_rust/src/util.rs | 31 +- + unit_tests/clamscan/alz_test.py | 321 ++++++++++++++++ + 9 files changed, 1006 insertions(+), 116 deletions(-) + +diff --git a/Cargo.lock b/Cargo.lock +index e96f29ffa..8ec8d3400 100644 +--- a/Cargo.lock ++++ b/Cargo.lock +@@ -8,12 +8,6 @@ version = "1.0.2" + source = "registry+https://github.com/rust-lang/crates.io-index" + checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +-[[package]] +-name = "adler32" +-version = "1.2.0" +-source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" +- + [[package]] + name = "aho-corasick" + version = "1.1.3" +@@ -199,7 +193,6 @@ dependencies = [ + "hex", + "hex-literal", + "image", +- "inflate", + "libc", + "log", + "num-traits", +@@ -523,12 +516,14 @@ dependencies = [ + ] + + [[package]] +-name = "inflate" +-version = "0.4.5" ++name = "interpolate_name" ++version = "0.2.4" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "1cdb29978cc5797bd8dcc8e5bf7de604891df2a8dc576973d71a281e916db2ff" ++checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" + dependencies = [ +- "adler32", ++ "proc-macro2", ++ "quote", ++ "syn 2.0.113", + ] + + [[package]] +diff --git a/libclamav/libclamav.map b/libclamav/libclamav.map +index a34a05dbb..f8cf8a299 100644 +--- a/libclamav/libclamav.map ++++ b/libclamav/libclamav.map +@@ -292,6 +292,7 @@ CLAMAV_PRIVATE { + cli_getdsig; + cli_infomsg_simple; + cli_set_debug_flag; ++ cli_append_potentially_unwanted_if_heur_exceedsmax; + fmap_dump_to_file; + init_allow_list; + init_domain_list; +diff --git a/libclamav_rust/Cargo.toml b/libclamav_rust/Cargo.toml +index efab908656..b75185b6c7 100644 +--- a/libclamav_rust/Cargo.toml ++++ b/libclamav_rust/Cargo.toml +@@ -22,7 +22,6 @@ unicode-segmentation = "1.10" + bindgen = "0.65" + onenote_parser = { git = "https://github.com/Cisco-Talos/onenote.rs.git", branch = "CLAM-2329-new-from-slice" } + hex-literal = "0.4" +-inflate = "0.4" + bzip2-rs = "0.1" + byteorder = "1.5" + delharc = "0.6" +diff --git a/libclamav_rust/build.rs b/libclamav_rust/build.rs +index ede1743f41..a35bc6211a 100644 +--- a/libclamav_rust/build.rs ++++ b/libclamav_rust/build.rs +@@ -48,6 +48,7 @@ const BINDGEN_FUNCTIONS: &[&str] = &[ + "cli_infomsg_simple", + "cli_errmsg", + "cli_append_virus", ++ "cli_append_potentially_unwanted_if_heur_exceedsmax", + "lsig_increment_subsig_match", + "cli_versig2", + "cli_getdsig", +diff --git a/libclamav_rust/src/alz.rs b/libclamav_rust/src/alz.rs +index fef676a0db..0ee30a0a04 100644 +--- a/libclamav_rust/src/alz.rs ++++ b/libclamav_rust/src/alz.rs +@@ -30,11 +30,12 @@ + )] + */ + ++use std::convert::TryFrom; + use std::io::{Cursor, Read}; + + use byteorder::{LittleEndian, ReadBytesExt}; + use bzip2_rs::DecoderReader; +-use inflate::InflateStream; ++use flate2::read::DeflateDecoder; + use log::debug; + + /// File header +@@ -61,6 +62,12 @@ pub enum Error { + #[error("Failed to extract file")] + Extract, + ++ #[error("Failed to allocate extracted file")] ++ Alloc, ++ ++ #[error("Extracted file exceeds scan limits")] ++ ScanLimitExceeded(u64), ++ + #[error("Failed to read field: {0}")] + Read(&'static str), + } +@@ -273,16 +280,20 @@ impl AlzLocalFileHeader { + } + } + +- #[allow(clippy::cast_possible_truncation)] +- let idx0: usize = cursor.position() as usize; +- let idx1: usize = idx0 + self.head.file_name_length as usize; ++ let idx0: usize = usize::try_from(cursor.position()) ++ .map_err(|_| Error::Parse("Invalid file name offset"))?; ++ let idx1: usize = idx0 ++ .checked_add(usize::from(self.head.file_name_length)) ++ .ok_or(Error::Parse("Invalid file name length"))?; + + if idx1 > cursor.get_ref().len() { + return Err(Error::Parse("Invalid file name length")); + } + + let filename = &cursor.get_ref().as_slice()[idx0..idx1]; +- cursor.set_position(idx1 as u64); ++ cursor.set_position( ++ u64::try_from(idx1).map_err(|_| Error::Parse("Invalid file name length"))?, ++ ); + + self.file_name = String::from_utf8_lossy(filename).into_owned(); + +@@ -293,12 +304,20 @@ impl AlzLocalFileHeader { + } + + self.start_of_compressed_data = cursor.position(); +- cursor.set_position(self.start_of_compressed_data + self.compressed_size); +- +- if self.start_of_compressed_data + self.compressed_size > cursor.get_ref().len() as u64 { ++ let end_of_compressed_data = self ++ .start_of_compressed_data ++ .checked_add(self.compressed_size) ++ .ok_or(Error::Parse("Invalid compressed data length"))?; ++ ++ if end_of_compressed_data ++ > u64::try_from(cursor.get_ref().len()) ++ .map_err(|_| Error::Parse("Invalid compressed data length"))? ++ { + return Err(Error::Parse("Invalid compressed data length")); + } + ++ cursor.set_position(end_of_compressed_data); ++ + Ok(()) + } + +@@ -323,120 +342,196 @@ impl AlzLocalFileHeader { + &mut self, + cursor: &std::io::Cursor<&Vec>, + files: &mut Vec, ++ max_extracted_size: u64, + ) -> Result<(), Error> { +- #[allow(clippy::cast_possible_truncation)] +- let start: usize = self.start_of_compressed_data as usize; +- #[allow(clippy::cast_possible_truncation)] +- let end: usize = start + self.compressed_size as usize; +- if end >= cursor.get_ref().len() { +- return Err(Error::Extract); +- } +- let data: &[u8] = &cursor.get_ref().as_slice()[start..end]; ++ let start: usize = ++ usize::try_from(self.start_of_compressed_data).map_err(|_| Error::Extract)?; ++ let len: usize = usize::try_from(self.compressed_size).map_err(|_| Error::Extract)?; ++ let end: usize = start.checked_add(len).ok_or(Error::Extract)?; ++ let data: &[u8] = cursor ++ .get_ref() ++ .as_slice() ++ .get(start..end) ++ .ok_or(Error::Extract)?; + +- let mut inflater = InflateStream::new(); + let mut out: Vec = Vec::::new(); +- let mut n: usize = 0; +- +- while n < data.len() { +- let res = inflater.update(&data[n..]); +- if let Ok((num_bytes_read, result)) = res { +- n += num_bytes_read; +- out.extend(result.iter().copied()); +- } else { +- return Err(Error::Extract); ++ let mut decompressor = DeflateDecoder::new(data); ++ let mut buffer = [0u8; 8192]; ++ ++ loop { ++ let len = decompressor.read(&mut buffer).map_err(|_| { ++ debug!("Unable to decompress deflate data"); ++ Error::Extract ++ })?; ++ if len == 0 { ++ break; ++ } ++ ++ if let Some(needed) = ++ self.append_output(&mut out, &buffer[..len], max_extracted_size)? ++ { ++ self.push_file(out, files)?; ++ return Err(Error::ScanLimitExceeded(needed)); + } + } + +- self.write_file(&out, files); ++ self.push_file(out, files) ++ } + +- Ok(()) ++ fn append_output( ++ &self, ++ out: &mut Vec, ++ buffer: &[u8], ++ max_extracted_size: u64, ++ ) -> Result, Error> { ++ let needed = out.len().checked_add(buffer.len()).ok_or(Error::Extract)?; ++ let needed_u64 = u64::try_from(needed).map_err(|_| Error::Extract)?; ++ ++ if needed_u64 > max_extracted_size { ++ let current = u64::try_from(out.len()).map_err(|_| Error::Extract)?; ++ let remaining = max_extracted_size.saturating_sub(current); ++ let copy_len = usize::try_from(remaining) ++ .unwrap_or(usize::MAX) ++ .min(buffer.len()); ++ ++ out.try_reserve(copy_len).map_err(|_| Error::Alloc)?; ++ out.extend_from_slice(&buffer[..copy_len]); ++ ++ return Ok(Some(needed_u64)); ++ } ++ ++ out.try_reserve(buffer.len()).map_err(|_| Error::Alloc)?; ++ out.extend_from_slice(buffer); ++ ++ Ok(None) + } + +- fn write_file(&mut self, buffer: &[u8], files: &mut Vec) { ++ fn push_file(&mut self, data: Vec, files: &mut Vec) -> Result<(), Error> { ++ if data.is_empty() { ++ return Ok(()); ++ } ++ ++ let mut name = String::new(); ++ name.try_reserve(self.file_name.len()) ++ .map_err(|_| Error::Alloc)?; ++ name.push_str(&self.file_name); ++ + let extracted_file: ExtractedFile = ExtractedFile { +- name: Some(self.file_name.to_string()), +- data: buffer.to_vec(), ++ name: Some(name), ++ data, + }; + +- if 0 != extracted_file.data.len() { +- files.push(extracted_file); +- } ++ files.try_reserve(1).map_err(|_| Error::Alloc)?; ++ files.push(extracted_file); ++ ++ Ok(()) ++ } ++ ++ fn write_file(&mut self, buffer: &[u8], files: &mut Vec) -> Result<(), Error> { ++ let mut data: Vec = Vec::new(); ++ data.try_reserve_exact(buffer.len()) ++ .map_err(|_| Error::Alloc)?; ++ data.extend_from_slice(buffer); ++ ++ self.push_file(data, files) + } + + fn extract_file_nocomp( + &mut self, +- cursor: &mut std::io::Cursor<&Vec>, ++ cursor: &std::io::Cursor<&Vec>, + files: &mut Vec, ++ max_extracted_size: u64, + ) -> Result<(), Error> { +- #[allow(clippy::cast_possible_truncation)] +- let idx0: usize = self.start_of_compressed_data as usize; ++ let idx0: usize = ++ usize::try_from(self.start_of_compressed_data).map_err(|_| Error::Extract)?; + +- let mut len = self.compressed_size; + if self.compressed_size != self.uncompressed_size { +- debug!("Uncompressed file has different lengths for compressed vs uncompressed, using the shorter"); +- if self.compressed_size > self.uncompressed_size { +- len = self.uncompressed_size; +- } ++ debug!("Uncompressed file has different lengths for compressed vs uncompressed, using the stored size"); + } + +- #[allow(clippy::cast_possible_truncation)] +- let idx1: usize = idx0 + len as usize; +- if idx1 > cursor.get_ref().len() { +- debug!("Invalid data length"); +- return Err(Error::Extract); ++ let len: usize = usize::try_from(self.compressed_size).map_err(|_| Error::Extract)?; ++ let idx1: usize = idx0.checked_add(len).ok_or(Error::Extract)?; ++ ++ let contents = cursor ++ .get_ref() ++ .as_slice() ++ .get(idx0..idx1) ++ .ok_or(Error::Extract)?; ++ ++ let contents_len = u64::try_from(contents.len()).map_err(|_| Error::Extract)?; ++ if contents_len > max_extracted_size { ++ let copy_len = usize::try_from(max_extracted_size) ++ .unwrap_or(usize::MAX) ++ .min(contents.len()); ++ self.write_file(&contents[..copy_len], files)?; ++ return Err(Error::ScanLimitExceeded(contents_len)); + } + +- let contents = &cursor.get_ref().as_slice()[idx0..idx1]; +- cursor.set_position(idx1 as u64); +- +- self.write_file(contents, files); +- Ok(()) ++ self.write_file(contents, files) + } + + fn extract_file_bzip2( + &mut self, + cursor: &std::io::Cursor<&Vec>, + files: &mut Vec, ++ max_extracted_size: u64, + ) -> Result<(), Error> { +- #[allow(clippy::cast_possible_truncation)] +- let idx0: usize = self.start_of_compressed_data as usize; +- #[allow(clippy::cast_possible_truncation)] +- let idx1: usize = idx0 + self.compressed_size as usize; ++ let idx0: usize = ++ usize::try_from(self.start_of_compressed_data).map_err(|_| Error::Extract)?; ++ let len: usize = usize::try_from(self.compressed_size).map_err(|_| Error::Extract)?; ++ let idx1: usize = idx0.checked_add(len).ok_or(Error::Extract)?; + +- let contents = &cursor.get_ref().as_slice()[idx0..idx1]; ++ let contents = cursor ++ .get_ref() ++ .as_slice() ++ .get(idx0..idx1) ++ .ok_or(Error::Extract)?; + +- /* +- * Create vector of the needed capacity. +- */ + let mut out: Vec = Vec::new(); +- for _i in 0..self.uncompressed_size { +- out.push(0); ++ let mut decompressor = DecoderReader::new(contents); ++ let mut buffer = [0u8; 8192]; ++ loop { ++ let len = decompressor.read(&mut buffer).map_err(|_| { ++ debug!("Unable to decompress bz2 data"); ++ Error::Extract ++ })?; ++ if len == 0 { ++ break; ++ } ++ ++ if let Some(needed) = ++ self.append_output(&mut out, &buffer[..len], max_extracted_size)? ++ { ++ self.push_file(out, files)?; ++ return Err(Error::ScanLimitExceeded(needed)); ++ } + } + +- let mut decompressor = DecoderReader::new(contents); +- let ret = decompressor.read_exact(&mut out); +- if ret.is_err() { +- debug!("Unable to decompress bz2 data"); +- return Err(Error::Extract); ++ if let Ok(uncompressed_size) = usize::try_from(self.uncompressed_size) { ++ if out.len() != uncompressed_size { ++ debug!( ++ "Bzip2 file has different lengths for declared vs decompressed data, using the decompressed size" ++ ); ++ } + } + +- self.write_file(&out, files); +- Ok(()) ++ self.push_file(out, files) + } + + fn extract_file( + &mut self, + cursor: &mut std::io::Cursor<&Vec>, + files: &mut Vec, ++ max_extracted_size: u64, + ) -> Result<(), Error> { + const ALZ_COMP_NOCOMP: u8 = 0; + const ALZ_COMP_BZIP2: u8 = 1; + const ALZ_COMP_DEFLATE: u8 = 2; + + match self.compression_method { +- ALZ_COMP_NOCOMP => self.extract_file_nocomp(cursor, files), +- ALZ_COMP_BZIP2 => self.extract_file_bzip2(cursor, files), +- ALZ_COMP_DEFLATE => self.extract_file_deflate(cursor, files), ++ ALZ_COMP_NOCOMP => self.extract_file_nocomp(cursor, files, max_extracted_size), ++ ALZ_COMP_BZIP2 => self.extract_file_bzip2(cursor, files, max_extracted_size), ++ ALZ_COMP_DEFLATE => self.extract_file_deflate(cursor, files, max_extracted_size), + _ => Err(Error::Extract), + } + } +@@ -448,9 +543,27 @@ pub struct ExtractedFile { + pub data: Vec, + } + ++pub struct AlzFileMetadata<'a> { ++ pub file_name: &'a str, ++ pub compressed_size: u64, ++ pub uncompressed_size: u64, ++ pub is_encrypted: bool, ++ pub file_crc: u32, ++ pub filepos: usize, ++} ++ ++pub struct AlzExtractionLimits { ++ pub max_file_size: u64, ++ pub max_total_size: u64, ++} ++ + #[derive(Default)] + pub struct Alz { + pub embedded_files: Vec, ++ pub file_limit_exceeded_size: Option, ++ pub total_limit_exceeded_size: Option, ++ extracted_size: u64, ++ parse_error: bool, + } + + impl<'aa> Alz { +@@ -462,29 +575,97 @@ impl<'aa> Alz { + .map_or(false, |n| ALZ_FILE_HEADER == n) + } + +- fn parse_local_fileheader(&mut self, cursor: &mut std::io::Cursor<&Vec>) -> bool { ++ fn parse_local_fileheader( ++ &mut self, ++ cursor: &mut std::io::Cursor<&Vec>, ++ filepos: &mut usize, ++ should_extract: &mut F, ++ ) -> Result<(), Error> ++ where ++ F: FnMut(&AlzFileMetadata<'_>) -> Option, ++ { + let mut local_fileheader = AlzLocalFileHeader::new(); + +- if let Err(err) = local_fileheader.parse(cursor) { +- debug!("{err}"); +- return false; +- } ++ local_fileheader.parse(cursor)?; + +- if let Err(err) = local_fileheader.is_supported() { +- debug!("{err}"); +- return false; +- } ++ let metadata_filepos = *filepos; ++ *filepos = metadata_filepos.saturating_add(1); + + if !local_fileheader.is_directory() { + /* The is_file flag doesn't appear to always be set, so we'll just assume it's a file if + * it's not marked as a directory.*/ +- let res2 = local_fileheader.extract_file(cursor, &mut self.embedded_files); +- if res2.is_err() { +- return false; ++ let metadata = AlzFileMetadata { ++ file_name: &local_fileheader.file_name, ++ compressed_size: local_fileheader.compressed_size, ++ uncompressed_size: local_fileheader.uncompressed_size, ++ is_encrypted: local_fileheader.is_encrypted(), ++ file_crc: local_fileheader.file_crc, ++ filepos: metadata_filepos, ++ }; ++ ++ let limits = should_extract(&metadata); ++ ++ if let Err(err) = local_fileheader.is_supported() { ++ debug!("{err}"); ++ return Ok(()); ++ } ++ ++ let Some(limits) = limits else { ++ return Ok(()); ++ }; ++ ++ let base_extracted_size = self.extracted_size; ++ let max_total_remaining = limits.max_total_size.saturating_sub(base_extracted_size); ++ let max_extracted_size = limits.max_file_size.min(max_total_remaining); ++ let files_start = self.embedded_files.len(); ++ ++ match local_fileheader.extract_file( ++ cursor, ++ &mut self.embedded_files, ++ max_extracted_size, ++ ) { ++ Ok(()) => {} ++ Err(Error::ScanLimitExceeded(needed)) => { ++ debug!( ++ "ALZ file {:?} exceeded extraction size limits. Scanning truncated content.", ++ local_fileheader.file_name ++ ); ++ if needed > limits.max_file_size ++ && self ++ .file_limit_exceeded_size ++ .map_or(true, |current| current < needed) ++ { ++ self.file_limit_exceeded_size = Some(needed); ++ } ++ ++ let total_needed = base_extracted_size.saturating_add(needed); ++ if total_needed > limits.max_total_size ++ && self ++ .total_limit_exceeded_size ++ .map_or(true, |current| current < total_needed) ++ { ++ self.total_limit_exceeded_size = Some(total_needed); ++ } ++ } ++ Err(Error::Extract) => { ++ debug!( ++ "Failed to extract ALZ file {:?}. Continuing with next entry.", ++ local_fileheader.file_name ++ ); ++ } ++ Err(err) => return Err(err), ++ } ++ ++ for file in &self.embedded_files[files_start..] { ++ let Ok(file_size) = u64::try_from(file.data.len()) else { ++ self.extracted_size = u64::MAX; ++ break; ++ }; ++ self.extracted_size = self.extracted_size.saturating_add(file_size); + } + } + +- true ++ Ok(()) + } + + #[allow(clippy::unused_self)] +@@ -503,16 +684,39 @@ impl<'aa> Alz { + pub const fn new() -> Self { + Self { + embedded_files: Vec::new(), ++ file_limit_exceeded_size: None, ++ total_limit_exceeded_size: None, ++ extracted_size: 0, ++ parse_error: false, + } + } + ++ pub const fn has_parse_error(&self) -> bool { ++ self.parse_error ++ } ++ + /// # Errors + /// Will return `Error::Parse` if file headers are not correct or are inconsistent. + pub fn from_bytes(bytes: &'aa [u8]) -> Result { ++ Self::from_bytes_with_filter(bytes, |_| { ++ Some(AlzExtractionLimits { ++ max_file_size: u64::MAX, ++ max_total_size: u64::MAX, ++ }) ++ }) ++ } ++ ++ /// # Errors ++ /// Will return `Error::Parse` if file headers are not correct or are inconsistent. ++ pub fn from_bytes_with_filter(bytes: &'aa [u8], mut should_extract: F) -> Result ++ where ++ F: FnMut(&AlzFileMetadata<'_>) -> Option, ++ { + let binding = bytes.to_vec(); + let mut cursor = Cursor::new(&binding); + + let mut alz: Self = Self::new(); ++ let mut filepos: usize = 1; + + if !alz.is_alz(&mut cursor) { + return Err(Error::Parse("No ALZ file header")); +@@ -531,9 +735,21 @@ impl<'aa> Alz { + + match sig { + ALZ_LOCAL_FILE_HEADER => { +- if alz.parse_local_fileheader(&mut cursor) { +- continue; ++ match alz.parse_local_fileheader(&mut cursor, &mut filepos, &mut should_extract) ++ { ++ Ok(()) => {} ++ Err(Error::Alloc) => return Err(Error::Alloc), ++ Err(err) => { ++ if filepos == 1 { ++ return Err(err); ++ } ++ ++ debug!("Failed to parse ALZ local file header: {err}"); ++ alz.parse_error = true; ++ break; ++ } + } ++ continue; + } + ALZ_CENTRAL_DIRECTORY_HEADER => { + if alz.parse_central_directoryheader(&mut cursor) { +@@ -546,7 +762,14 @@ impl<'aa> Alz { + } + _ => { + #[allow(clippy::uninlined_format_args)] +- return Err(Error::UnrecognizedSig(format!("{:x}", sig))); ++ let err = Error::UnrecognizedSig(format!("{:x}", sig)); ++ if filepos == 1 { ++ return Err(err); ++ } ++ ++ debug!("Failed to parse ALZ archive: {err}"); ++ alz.parse_error = true; ++ break; + } + } + } +@@ -554,3 +777,226 @@ impl<'aa> Alz { + Ok(alz) + } + } ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use std::io::Write; ++ ++ fn append_local_file( ++ alz: &mut Vec, ++ name: &str, ++ compression_method: u8, ++ uncompressed_size: u8, ++ data: &[u8], ++ ) { ++ append_local_entry( ++ alz, ++ name, ++ AlzFileAttribute::File as u8, ++ 0x10, ++ compression_method, ++ uncompressed_size, ++ data, ++ ); ++ } ++ ++ fn append_local_entry( ++ alz: &mut Vec, ++ name: &str, ++ file_attribute: u8, ++ file_descriptor: u8, ++ compression_method: u8, ++ uncompressed_size: u8, ++ data: &[u8], ++ ) { ++ let name = name.as_bytes(); ++ let name_len = u16::try_from(name.len()).unwrap(); ++ let compressed_size = u8::try_from(data.len()).unwrap(); ++ ++ alz.extend_from_slice(&ALZ_LOCAL_FILE_HEADER.to_le_bytes()); ++ alz.extend_from_slice(&name_len.to_le_bytes()); ++ alz.push(file_attribute); ++ alz.extend_from_slice(&0u32.to_le_bytes()); ++ alz.push(file_descriptor); ++ alz.push(0); ++ alz.push(compression_method); ++ alz.push(0); ++ alz.extend_from_slice(&0u32.to_le_bytes()); ++ alz.push(compressed_size); ++ alz.push(uncompressed_size); ++ alz.extend_from_slice(name); ++ if file_descriptor & 0x01 != 0 { ++ alz.extend_from_slice(&[0; ALZ_ENCR_HEADER_LEN as usize]); ++ } ++ alz.extend_from_slice(data); ++ } ++ ++ fn extraction_limits() -> AlzExtractionLimits { ++ AlzExtractionLimits { ++ max_file_size: u64::MAX, ++ max_total_size: u64::MAX, ++ } ++ } ++ ++ fn raw_deflate(data: &[u8]) -> Vec { ++ let mut encoder = ++ flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); ++ encoder.write_all(data).unwrap(); ++ encoder.finish().unwrap() ++ } ++ ++ #[test] ++ fn extraction_error_does_not_stop_later_entries() { ++ const ALZ_COMP_NOCOMP: u8 = 0; ++ const ALZ_COMP_BZIP2: u8 = 1; ++ ++ let mut bytes = Vec::new(); ++ bytes.extend_from_slice(&ALZ_FILE_HEADER.to_le_bytes()); ++ bytes.extend_from_slice(&0u32.to_le_bytes()); ++ append_local_file(&mut bytes, "bad.bz2", ALZ_COMP_BZIP2, 1, &[0]); ++ append_local_file(&mut bytes, "good.txt", ALZ_COMP_NOCOMP, 4, b"good"); ++ bytes.extend_from_slice(&ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_le_bytes()); ++ ++ let alz = Alz::from_bytes_with_filter(&bytes, |_| Some(extraction_limits())).unwrap(); ++ ++ assert_eq!(alz.embedded_files.len(), 1); ++ assert_eq!(alz.embedded_files[0].name.as_deref(), Some("good.txt")); ++ assert_eq!(alz.embedded_files[0].data, b"good"); ++ } ++ ++ #[test] ++ fn later_parse_error_preserves_earlier_extracted_entries() { ++ const ALZ_COMP_NOCOMP: u8 = 0; ++ ++ let mut bytes = Vec::new(); ++ bytes.extend_from_slice(&ALZ_FILE_HEADER.to_le_bytes()); ++ bytes.extend_from_slice(&0u32.to_le_bytes()); ++ append_local_file(&mut bytes, "good.txt", ALZ_COMP_NOCOMP, 4, b"good"); ++ bytes.extend_from_slice(&ALZ_LOCAL_FILE_HEADER.to_le_bytes()); ++ ++ let alz = Alz::from_bytes_with_filter(&bytes, |_| Some(extraction_limits())).unwrap(); ++ ++ assert!(alz.has_parse_error()); ++ assert_eq!(alz.embedded_files.len(), 1); ++ assert_eq!(alz.embedded_files[0].name.as_deref(), Some("good.txt")); ++ assert_eq!(alz.embedded_files[0].data, b"good"); ++ } ++ ++ #[test] ++ fn deflate_limit_uses_decompressed_size_not_header() { ++ const ALZ_COMP_DEFLATE: u8 = 2; ++ let payload = vec![b'A'; 1024]; ++ let compressed = raw_deflate(&payload); ++ assert!(compressed.len() <= u8::MAX.into()); ++ ++ let mut bytes = Vec::new(); ++ bytes.extend_from_slice(&ALZ_FILE_HEADER.to_le_bytes()); ++ bytes.extend_from_slice(&0u32.to_le_bytes()); ++ append_local_file(&mut bytes, "big.txt", ALZ_COMP_DEFLATE, 1, &compressed); ++ bytes.extend_from_slice(&ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_le_bytes()); ++ ++ let alz = Alz::from_bytes_with_filter(&bytes, |_| { ++ Some(AlzExtractionLimits { ++ max_file_size: 64, ++ max_total_size: u64::MAX, ++ }) ++ }) ++ .unwrap(); ++ ++ assert_eq!(alz.file_limit_exceeded_size, Some(payload.len() as u64)); ++ assert_eq!(alz.embedded_files.len(), 1); ++ assert_eq!(alz.embedded_files[0].name.as_deref(), Some("big.txt")); ++ assert_eq!(alz.embedded_files[0].data.as_slice(), &payload[..64]); ++ } ++ ++ #[test] ++ fn total_limit_does_not_set_per_file_limit() { ++ const ALZ_COMP_NOCOMP: u8 = 0; ++ let payload = vec![b'A'; 60]; ++ ++ let mut bytes = Vec::new(); ++ bytes.extend_from_slice(&ALZ_FILE_HEADER.to_le_bytes()); ++ bytes.extend_from_slice(&0u32.to_le_bytes()); ++ append_local_file( ++ &mut bytes, ++ "a", ++ ALZ_COMP_NOCOMP, ++ payload.len() as u8, ++ &payload, ++ ); ++ append_local_file( ++ &mut bytes, ++ "b", ++ ALZ_COMP_NOCOMP, ++ payload.len() as u8, ++ &payload, ++ ); ++ bytes.extend_from_slice(&ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_le_bytes()); ++ ++ let alz = Alz::from_bytes_with_filter(&bytes, |_| { ++ Some(AlzExtractionLimits { ++ max_file_size: 64, ++ max_total_size: 100, ++ }) ++ }) ++ .unwrap(); ++ ++ assert_eq!(alz.file_limit_exceeded_size, None); ++ assert_eq!(alz.total_limit_exceeded_size, Some(120)); ++ assert_eq!(alz.embedded_files.len(), 2); ++ assert_eq!(alz.embedded_files[0].data.len(), 60); ++ assert_eq!(alz.embedded_files[1].data.len(), 40); ++ } ++ ++ #[test] ++ fn metadata_file_positions_are_one_based_and_include_skipped_entries() { ++ const ALZ_COMP_NOCOMP: u8 = 0; ++ ++ let mut bytes = Vec::new(); ++ bytes.extend_from_slice(&ALZ_FILE_HEADER.to_le_bytes()); ++ bytes.extend_from_slice(&0u32.to_le_bytes()); ++ append_local_entry( ++ &mut bytes, ++ "dir/", ++ AlzFileAttribute::Directory as u8, ++ 0x10, ++ ALZ_COMP_NOCOMP, ++ 0, ++ b"", ++ ); ++ append_local_entry( ++ &mut bytes, ++ "encrypted.bin", ++ AlzFileAttribute::File as u8, ++ 0x11, ++ ALZ_COMP_NOCOMP, ++ 0, ++ b"", ++ ); ++ append_local_file(&mut bytes, "first.txt", ALZ_COMP_NOCOMP, 5, b"first"); ++ append_local_file(&mut bytes, "second.txt", ALZ_COMP_NOCOMP, 6, b"second"); ++ bytes.extend_from_slice(&ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_le_bytes()); ++ ++ let mut metadata_positions = Vec::new(); ++ let alz = Alz::from_bytes_with_filter(&bytes, |metadata| { ++ metadata_positions.push(( ++ metadata.file_name.to_owned(), ++ metadata.filepos, ++ metadata.is_encrypted, ++ )); ++ None ++ }) ++ .unwrap(); ++ ++ assert!(alz.embedded_files.is_empty()); ++ assert_eq!( ++ metadata_positions, ++ vec![ ++ ("encrypted.bin".to_owned(), 2, true), ++ ("first.txt".to_owned(), 3, false), ++ ("second.txt".to_owned(), 4, false), ++ ] ++ ); ++ } ++} +diff --git a/libclamav_rust/src/scanners.rs b/libclamav_rust/src/scanners.rs +index be8c52acd1..862b0f46e1 100644 +--- a/libclamav_rust/src/scanners.rs ++++ b/libclamav_rust/src/scanners.rs +@@ -21,6 +21,7 @@ + */ + + use std::{ ++ convert::TryFrom, + ffi::{c_char, CString}, + io::Read, + panic, +@@ -33,14 +34,17 @@ use libc::c_void; + use log::{debug, error, warn}; + + use crate::{ +- alz::Alz, ++ alz::{Alz, AlzExtractionLimits, Error as AlzError}, + ctx, + onenote::OneNote, + sys::{ +- cl_error_t, cl_error_t_CL_EFORMAT, cl_error_t_CL_ERROR, cl_error_t_CL_SUCCESS, cli_ctx, ++ cl_error_t, cl_error_t_CL_EFORMAT, cl_error_t_CL_EMAXSIZE, cl_error_t_CL_EMEM, ++ cl_error_t_CL_ERROR, cl_error_t_CL_SUCCESS, cl_error_t_CL_VIRUS, cli_ctx, + cli_magic_scan_buff, + }, +- util::{check_scan_limits, scan_archive_metadata}, ++ util::{ ++ append_potentially_unwanted_if_heur_exceedsmax, check_scan_limits, scan_archive_metadata, ++ }, + }; + + /// Rust wrapper of libclamav's cli_magic_scan_buff() function. +@@ -307,6 +311,30 @@ pub unsafe extern "C" fn scan_lha_lzh(ctx: *mut cli_ctx) -> cl_error_t { + cl_error_t_CL_SUCCESS + } + ++unsafe fn alz_extraction_limits(ctx: *mut cli_ctx) -> AlzExtractionLimits { ++ if ctx.is_null() || (*ctx).engine.is_null() { ++ return AlzExtractionLimits { ++ max_file_size: u64::MAX, ++ max_total_size: u64::MAX, ++ }; ++ } ++ ++ let engine = &*(*ctx).engine; ++ ++ AlzExtractionLimits { ++ max_file_size: if engine.maxfilesize == 0 { ++ u64::MAX ++ } else { ++ engine.maxfilesize ++ }, ++ max_total_size: if engine.maxscansize == 0 { ++ u64::MAX ++ } else { ++ engine.maxscansize.saturating_sub((*ctx).scansize) ++ }, ++ } ++} ++ + /// Scan an Alz file for attachments + /// + /// # Safety +@@ -333,14 +361,89 @@ pub unsafe extern "C" fn cli_scanalz(ctx: *mut cli_ctx) -> cl_error_t { + } + }; + +- let alz = match Alz::from_bytes(file_bytes) { +- Ok(x) => x, +- Err(err) => { +- error!("Failed to parse Alz file: {}", err.to_string()); +- return cl_error_t_CL_ERROR; ++ let mut alz_metadata_ret = cl_error_t_CL_SUCCESS; ++ let alz_result = panic::catch_unwind(panic::AssertUnwindSafe(|| { ++ Alz::from_bytes_with_filter(file_bytes, |metadata| { ++ if check_scan_limits("ALZ", ctx, 0, 0, 0) != cl_error_t_CL_SUCCESS { ++ debug!("Exceeded scan limits. Bailing out."); ++ return None; ++ } ++ ++ match ( ++ usize::try_from(metadata.compressed_size), ++ usize::try_from(metadata.uncompressed_size), ++ ) { ++ (Ok(compressed_size), Ok(uncompressed_size)) => { ++ let metadata_ret = scan_archive_metadata( ++ ctx, ++ metadata.file_name, ++ compressed_size, ++ uncompressed_size, ++ metadata.is_encrypted, ++ metadata.filepos, ++ metadata.file_crc as i32, ++ ); ++ if metadata_ret == cl_error_t_CL_VIRUS { ++ alz_metadata_ret = metadata_ret; ++ debug!( ++ "ALZ file {:?} metadata did not pass scan checks. Skipping extraction.", ++ metadata.file_name ++ ); ++ return None; ++ } else if metadata_ret != cl_error_t_CL_SUCCESS { ++ debug!( ++ "ALZ file {:?} metadata scan failed with {}. Continuing extraction.", ++ metadata.file_name, metadata_ret ++ ); ++ } ++ } ++ _ => debug!( ++ "ALZ file {:?} metadata sizes are too large for this platform. Continuing extraction.", ++ metadata.file_name ++ ), ++ } ++ ++ Some(alz_extraction_limits(ctx)) ++ }) ++ })); ++ ++ let alz = match alz_result { ++ Ok(Ok(x)) => x, ++ Ok(Err(AlzError::Alloc)) => { ++ debug!("Failed to allocate memory when parsing ALZ archive"); ++ return cl_error_t_CL_EMEM; ++ } ++ Ok(Err(err)) => { ++ debug!("Failed to parse Alz file: {}", err.to_string()); ++ return cl_error_t_CL_EFORMAT; ++ } ++ Err(_) => { ++ debug!("Panic occurred when trying to parse ALZ archive"); ++ return cl_error_t_CL_EFORMAT; + } + }; + ++ if alz_metadata_ret != cl_error_t_CL_SUCCESS { ++ return alz_metadata_ret; ++ } ++ ++ if let Some(needed) = alz.file_limit_exceeded_size { ++ let ret = check_scan_limits("ALZ", ctx, needed, 0, 0); ++ if ret != cl_error_t_CL_SUCCESS && ret != cl_error_t_CL_EMAXSIZE { ++ return ret; ++ } ++ } ++ ++ if alz.total_limit_exceeded_size.is_some() { ++ let ret = append_potentially_unwanted_if_heur_exceedsmax( ++ ctx, ++ "Heuristics.Limits.Exceeded.MaxScanSize", ++ ); ++ if ret != cl_error_t_CL_SUCCESS { ++ return ret; ++ } ++ } ++ + for i in 0..alz.embedded_files.len() { + let ret = magic_scan( + ctx, +@@ -352,5 +455,9 @@ pub unsafe extern "C" fn cli_scanalz(ctx: *mut cli_ctx) -> cl_error_t { + } + } + ++ if alz.has_parse_error() { ++ return cl_error_t_CL_EFORMAT; ++ } ++ + cl_error_t_CL_SUCCESS + } +diff --git a/libclamav_rust/src/sys.rs b/libclamav_rust/src/sys.rs +index 399ec438a4..9e36a11709 100644 +--- a/libclamav_rust/src/sys.rs ++++ b/libclamav_rust/src/sys.rs +@@ -795,6 +795,12 @@ extern "C" { + virname: *const ::std::os::raw::c_char, + ) -> cl_error_t; + } ++extern "C" { ++ pub fn cli_append_potentially_unwanted_if_heur_exceedsmax( ++ ctx: *mut cli_ctx, ++ vname: *mut ::std::os::raw::c_char, ++ ); ++} + extern "C" { + pub fn cli_warnmsg(str_: *const ::std::os::raw::c_char, ...); + } +diff --git a/libclamav_rust/src/util.rs b/libclamav_rust/src/util.rs +index 086fdf2786..5a459919f3 100644 +--- a/libclamav_rust/src/util.rs ++++ b/libclamav_rust/src/util.rs +@@ -22,7 +22,7 @@ + + use std::{ffi::CStr, fs::File}; + +-use log::error; ++use log::{debug, error}; + + use crate::sys; + +@@ -85,7 +85,7 @@ pub unsafe fn check_scan_limits( + let module_name = match std::ffi::CString::new(module_name) { + Ok(name) => name, + Err(_) => { +- error!("Invalid module_name: {}", module_name); ++ error!("Invalid module_name: {:?}", module_name); + return sys::cl_error_t_CL_EFORMAT; + } + }; +@@ -93,6 +93,31 @@ pub unsafe fn check_scan_limits( + unsafe { sys::cli_checklimits(module_name.as_ptr(), ctx, need1, need2, need3) } + } + ++/// Append an exceeds-max heuristic alert or metadata entry. ++/// ++/// # Safety ++/// ++/// ctx must be a valid pointer to a clamav scan context structure ++/// ++pub unsafe fn append_potentially_unwanted_if_heur_exceedsmax( ++ ctx: *mut sys::cli_ctx, ++ virname: &str, ++) -> sys::cl_error_t { ++ let virname = match std::ffi::CString::new(virname) { ++ Ok(name) => name, ++ Err(_) => { ++ error!("Invalid virname: {:?}", virname); ++ return sys::cl_error_t_CL_EFORMAT; ++ } ++ }; ++ ++ unsafe { ++ sys::cli_append_potentially_unwanted_if_heur_exceedsmax(ctx, virname.as_ptr().cast_mut()); ++ } ++ ++ sys::cl_error_t_CL_SUCCESS ++} ++ + /// Scan archive metadata. + /// + /// # Safety +@@ -111,7 +136,7 @@ pub unsafe fn scan_archive_metadata( + let module_name = match std::ffi::CString::new(filename) { + Ok(name) => name, + Err(_) => { +- error!("Invalid module_name: {}", filename); ++ debug!("Invalid archive metadata filename: {:?}", filename); + return sys::cl_error_t_CL_EFORMAT; + } + }; +diff --git a/unit_tests/clamscan/alz_test.py b/unit_tests/clamscan/alz_test.py +index 78b294b339..995c4a5156 100644 +--- a/unit_tests/clamscan/alz_test.py ++++ b/unit_tests/clamscan/alz_test.py +@@ -4,12 +4,58 @@ + Run clamscan tests. + """ + ++import hashlib + import sys ++import zlib + + sys.path.append('../unit_tests') + import testcase + + ++ALZ_FILE_HEADER = 0x015A4C41 ++ALZ_LOCAL_FILE_HEADER = 0x015A4C42 ++ALZ_END_OF_CENTRAL_DIRECTORY_HEADER = 0x025A4C43 ++ALZ_ATTR_DIRECTORY = 0x10 ++ALZ_ATTR_FILE = 0x20 ++ALZ_COMP_NOCOMP = 0 ++ALZ_COMP_BZIP2 = 1 ++ALZ_COMP_DEFLATE = 2 ++ALZ_ENCR_HEADER_LEN = 12 ++ ++ ++def append_alz_file( ++ alz, ++ name, ++ compression_method, ++ uncompressed_size, ++ data, ++ file_attribute=ALZ_ATTR_FILE, ++ file_descriptor=0x10, ++): ++ name = name.encode('utf-8') ++ ++ alz.extend(ALZ_LOCAL_FILE_HEADER.to_bytes(4, 'little')) ++ alz.extend(len(name).to_bytes(2, 'little')) ++ alz.append(file_attribute) ++ alz.extend((0).to_bytes(4, 'little')) # file time/date ++ alz.append(file_descriptor) ++ alz.append(0) # unknown ++ alz.append(compression_method) ++ alz.append(0) # unknown ++ alz.extend((0).to_bytes(4, 'little')) # crc ++ alz.append(len(data)) ++ alz.append(uncompressed_size) ++ alz.extend(name) ++ if file_descriptor & 0x01: ++ alz.extend(b'\x00' * ALZ_ENCR_HEADER_LEN) ++ alz.extend(data) ++ ++ ++def raw_deflate(data): ++ compressor = zlib.compressobj(wbits=-15) ++ return compressor.compress(data) + compressor.flush() ++ ++ + class TC(testcase.TestCase): + @classmethod + def setUpClass(cls): +@@ -116,6 +162,281 @@ def test_uncompressed_with_binary(self): + ] + self.verify_output(output.out, expected=expected_results) + ++ def test_extraction_error_does_not_abort_archive_scan(self): ++ self.step_name('Test alz scan continues after a malformed member') ++ ++ payload = b'alz-later-member' ++ testfile = TC.path_tmp / 'bad-then-good.alz' ++ path_db = TC.path_tmp / 'bad-then-good.hdb' ++ ++ alz = bytearray() ++ alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little')) ++ alz.extend((0).to_bytes(4, 'little')) ++ append_alz_file(alz, 'bad.bz2', ALZ_COMP_BZIP2, 1, b'\x00') ++ append_alz_file(alz, 'good.txt', ALZ_COMP_NOCOMP, len(payload), payload) ++ alz.extend(ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_bytes(4, 'little')) ++ ++ testfile.write_bytes(alz) ++ path_db.write_text( ++ '{}:{}:ALZ_LATER_FILE\n'.format(hashlib.sha1(payload).hexdigest(), len(payload)) ++ ) ++ ++ command = '{valgrind} {valgrind_args} {clamscan} -d {path_db} {testfile}'.format( ++ valgrind=TC.valgrind, valgrind_args=TC.valgrind_args, clamscan=TC.clamscan, ++ path_db=path_db, ++ testfile=testfile, ++ ) ++ output = self.execute_command(command) ++ ++ assert output.ec == 1 # virus ++ ++ expected_results = [ ++ 'ALZ_LATER_FILE.UNOFFICIAL FOUND', ++ ] ++ self.verify_output(output.out, expected=expected_results) ++ ++ def test_later_parse_error_does_not_skip_earlier_member_scan(self): ++ self.step_name('Test alz scan checks earlier members before later parse errors') ++ ++ payload = b'alz-earlier-member' ++ testfile = TC.path_tmp / 'good-then-bad-header.alz' ++ path_db = TC.path_tmp / 'good-then-bad-header.hdb' ++ ++ alz = bytearray() ++ alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little')) ++ alz.extend((0).to_bytes(4, 'little')) ++ append_alz_file(alz, 'good.txt', ALZ_COMP_NOCOMP, len(payload), payload) ++ alz.extend(ALZ_LOCAL_FILE_HEADER.to_bytes(4, 'little')) ++ ++ testfile.write_bytes(alz) ++ path_db.write_text( ++ '{}:{}:ALZ_EARLIER_FILE\n'.format(hashlib.sha1(payload).hexdigest(), len(payload)) ++ ) ++ ++ command = '{valgrind} {valgrind_args} {clamscan} -d {path_db} {testfile}'.format( ++ valgrind=TC.valgrind, valgrind_args=TC.valgrind_args, clamscan=TC.clamscan, ++ path_db=path_db, ++ testfile=testfile, ++ ) ++ output = self.execute_command(command) ++ ++ assert output.ec == 1 # virus ++ ++ expected_results = [ ++ 'ALZ_EARLIER_FILE.UNOFFICIAL FOUND', ++ ] ++ self.verify_output(output.out, expected=expected_results) ++ ++ def test_deflate_limit_uses_decompressed_size(self): ++ self.step_name('Test alz deflate scan limits use decompressed output size') ++ ++ truncated_payload = b'A' * 64 ++ payload = b'A' * 4096 ++ compressed = raw_deflate(payload) ++ assert len(compressed) <= 255 ++ ++ testfile = TC.path_tmp / 'deflate-limit.alz' ++ path_db = TC.path_tmp / 'deflate-limit.hdb' ++ ++ alz = bytearray() ++ alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little')) ++ alz.extend((0).to_bytes(4, 'little')) ++ append_alz_file(alz, 'x', ALZ_COMP_DEFLATE, 1, compressed) ++ alz.extend(ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_bytes(4, 'little')) ++ assert len(alz) <= 64 ++ ++ testfile.write_bytes(alz) ++ path_db.write_text( ++ '{}:{}:ALZ_DEFLATE_LIMIT\n'.format( ++ hashlib.sha1(truncated_payload).hexdigest(), ++ len(truncated_payload), ++ ) ++ ) + ++ command = ( ++ '{valgrind} {valgrind_args} {clamscan} --max-filesize=64 ' ++ '--max-scansize=4096 -d {path_db} {testfile}' ++ ).format( ++ valgrind=TC.valgrind, ++ valgrind_args=TC.valgrind_args, ++ clamscan=TC.clamscan, ++ path_db=path_db, ++ testfile=testfile, ++ ) ++ output = self.execute_command(command) ++ ++ assert output.ec == 1 # virus ++ ++ expected_results = [ ++ 'ALZ_DEFLATE_LIMIT.UNOFFICIAL FOUND', ++ ] ++ self.verify_output(output.out, expected=expected_results) + ++ def test_inflated_header_size_does_not_skip_extraction(self): ++ self.step_name('Test alz scan ignores inflated header size for extraction gating') ++ ++ payload = b'alz-small-hit' ++ testfile = TC.path_tmp / 'inflated-header-size.alz' ++ path_db = TC.path_tmp / 'inflated-header-size.hdb' ++ ++ alz = bytearray() ++ alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little')) ++ alz.extend((0).to_bytes(4, 'little')) ++ append_alz_file(alz, 'x', ALZ_COMP_NOCOMP, 80, payload) ++ alz.extend(ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_bytes(4, 'little')) ++ ++ testfile.write_bytes(alz) ++ path_db.write_text( ++ '{}:{}:ALZ_INFLATED_HEADER_SIZE\n'.format( ++ hashlib.sha1(payload).hexdigest(), ++ len(payload), ++ ) ++ ) + ++ command = ( ++ '{valgrind} {valgrind_args} {clamscan} --max-filesize=64 ' ++ '--max-scansize=1024 -d {path_db} {testfile}' ++ ).format( ++ valgrind=TC.valgrind, ++ valgrind_args=TC.valgrind_args, ++ clamscan=TC.clamscan, ++ path_db=path_db, ++ testfile=testfile, ++ ) ++ output = self.execute_command(command) ++ ++ assert output.ec == 1 # virus ++ ++ expected_results = [ ++ 'ALZ_INFLATED_HEADER_SIZE.UNOFFICIAL FOUND', ++ ] ++ self.verify_output(output.out, expected=expected_results) ++ ++ def test_total_limit_does_not_report_max_file_size(self): ++ self.step_name('Test alz archive total limit is not reported as max file size') ++ ++ payload = b'A' * 60 ++ testfile = TC.path_tmp / 'total-limit.alz' ++ path_db = TC.path_tmp / 'total-limit.hdb' ++ ++ alz = bytearray() ++ alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little')) ++ alz.extend((0).to_bytes(4, 'little')) ++ append_alz_file(alz, 'a', ALZ_COMP_NOCOMP, len(payload), payload) ++ append_alz_file(alz, 'b', ALZ_COMP_NOCOMP, len(payload), payload) ++ alz.extend(ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_bytes(4, 'little')) ++ assert len(alz) <= 200 ++ ++ testfile.write_bytes(alz) ++ path_db.write_text( ++ '{}:{}:ALZ_UNUSED\n'.format( ++ hashlib.sha1(b'not-present').hexdigest(), ++ len(b'not-present'), ++ ) ++ ) ++ ++ command = ( ++ '{valgrind} {valgrind_args} {clamscan} --alert-exceeds-max=yes ' ++ '--max-filesize=200 --max-scansize=100 -d {path_db} {testfile}' ++ ).format( ++ valgrind=TC.valgrind, ++ valgrind_args=TC.valgrind_args, ++ clamscan=TC.clamscan, ++ path_db=path_db, ++ testfile=testfile, ++ ) ++ output = self.execute_command(command) ++ ++ assert output.ec == 1 # virus ++ ++ expected_results = [ ++ 'Heuristics.Limits.Exceeded.MaxScanSize FOUND', ++ ] ++ unexpected_results = [ ++ 'Heuristics.Limits.Exceeded.MaxFileSize FOUND', ++ ] ++ self.verify_output(output.out, expected=expected_results, unexpected=unexpected_results) ++ ++ def test_metadata_filepos_counts_skipped_entries_from_one(self): ++ self.step_name('Test alz metadata file position counts skipped entries from one') ++ ++ payload = b'alz-filepos-member' ++ testfile = TC.path_tmp / 'filepos-skipped.alz' ++ path_db = TC.path_tmp / 'filepos-skipped.cdb' ++ ++ alz = bytearray() ++ alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little')) ++ alz.extend((0).to_bytes(4, 'little')) ++ append_alz_file( ++ alz, ++ 'dir/', ++ ALZ_COMP_NOCOMP, ++ 0, ++ b'', ++ file_attribute=ALZ_ATTR_DIRECTORY, ++ ) ++ append_alz_file( ++ alz, ++ 'encrypted.bin', ++ ALZ_COMP_NOCOMP, ++ 0, ++ b'', ++ file_descriptor=0x11, ++ ) ++ append_alz_file(alz, 'good.txt', ALZ_COMP_NOCOMP, len(payload), payload) ++ alz.extend(ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_bytes(4, 'little')) ++ ++ testfile.write_bytes(alz) ++ path_db.write_text('ALZ_FILEPOS_TEST:CL_TYPE_ALZ:*:good.txt:*:*:0:3:*:*\n') ++ ++ command = '{valgrind} {valgrind_args} {clamscan} -d {path_db} {testfile}'.format( ++ valgrind=TC.valgrind, valgrind_args=TC.valgrind_args, clamscan=TC.clamscan, ++ path_db=path_db, ++ testfile=testfile, ++ ) ++ output = self.execute_command(command) ++ ++ assert output.ec == 1 # virus ++ ++ expected_results = [ ++ 'ALZ_FILEPOS_TEST.UNOFFICIAL FOUND', ++ ] ++ self.verify_output(output.out, expected=expected_results) ++ ++ def test_encrypted_metadata_matches_before_extraction_skip(self): ++ self.step_name('Test alz encrypted entries are matched before extraction skip') ++ ++ testfile = TC.path_tmp / 'encrypted-metadata.alz' ++ path_db = TC.path_tmp / 'encrypted-metadata.cdb' ++ ++ alz = bytearray() ++ alz.extend(ALZ_FILE_HEADER.to_bytes(4, 'little')) ++ alz.extend((0).to_bytes(4, 'little')) ++ append_alz_file( ++ alz, ++ 'encrypted.bin', ++ ALZ_COMP_NOCOMP, ++ 0, ++ b'', ++ file_descriptor=0x11, ++ ) ++ alz.extend(ALZ_END_OF_CENTRAL_DIRECTORY_HEADER.to_bytes(4, 'little')) ++ ++ testfile.write_bytes(alz) ++ path_db.write_text( ++ 'ALZ_ENCRYPTED_METADATA_TEST:CL_TYPE_ALZ:*:encrypted.bin:*:*:1:1:*:*\n' ++ ) ++ ++ command = '{valgrind} {valgrind_args} {clamscan} -d {path_db} {testfile}'.format( ++ valgrind=TC.valgrind, valgrind_args=TC.valgrind_args, clamscan=TC.clamscan, ++ path_db=path_db, ++ testfile=testfile, ++ ) ++ output = self.execute_command(command) ++ ++ assert output.ec == 1 # virus ++ ++ expected_results = [ ++ 'ALZ_ENCRYPTED_METADATA_TEST.UNOFFICIAL FOUND', ++ ] ++ self.verify_output(output.out, expected=expected_results) diff --git a/package/clamav/clamav.mk b/package/clamav/clamav.mk index f6a5e0edf4..8e5d9278cc 100644 --- a/package/clamav/clamav.mk +++ b/package/clamav/clamav.mk @@ -39,6 +39,9 @@ CLAMAV_IGNORE_CVES += CVE-2026-20216 # 0005-libclamav-fix-fsg-section-loop-underflow-51.patch CLAMAV_IGNORE_CVES += CVE-2026-20214 +# 0006-fix-alz-parser-robustness-and-scan-coverage-63.patch +CLAMAV_IGNORE_CVES += CVE-2026-20243 + CLAMAV_DEPENDENCIES = \ bzip2 \ host-pkgconf \