Skip to content

Commit fa859d8

Browse files
committed
Harden codesign and cdiff input parsing
Detached .sign files and cdiff/script commands are database-side inputs that should be trusted only after basic format validation. A malformed .sign header could reach a split/unwrap path and abort the process instead of returning a verification error. Several adjacent .sign certificate/signature metadata paths and cdiff command argument paths had similar panic-on-malformed-input assumptions. Validate the .sign file header explicitly, report malformed digital-signature metadata and certificate common-name failures as verification errors, and reject truncated cdiff/script commands with MissingParameter errors. Add focused Rust unit tests for the malformed header and command cases. These changes are hardening only. ClamAV database and update artifacts are expected to be provided by administrators or trusted update infrastructure, so we are not treating this as a vulnerability fix. Credit: Owais Lone (TheSecGuy) CLAM-2996
1 parent 9d0878e commit fa859d8

2 files changed

Lines changed: 164 additions & 49 deletions

File tree

libclamav_rust/src/cdiff.rs

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,22 +1172,25 @@ fn process_line(ctx: &mut Context, line: &[u8]) -> Result<(), InputError> {
11721172
// Call the appropriate command function
11731173
match cmd {
11741174
b"OPEN" => cmd_open(ctx, remainder),
1175-
b"ADD" => cmd_add(ctx, remainder_with_nl.unwrap()),
1175+
b"ADD" => cmd_add(
1176+
ctx,
1177+
nonempty_command_args("ADD", "signature", remainder_with_nl)?,
1178+
),
11761179
b"DEL" => {
1177-
let del_op = DelOp::new(remainder.unwrap())?;
1180+
let del_op = DelOp::new(command_args("DEL", "parameters", remainder)?)?;
11781181
cmd_del(ctx, del_op)
11791182
}
11801183
b"XCHG" => {
1181-
let xchg_op = XchgOp::new(remainder.unwrap())?;
1184+
let xchg_op = XchgOp::new(command_args("XCHG", "parameters", remainder)?)?;
11821185
cmd_xchg(ctx, xchg_op)
11831186
}
11841187
b"MOVE" => {
1185-
let move_op = MoveOp::new(remainder.unwrap())?;
1188+
let move_op = MoveOp::new(command_args("MOVE", "parameters", remainder)?)?;
11861189
cmd_move(ctx, move_op)
11871190
}
11881191
b"CLOSE" => cmd_close(ctx),
11891192
b"UNLINK" => {
1190-
let unlink_op = UnlinkOp::new(remainder.unwrap())?;
1193+
let unlink_op = UnlinkOp::new(command_args("UNLINK", "parameters", remainder)?)?;
11911194
cmd_unlink(ctx, unlink_op)
11921195
}
11931196
_ => Err(InputError::UnknownCommand(
@@ -1196,6 +1199,26 @@ fn process_line(ctx: &mut Context, line: &[u8]) -> Result<(), InputError> {
11961199
}
11971200
}
11981201

1202+
fn command_args<'a>(
1203+
command: &'static str,
1204+
parameter: &'static str,
1205+
args: Option<&'a [u8]>,
1206+
) -> Result<&'a [u8], InputError> {
1207+
args.ok_or(InputError::MissingParameter(command, parameter))
1208+
}
1209+
1210+
fn nonempty_command_args<'a>(
1211+
command: &'static str,
1212+
parameter: &'static str,
1213+
args: Option<&'a [u8]>,
1214+
) -> Result<&'a [u8], InputError> {
1215+
let args = command_args(command, parameter, args)?;
1216+
if args.is_empty() {
1217+
return Err(InputError::MissingParameter(command, parameter));
1218+
}
1219+
Ok(args)
1220+
}
1221+
11991222
/// Main loop for iterating over cdiff command lines and handling them
12001223
fn process_lines<T>(
12011224
ctx: &mut Context,
@@ -1392,6 +1415,33 @@ mod tests {
13921415
));
13931416
}
13941417

