mirror of
https://gitlab.com/buildroot.org/buildroot.git
synced 2026-09-30 22:11:59 -09:00
Signed-off-by: Titouan Christophe <titouan.christophe@mind.be> Signed-off-by: Raphaël Mélotte <raphael.melotte@mind.be>
1504 lines
51 KiB
Diff
1504 lines
51 KiB
Diff
From: "Val S." <valsnyde@cisco.com>
|
|
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 <titouan.christophe@mind.be>
|
|
---
|
|
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<u8>>,
|
|
files: &mut Vec<ExtractedFile>,
|
|
+ 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<u8> = Vec::<u8>::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<u8>,
|
|
+ buffer: &[u8],
|
|
+ max_extracted_size: u64,
|
|
+ ) -> Result<Option<u64>, 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<ExtractedFile>) {
|
|
+ fn push_file(&mut self, data: Vec<u8>, files: &mut Vec<ExtractedFile>) -> 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<ExtractedFile>) -> Result<(), Error> {
|
|
+ let mut data: Vec<u8> = 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<u8>>,
|
|
+ cursor: &std::io::Cursor<&Vec<u8>>,
|
|
files: &mut Vec<ExtractedFile>,
|
|
+ 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<u8>>,
|
|
files: &mut Vec<ExtractedFile>,
|
|
+ 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<u8> = 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<u8>>,
|
|
files: &mut Vec<ExtractedFile>,
|
|
+ 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<u8>,
|
|
}
|
|
|
|
+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<ExtractedFile>,
|
|
+ pub file_limit_exceeded_size: Option<u64>,
|
|
+ pub total_limit_exceeded_size: Option<u64>,
|
|
+ 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<u8>>) -> bool {
|
|
+ fn parse_local_fileheader<F>(
|
|
+ &mut self,
|
|
+ cursor: &mut std::io::Cursor<&Vec<u8>>,
|
|
+ filepos: &mut usize,
|
|
+ should_extract: &mut F,
|
|
+ ) -> Result<(), Error>
|
|
+ where
|
|
+ F: FnMut(&AlzFileMetadata<'_>) -> Option<AlzExtractionLimits>,
|
|
+ {
|
|
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, Error> {
|
|
+ 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<F>(bytes: &'aa [u8], mut should_extract: F) -> Result<Self, Error>
|
|
+ where
|
|
+ F: FnMut(&AlzFileMetadata<'_>) -> Option<AlzExtractionLimits>,
|
|
+ {
|
|
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<u8>,
|
|
+ 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<u8>,
|
|
+ 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<u8> {
|
|
+ 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)
|