Skip to content

Commit f90fcf0

Browse files
authored
feat: Add support for scanning Zstandard (zstd) compressed files (#1700)
ClamAV did not previously detect or decompress zstd-compressed files. This adds full support in Rust using the ruzstd crate. A few special cases: - Concatenated frames: the decoder is recreated per frame until the input is exhausted (ruzstd's StreamingDecoder decodes a single frame). - Skippable frames: handled via the SkipFrame header error. - Partial output is always scanned: on decode error, scan-limit hit, or even a decoder panic (caught so it cannot unwind across the FFI boundary), whatever was decompressed so far is still scanned, avoiding evasion gaps. - Scan limits are enforced between and within frames. - It is per zstd spec possible that a file starts with a skippable frame
1 parent 1d043e2 commit f90fcf0

15 files changed

Lines changed: 259 additions & 31 deletions

File tree

Cargo.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libclamav/dconf.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ static struct dconf_module modules[] = {
109109
{"ARCHIVE", "UDF", ARCH_CONF_UDF, 1},
110110
{"ARCHIVE", "LHA", ARCH_CONF_LHA_LZH, 1},
111111
{"ARCHIVE", "ALZ", ARCH_CONF_ALZ, 1},
112+
{"ARCHIVE", "ZSTD", ARCH_CONF_ZSTD, 1},
112113

113114
{"DOCUMENT", "HTML", DOC_CONF_HTML, 1},
114115
{"DOCUMENT", "RTF", DOC_CONF_RTF, 1},

libclamav/dconf.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ struct cli_dconf {
9999
#define ARCH_CONF_UDF 0x8000000
100100
#define ARCH_CONF_LHA_LZH 0x10000000
101101
#define ARCH_CONF_ALZ 0x20000000
102+
#define ARCH_CONF_ZSTD 0x40000000
102103

103104
/* Document flags */
104105
#define DOC_CONF_HTML 0x1

libclamav/filetypes.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ static const struct ftmap_s {
143143
{ "CL_TYPE_PYTHON_COMPILED", CL_TYPE_PYTHON_COMPILED },
144144
{ "CL_TYPE_LHA_LZH", CL_TYPE_LHA_LZH },
145145
{ "CL_TYPE_AI_MODEL", CL_TYPE_AI_MODEL },
146+
{ "CL_TYPE_ZSTD", CL_TYPE_ZSTD },
146147
{ NULL, CL_TYPE_IGNORED }
147148
};
148149
// clang-format on

libclamav/filetypes.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ typedef enum cli_file {
9696
CL_TYPE_PYTHON_COMPILED,
9797
CL_TYPE_LHA_LZH,
9898
CL_TYPE_AI_MODEL,
99+
CL_TYPE_ZSTD,
99100

100101
/* Section for partition types */
101102
CL_TYPE_PART_ANY, /* unknown partition type */

libclamav/filetypes_int.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ static const char *ftypes_int[] = {
163163
"0:0:78617221:XAR container file:CL_TYPE_ANY:CL_TYPE_XAR:75",
164164
"1:EOF-512:6b6f6c79:DMG container file:CL_TYPE_ANY:CL_TYPE_DMG:75",
165165
"0:0:fd377a585a00:XZ container file:CL_TYPE_ANY:CL_TYPE_XZ:76",
166+
"0:0:28b52ffd:Zstandard compressed file:CL_TYPE_ANY:CL_TYPE_ZSTD:76",
167+
"1:0:5?2a4d18:Zstandard skippable frame:CL_TYPE_ANY:CL_TYPE_ZSTD:76",
166168
"4:1024:482B0004:HFS+ partition:CL_TYPE_PART_ANY:CL_TYPE_PART_HFSPLUS:75",
167169
"4:1024:48580005:HFSX partition:CL_TYPE_PART_ANY:CL_TYPE_PART_HFSPLUS:75",
168170
"1:0:3c3f786d6c2076657273696f6e3d22312e3022{0-1024}3c576f726b626f6f6b:Microsoft Excel 2003 XML Document:CL_TYPE_ANY:CL_TYPE_XML_XL:80",

libclamav/scanners.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4857,6 +4857,11 @@ cl_error_t cli_magic_scan(cli_ctx *ctx, cli_file_t type)
48574857
ret = cli_scanxz(ctx);
48584858
break;
48594859

4860+
case CL_TYPE_ZSTD:
4861+
if (SCAN_PARSE_ARCHIVE && (DCONF_ARCH & ARCH_CONF_ZSTD))
4862+
ret = cli_scanzstd(ctx);
4863+
break;
4864+
48604865
case CL_TYPE_GPT:
48614866
if (SCAN_PARSE_ARCHIVE && (DCONF_ARCH & ARCH_CONF_GPT))
48624867
ret = cli_scangpt(ctx, 0);

libclamav_rust/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ md5 = "0.7.0"
3232
openssl = "0.10.70"
3333
glob = "0.3.1"
3434
indexmap = "2.10.0"
35+
ruzstd = "0.8.3"
3536

3637
[features]
3738
not_ready = []

libclamav_rust/src/scanners.rs

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
use std::{
2424
ffi::{c_char, CString},
25-
io::Read,
25+
io::{Cursor, Read},
2626
panic,
2727
path::Path,
2828
ptr::null_mut,
@@ -31,6 +31,10 @@ use std::{
3131
use delharc::LhaDecodeReader;
3232
use libc::c_void;
3333
use log::{debug, error, warn};
34+
use ruzstd::decoding::{
35+
errors::{FrameDecoderError, ReadFrameHeaderError},
36+
StreamingDecoder,
37+
};
3438

3539
use crate::{
3640
alz::Alz,
@@ -352,3 +356,115 @@ pub unsafe extern "C" fn cli_scanalz(ctx: *mut cli_ctx) -> cl_error_t {
352356

353357
cl_error_t_CL_SUCCESS
354358
}
359+
360+
/// Decompress and scan a Zstandard (zstd) compressed file.
361+
///
362+
/// Uses the pure-Rust `ruzstd` decoder, so no libzstd C dependency is required.
363+
/// Handles streams made up of multiple concatenated frames as well as
364+
/// skippable frames, mirroring the behavior of the gzip/bzip2/xz scanners.
365+
///
366+
/// # Safety
367+
///
368+
/// Must be a valid ctx pointer.
369+
#[no_mangle]
370+
pub unsafe extern "C" fn cli_scanzstd(ctx: *mut cli_ctx) -> cl_error_t {
371+
let fmap = match ctx::current_fmap(ctx) {
372+
Ok(fmap) => fmap,
373+
Err(e) => {
374+
warn!("Error getting FMap from ctx: {e}");
375+
return cl_error_t_CL_ERROR;
376+
}
377+
};
378+
379+
let file_bytes = match fmap.need_off(0, fmap.len()) {
380+
Ok(bytes) => bytes,
381+
Err(err) => {
382+
error!(
383+
"Failed to get file bytes for fmap of size {}: {err}",
384+
fmap.len()
385+
);
386+
return cl_error_t_CL_ERROR;
387+
}
388+
};
389+
390+
debug!("in cli_scanzstd()");
391+
392+
// Decompress every zstd frame into a single buffer.
393+
//
394+
// `output` is owned outside the closure so that even if the decoder panics
395+
// on malformed input we still scan whatever was decompressed so far, rather
396+
// than discarding it (an evasion gap). The decode loop is wrapped in
397+
// catch_unwind so that a panic cannot unwind across the C FFI boundary.
398+
let mut output: Vec<u8> = Vec::new();
399+
400+
let decompress = panic::catch_unwind(panic::AssertUnwindSafe(|| {
401+
let mut cursor = Cursor::new(file_bytes);
402+
let total_len = file_bytes.len() as u64;
403+
let mut chunk = [0u8; 65536];
404+
405+
'frames: while cursor.position() < total_len {
406+
// Stop before starting a new frame if we've already hit scan limits.
407+
if unsafe { check_scan_limits("zstd", ctx, output.len() as u64, 0, 0) }
408+
!= cl_error_t_CL_SUCCESS
409+
{
410+
debug!("cli_scanzstd: exceeded scan limits. Bailing out.");
411+
break;
412+
}
413+
414+
// ruzstd's StreamingDecoder decodes a single frame, so we recreate it
415+
// for each concatenated frame in the stream.
416+
let mut decoder = match StreamingDecoder::new(&mut cursor) {
417+
Ok(decoder) => decoder,
418+
Err(FrameDecoderError::ReadFrameHeaderError(ReadFrameHeaderError::SkipFrame {
419+
length,
420+
..
421+
})) => {
422+
// Skippable frame: its 8-byte header was already consumed; skip the body.
423+
let next = cursor
424+
.position()
425+
.saturating_add(length as u64)
426+
.min(total_len);
427+
cursor.set_position(next);
428+
continue;
429+
}
430+
Err(err) => {
431+
// No more valid frames (e.g. trailing data). Scan what we have.
432+
debug!("cli_scanzstd: stopping frame parsing: {err}");
433+
break;
434+
}
435+
};
436+
437+
loop {
438+
match decoder.read(&mut chunk) {
439+
Ok(0) => break, // current frame fully decoded
440+
Ok(n) => {
441+
output.extend_from_slice(&chunk[..n]);
442+
443+
if unsafe { check_scan_limits("zstd", ctx, output.len() as u64, 0, 0) }
444+
!= cl_error_t_CL_SUCCESS
445+
{
446+
debug!(
447+
"cli_scanzstd: decompressed size exceeds limits - \
448+
only scanning {} bytes",
449+
output.len()
450+
);
451+
break 'frames;
452+
}
453+
}
454+
Err(err) => {
455+
// Scan whatever we decompressed so far.
456+
debug!("cli_scanzstd: decompress error: {err}");
457+
break 'frames;
458+
}
459+
}
460+
}
461+
}
462+
}));
463+
464+
if decompress.is_err() {
465+
// The decoder panicked; scan whatever was decompressed before the panic.
466+
debug!("cli_scanzstd: panic while decompressing zstd data");
467+
}
468+
469+
magic_scan(ctx, &output, None)
470+
}

libclamav_rust/src/sys.rs

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -369,36 +369,37 @@ pub const cli_file_CL_TYPE_ONENOTE: cli_file = 554;
369369
pub const cli_file_CL_TYPE_PYTHON_COMPILED: cli_file = 555;
370370
pub const cli_file_CL_TYPE_LHA_LZH: cli_file = 556;
371371
pub const cli_file_CL_TYPE_AI_MODEL: cli_file = 557;
372-
pub const cli_file_CL_TYPE_PART_ANY: cli_file = 558;
373-
pub const cli_file_CL_TYPE_PART_HFSPLUS: cli_file = 559;
374-
pub const cli_file_CL_TYPE_MBR: cli_file = 560;
375-
pub const cli_file_CL_TYPE_HTML: cli_file = 561;
376-
pub const cli_file_CL_TYPE_MAIL: cli_file = 562;
377-
pub const cli_file_CL_TYPE_SFX: cli_file = 563;
378-
pub const cli_file_CL_TYPE_ZIPSFX: cli_file = 564;
379-
pub const cli_file_CL_TYPE_RARSFX: cli_file = 565;
380-
pub const cli_file_CL_TYPE_7ZSFX: cli_file = 566;
381-
pub const cli_file_CL_TYPE_CABSFX: cli_file = 567;
382-
pub const cli_file_CL_TYPE_ARJSFX: cli_file = 568;
383-
pub const cli_file_CL_TYPE_EGGSFX: cli_file = 569;
384-
pub const cli_file_CL_TYPE_NULSFT: cli_file = 570;
385-
pub const cli_file_CL_TYPE_AUTOIT: cli_file = 571;
386-
pub const cli_file_CL_TYPE_ISHIELD_MSI: cli_file = 572;
387-
pub const cli_file_CL_TYPE_ISO9660: cli_file = 573;
388-
pub const cli_file_CL_TYPE_DMG: cli_file = 574;
389-
pub const cli_file_CL_TYPE_GPT: cli_file = 575;
390-
pub const cli_file_CL_TYPE_APM: cli_file = 576;
391-
pub const cli_file_CL_TYPE_XDP: cli_file = 577;
392-
pub const cli_file_CL_TYPE_XML_WORD: cli_file = 578;
393-
pub const cli_file_CL_TYPE_XML_XL: cli_file = 579;
394-
pub const cli_file_CL_TYPE_XML_HWP: cli_file = 580;
395-
pub const cli_file_CL_TYPE_HWPOLE2: cli_file = 581;
396-
pub const cli_file_CL_TYPE_MHTML: cli_file = 582;
397-
pub const cli_file_CL_TYPE_LNK: cli_file = 583;
398-
pub const cli_file_CL_TYPE_UDF: cli_file = 584;
399-
pub const cli_file_CL_TYPE_ALZ: cli_file = 585;
400-
pub const cli_file_CL_TYPE_OTHER: cli_file = 586;
401-
pub const cli_file_CL_TYPE_IGNORED: cli_file = 587;
372+
pub const cli_file_CL_TYPE_ZSTD: cli_file = 558;
373+
pub const cli_file_CL_TYPE_PART_ANY: cli_file = 559;
374+
pub const cli_file_CL_TYPE_PART_HFSPLUS: cli_file = 560;
375+
pub const cli_file_CL_TYPE_MBR: cli_file = 561;
376+
pub const cli_file_CL_TYPE_HTML: cli_file = 562;
377+
pub const cli_file_CL_TYPE_MAIL: cli_file = 563;
378+
pub const cli_file_CL_TYPE_SFX: cli_file = 564;
379+
pub const cli_file_CL_TYPE_ZIPSFX: cli_file = 565;
380+
pub const cli_file_CL_TYPE_RARSFX: cli_file = 566;
381+
pub const cli_file_CL_TYPE_7ZSFX: cli_file = 567;
382+
pub const cli_file_CL_TYPE_CABSFX: cli_file = 568;
383+
pub const cli_file_CL_TYPE_ARJSFX: cli_file = 569;
384+
pub const cli_file_CL_TYPE_EGGSFX: cli_file = 570;
385+
pub const cli_file_CL_TYPE_NULSFT: cli_file = 571;
386+
pub const cli_file_CL_TYPE_AUTOIT: cli_file = 572;
387+
pub const cli_file_CL_TYPE_ISHIELD_MSI: cli_file = 573;
388+
pub const cli_file_CL_TYPE_ISO9660: cli_file = 574;
389+
pub const cli_file_CL_TYPE_DMG: cli_file = 575;
390+
pub const cli_file_CL_TYPE_GPT: cli_file = 576;
391+
pub const cli_file_CL_TYPE_APM: cli_file = 577;
392+
pub const cli_file_CL_TYPE_XDP: cli_file = 578;
393+
pub const cli_file_CL_TYPE_XML_WORD: cli_file = 579;
394+
pub const cli_file_CL_TYPE_XML_XL: cli_file = 580;
395+
pub const cli_file_CL_TYPE_XML_HWP: cli_file = 581;
396+
pub const cli_file_CL_TYPE_HWPOLE2: cli_file = 582;
397+
pub const cli_file_CL_TYPE_MHTML: cli_file = 583;
398+
pub const cli_file_CL_TYPE_LNK: cli_file = 584;
399+
pub const cli_file_CL_TYPE_UDF: cli_file = 585;
400+
pub const cli_file_CL_TYPE_ALZ: cli_file = 586;
401+
pub const cli_file_CL_TYPE_OTHER: cli_file = 587;
402+
pub const cli_file_CL_TYPE_IGNORED: cli_file = 588;
402403
pub type cli_file = ::std::os::raw::c_uint;
403404
pub use self::cli_file as cli_file_t;
404405
#[repr(C)]

0 commit comments

Comments
 (0)