1418+
#[test]
1419+
fn process_line_rejects_missing_add_signature() {
1420+
let mut ctx = Context::default();
1421+
1422+
assert!(matches!(
1423+
process_line(&mut ctx, b"ADD\n"),
1424+
Err(InputError::MissingParameter("ADD", "signature"))
1425+
));
1426+
}
1427+
1428+
#[test]
1429+
fn process_line_rejects_missing_command_args() {
1430+
for (command, input) in [
1431+
("DEL", b"DEL\n".as_slice()),
1432+
("XCHG", b"XCHG\n".as_slice()),
1433+
("MOVE", b"MOVE\n".as_slice()),
1434+
("UNLINK", b"UNLINK\n".as_slice()),
1435+
] {
1436+
let mut ctx = Context::default();
1437+
1438+
assert!(matches!(
1439+
process_line(&mut ctx, input),
1440+
Err(InputError::MissingParameter(cmd, "parameters")) if cmd == command
1441+
));
1442+
}
1443+
}
1444+
13951445
/// Helper function to set up a test folder and initialize a pseudo-db file with specified data.
13961446
fn initialize_db_file_with_data(
13971447
initial_data: Vec<&str>,

libclamav_rust/src/codesign.rs

Lines changed: 109 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
*/
2020

2121
use std::{
22-
ffi::{c_void, CStr},
22+
ffi::{c_void, CStr, CString},
2323
fs::File,
2424
io::{prelude::*, BufReader},
2525
mem::ManuallyDrop,
@@ -33,7 +33,7 @@ use openssl::{
3333
stack::{self, Stack},
3434
x509::{
3535
store::{X509Store, X509StoreBuilder},
36-
X509,
36+
X509Ref, X509,
3737
},
3838
};
3939

@@ -43,10 +43,13 @@ use clam_sigutil::{
4343
SigType, Signature,
4444
};
4545

46-
use log::{debug, error, warn};
46+
use log::{debug, warn};
4747

4848
use crate::{ffi_error, ffi_util::FFIError, sys::cl_retflevel, validate_str_param};
4949

50+
const CLAMSIGN_HEADER_PREFIX: &str = "#clamsign-";
51+
const CLAMSIGN_VERSION: &str = "1.0";
52+
5053
#[derive(Debug, thiserror::Error)]
5154
pub enum Error {
5255
#[error("Can't verify: {0}")]
@@ -82,6 +85,38 @@ pub enum Error {
8285
IncorrectPublicKey,
8386
}
8487

88+
fn validate_clamsign_header(line: &str) -> Result<(), Error> {
89+
let version = line.strip_prefix(CLAMSIGN_HEADER_PREFIX).ok_or_else(|| {
90+
Error::CannotVerify(format!(
91+
"Unsupported signature file format, expected first line to start with '{}{}'",
92+
CLAMSIGN_HEADER_PREFIX, CLAMSIGN_VERSION
93+
))
94+
})?;
95+
96+
if version != CLAMSIGN_VERSION {
97+
return Err(Error::CannotVerify(format!(
98+
"Unsupported signature file version, expected '{}'",
99+
CLAMSIGN_VERSION
100+
)));
101+
}
102+
103+
Ok(())
104+
}
105+
106+
fn certificate_common_name(cert: &X509Ref) -> Result<String, String> {
107+
let common_name = cert
108+
.subject_name()
109+
.entries()
110+
.find(|name_entry| name_entry.object().nid() == openssl::nid::Nid::COMMONNAME)
111+
.ok_or_else(|| "certificate subject is missing a common name".to_string())?;
112+
113+
common_name
114+
.data()
115+
.as_utf8()
116+
.map(|name| name.to_string())
117+
.map_err(|e| format!("certificate common name is not valid UTF-8: {}", e))
118+
}
119+
85120
/// C interface for verify_signed_file() which verifies a file's external digital signature.
86121
/// Handles all the unsafe ffi stuff.
87122
///
@@ -306,7 +341,15 @@ pub unsafe extern "C" fn codesign_verify_file(
306341
Ok(signer) => {
307342
debug!("CVD verified successfully");
308343
// convert the signer_name to a CString and store it in the output parameter
309-
let signer_cstr = std::ffi::CString::new(signer).unwrap();
344+
let signer_cstr = match CString::new(signer) {
345+
Ok(signer_cstr) => signer_cstr,
346+
Err(e) => {
347+
return ffi_error!(
348+
err = err,
349+
Error::CannotVerify(format!("Invalid signer name: {}", e))
350+
);
351+
}
352+
};
310353
*signer_name = signer_cstr.into_raw();
311354
true
312355
}
@@ -407,19 +450,7 @@ pub fn verify_signed_file(
407450
// First line should be "#clamsign-MAJOR.MINOR"
408451
if index == 0 {
409452
let line = line?;
410-
if !line.starts_with("#clamsign") {
411-
return Err(Error::CannotVerify(
412-
"Unsupported signature file format, expected first line start with '#clamsign-1.0'".to_string(),
413-
));
414-
}
415-
416-
// Check clamsign version
417-
let version = line.split('-').nth(1).unwrap();
418-
if version != "1.0" {
419-
return Err(Error::CannotVerify(
420-
"Unsupported signature file version, expected '1.0'".to_string(),
421-
));
422-
}
453+
validate_clamsign_header(&line)?;
423454

424455
continue;
425456
}
@@ -441,7 +472,12 @@ pub fn verify_signed_file(
441472

442473
match parse_from_cvd_with_meta(SigType::DigitalSignature, &data.into()) {
443474
Ok((sig, meta)) => {
444-
let sig = sig.downcast::<DigitalSig>().unwrap();
475+
let sig = sig.downcast::<DigitalSig>().map_err(|_| {
476+
Error::CannotVerify(format!(
477+
"{:?}:{}: Signature did not parse as a digital signature",
478+
signature_file_path, index
479+
))
480+
})?;
445481

446482
sig.validate(&meta).map_err(|e| {
447483
Error::CannotVerify(format!(
@@ -452,7 +488,12 @@ pub fn verify_signed_file(
452488

453489
// verify the flevel bounds of this signature compared with the current flevel
454490
let current_flevel = unsafe { cl_retflevel() };
455-
let sig_flevel_range = meta.f_level.unwrap();
491+
let sig_flevel_range = meta.f_level.ok_or_else(|| {
492+
Error::CannotVerify(format!(
493+
"{:?}:{}: Digital signature is missing feature-level metadata",
494+
signature_file_path, index
495+
))
496+
})?;
456497
if !sig_flevel_range.contains(&current_flevel) {
457498
debug!(
458499
"{:?}:{}: Signature feature level range {:?} does not include current feature level {}",
@@ -574,25 +615,27 @@ impl Verifier {
574615
let file = file?;
575616
let path = file.path();
576617
if path.is_file() {
577-
let ext = path.extension();
578-
if ext.is_some() && (ext.unwrap() == "pem" || ext.unwrap() == "crt") {
579-
let read_result = std::fs::read(&path);
580-
if let Err(e) = read_result {
581-
debug!("Error reading certificate file '{:?}': {}", path, e);
582-
continue;
583-
}
584-
let certs_in_file = X509::stack_from_pem(&read_result.unwrap())?;
618+
let is_certificate = match path.extension() {
619+
Some(ext) => ext == "pem" || ext == "crt",
620+
None => false,
621+
};
622+
if is_certificate {
623+
let cert_bytes = match std::fs::read(&path) {
624+
Ok(cert_bytes) => cert_bytes,
625+
Err(e) => {
626+
debug!("Error reading certificate file '{:?}': {}", path, e);
627+
continue;
628+
}
629+
};
630+
let certs_in_file = X509::stack_from_pem(&cert_bytes)?;
585631

586632
for cert in certs_in_file {
587-
// get cert common name
588-
let common_name = cert
589-
.subject_name()
590-
.entries()
591-
.find(|name_entry| {
592-
name_entry.object().nid() == openssl::nid::Nid::COMMONNAME
593-
})
594-
.map(|name_entry| name_entry.data().as_utf8().unwrap().to_string())
595-
.unwrap();
633+
let common_name = certificate_common_name(&cert).map_err(|e| {
634+
Error::CertificateStore(format!(
635+
"Invalid certificate {:?}: {}",
636+
path, e
637+
))
638+
})?;
596639

597640
if root_common_names.contains(&common_name) {
598641
return Err(Error::CertificateStore(format!(
@@ -628,15 +671,11 @@ impl Verifier {
628671
let signers: Vec<String> = signer
629672
.iter()
630673
.map(|cert| {
631-
cert.subject_name()
632-
.entries()
633-
.find(|name_entry| {
634-
name_entry.object().nid() == openssl::nid::Nid::COMMONNAME
635-
})
636-
.map(|name_entry| name_entry.data().as_utf8().unwrap().to_string())
637-
.unwrap()
674+
certificate_common_name(cert).map_err(|e| {
675+
Error::InvalidDigitalSignature(format!("Invalid signer certificate: {}", e))
676+
})
638677
})
639-
.collect();
678+
.collect::<Result<_, _>>()?;
640679

641680
match result {
642681
Ok(()) => {
@@ -656,3 +695,29 @@ impl Verifier {
656695
}
657696
}
658697
}
698+
699+
#[cfg(test)]
700+
mod tests {
701+
use super::*;
702+
703+
#[test]
704+
fn clamsign_header_accepts_supported_version() {
705+
validate_clamsign_header("#clamsign-1.0").expect("valid clamsign header");
706+
}
707+
708+
#[test]
709+
fn clamsign_header_rejects_missing_separator() {
710+
assert!(matches!(
711+
validate_clamsign_header("#clamsign"),
712+
Err(Error::CannotVerify(_))
713+
));
714+
}
715+
716+
#[test]
717+
fn clamsign_header_rejects_unsupported_version() {
718+
assert!(matches!(
719+
validate_clamsign_header("#clamsign-2.0"),
720+
Err(Error::CannotVerify(_))
721+
));
722+
}
723+
}

0 commit comments

Comments
 (0)