use std::{fs::File, io::Read, os::fd::AsRawFd, path::PathBuf};
use ::serde::{Deserialize, Serialize};
use libc::FS_IOC_GETFLAGS;
use log::{debug, warn};
use nix::unistd::{AccessFlags, access};
use rootasrole_core::{
database::score::{CmdMin, CmdOrder},
util::{all_paths_from_env, match_single_path, read_with_privileges},
};
use serde_json::to_value;
use sha2::Digest;
use crate::{error::SrResult, finder::cmd::match_args};
use super::{Api, ApiEvent, EventKey};
#[derive(Debug, Serialize, Deserialize, Hash, Eq, PartialEq)]
#[serde(rename_all = "lowercase", untagged)]
pub enum HashElement {
SHA224 {
#[serde(rename = "sha224")]
sha224: String,
},
SHA256 {
#[serde(rename = "sha256")]
sha256: String,
},
SHA384 {
#[serde(rename = "sha384")]
sha384: String,
},
SHA512 {
#[serde(rename = "sha512")]
sha512: String,
},
}
#[derive(Debug, Serialize, Deserialize, Hash, Eq, PartialEq)]
struct HashChecker {
#[serde(flatten)]
hash: Option<HashElement>,
#[serde(alias = "read-only")]
read_only: Option<bool>,
immutable: Option<bool>,
command: String,
}
fn new_complex_command(event: &mut ApiEvent) -> SrResult<()> {
if let ApiEvent::ProcessComplexCommand(
value,
env_path,
cmd_path,
cmd_args,
cmd_min,
final_path,
) = event
{
let hash_checker: HashChecker = serde_json::from_value(to_value(value)?)?;
process_hash_check(
&hash_checker,
env_path,
cmd_path,
cmd_args,
cmd_min,
final_path,
);
}
Ok(())
}
fn evaluate_hash(hashtype: &HashElement, hash: &[u8]) -> bool {
match hashtype {
HashElement::SHA224 { sha224 } => {
let mut hasher = sha2::Sha224::new();
hasher.update(hash);
hasher.finalize().to_vec() == hex::decode(sha224).unwrap_or_default()
}
HashElement::SHA256 { sha256 } => {
let mut hasher = sha2::Sha256::new();
hasher.update(hash);
hasher.finalize().to_vec() == hex::decode(sha256).unwrap_or_default()
}
HashElement::SHA384 { sha384 } => {
let mut hasher = sha2::Sha384::new();
hasher.update(hash);
hasher.finalize().to_vec() == hex::decode(sha384).unwrap_or_default()
}
HashElement::SHA512 { sha512 } => {
let mut hasher = sha2::Sha512::new();
hasher.update(hash);
hasher.finalize().to_vec() == hex::decode(sha512).unwrap_or_default()
}
}
}
const FS_IMMUTABLE_FL: u32 = 0x0000_0010;
fn is_immutable(file: &File) -> Result<bool, Box<dyn std::error::Error>> {
let mut val = 0;
let fd = file.as_raw_fd();
if unsafe { nix::libc::ioctl(fd, FS_IOC_GETFLAGS, &mut val) } < 0 {
debug!("Error getting flags {:?}", std::io::Error::last_os_error());
return Err("Error getting flags".into());
}
Ok(val & FS_IMMUTABLE_FL != 0)
}
fn match_path(
checker: &HashChecker,
env_path: &[&str],
cmd_path: &PathBuf,
role_path: &String,
final_path: &mut Option<PathBuf>,
) -> CmdMin {
if cfg!(feature = "glob") && role_path == "**" {
CmdMin::builder()
.matching()
.order(CmdOrder::FullWildcardPath)
.build()
} else if cmd_path.is_absolute() {
let min = match_single_path(cmd_path, role_path);
verify_executable_conditions(checker, final_path, cmd_path, min).unwrap_or_default()
} else {
all_paths_from_env(env_path, cmd_path)
.iter()
.find_map(|cmd_path| {
let min = match_single_path(cmd_path, role_path);
verify_executable_conditions(checker, final_path, cmd_path, min)
})
.unwrap_or_default()
}
}
fn verify_executable_conditions(
checker: &HashChecker,
final_path: &mut Option<PathBuf>,
cmd_path: &PathBuf,
min: CmdMin,
) -> Option<CmdMin> {
if min.matching() {
if checker.read_only.is_some_and(|read_only| read_only) {
if access(cmd_path, AccessFlags::W_OK).is_ok() {
warn!("File should be read only but has write access");
return None;
}
warn!(
"Executor has write access to the executable, this could lead to a race condition vulnerability"
);
}
let open = read_with_privileges(cmd_path);
if open.is_err() {
return None;
}
let mut open = open.expect("File should be accessible");
if checker.immutable.is_some_and(|immutable| immutable) {
let is_immutable = is_immutable(&open);
if is_immutable.is_err() {
return None;
}
if !is_immutable.expect("File should be accessible") {
warn!("File should be immutable but is not");
return None;
}
}
if let Some(hash_element) = &checker.hash {
let mut buf = Vec::new();
let res = open.read_to_end(&mut buf);
if res.is_err() {
warn!("Error reading file {res:?}");
return None;
}
if !evaluate_hash(hash_element, &buf) {
warn!("Hash does not match");
return None;
}
}
*final_path = Some(cmd_path.clone());
Some(min)
} else {
None
}
}
fn match_command_line(
checker: &HashChecker,
env_path: &[&str],
cmd_path: &PathBuf,
cmd_args: &[String],
role_command: &[String],
cmd_min: &mut CmdMin,
final_path: &mut Option<PathBuf>,
) {
let mut result = match_path(checker, env_path, cmd_path, &role_command[0], final_path);
if !result.matching() || role_command.len() == 1 {
*cmd_min = result;
return;
}
match match_args(cmd_args, &shell_words::join(&role_command[1..])) {
Ok(args_result) => result = args_result,
Err(err) => {
debug!("Error: {err}");
return;
}
}
*cmd_min = result;
}
fn process_hash_check(
checker: &HashChecker,
env_path: &[&str],
cmd_path: &PathBuf,
cmd_args: &[String],
min_score: &mut CmdMin,
final_path: &mut Option<PathBuf>,
) {
match shell_words::split(&checker.command).map_err(Into::<Box<dyn std::error::Error>>::into) {
Ok(command) => {
match_command_line(
checker, env_path, cmd_path, cmd_args, &command, min_score, final_path,
);
}
Err(err) => {
warn!("Error: {err}");
}
}
}
pub fn register() {
Api::register(EventKey::NewComplexCommand, new_complex_command);
}
#[allow(clippy::undocumented_unsafe_blocks)]
#[cfg(test)]
mod tests {
use std::{
fs::File,
io::{self, Read},
os::fd::AsRawFd,
path::{Path, PathBuf},
};
use capctl::{Cap, CapSet, CapState};
use libc::{FS_IOC_GETFLAGS, FS_IOC_SETFLAGS};
use log::debug;
use nix::{
fcntl::AT_FDCWD,
sys::stat::{Mode, fchmodat},
};
use rootasrole_core::{
database::score::CmdMin,
util::{has_privileges, immutable_required_privileges},
};
use serde::de::DeserializeSeed;
use sha2::{Digest, Sha224, Sha256, Sha384, Sha512};
use crate::finder::{
api::hashchecker::{FS_IMMUTABLE_FL, register},
de::commands::DCommandDeserializer,
};
pub struct Defer<F: FnOnce()>(Option<F>);
impl<F: FnOnce()> Defer<F> {
pub fn new(f: F) -> Self {
Self(Some(f))
}
}
impl<F: FnOnce()> Drop for Defer<F> {
fn drop(&mut self) {
if let Some(f) = self.0.take() {
f();
}
}
}
pub fn defer<F: FnOnce()>(f: F) -> Defer<F> {
Defer::new(f)
}
fn set_read_only(path: &Path) -> nix::Result<()> {
fchmodat(
AT_FDCWD, path,
Mode::S_IRUSR | Mode::S_IRGRP | Mode::S_IROTH, nix::sys::stat::FchmodatFlags::NoFollowSymlink, )?;
Ok(())
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_dcommand_seed_hashchecker() {
register();
let filename = "test.sh";
let _cleanup = defer(|| {
let filename = PathBuf::from(filename)
.canonicalize()
.unwrap_or_else(|_| filename.into());
if std::fs::remove_file(&filename).is_err() {
debug!("Failed to delete the file: {}", filename.display());
}
});
File::create(filename).unwrap();
let filename = PathBuf::from(filename).canonicalize().unwrap();
let mut file = File::open(&filename).unwrap();
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).unwrap();
let mut sha224hasher = Sha224::new();
sha224hasher.update(&buffer);
let sha224 = sha224hasher.finalize();
let sha224 = hex::encode(sha224);
let json = format!(
r#"{{"sha224": "{}", "command": "{} -l"}}"#,
sha224,
&filename.display()
);
let mut final_path = None;
let mut cmd_min = CmdMin::default();
let deserializer = DCommandDeserializer {
env_path: &["/usr/bin"],
cmd_path: &PathBuf::from(&filename).canonicalize().unwrap(),
cmd_args: &["-l".to_string()],
final_path: &mut final_path,
cmd_min: &mut cmd_min,
};
let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(&json));
assert!(result.is_ok());
assert_eq!(final_path, Some(PathBuf::from(&filename)));
assert_eq!(cmd_min, CmdMin::MATCH);
let mut sha256hasher = Sha256::new();
sha256hasher.update(&buffer);
let sha256 = sha256hasher.finalize();
let json = format!(
r#"{{"sha256": "{}", "command": "{} -l"}}"#,
hex::encode(sha256),
&filename.display()
);
let mut final_path = None;
let mut cmd_min = CmdMin::default();
let deserializer = DCommandDeserializer {
env_path: &["/usr/bin"],
cmd_path: &PathBuf::from(&filename).canonicalize().unwrap(),
cmd_args: &["-l".to_string()],
final_path: &mut final_path,
cmd_min: &mut cmd_min,
};
let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(&json));
assert!(result.is_ok());
assert_eq!(final_path, Some(PathBuf::from(&filename)));
assert_eq!(cmd_min, CmdMin::MATCH);
let mut sha384hasher = Sha384::new();
sha384hasher.update(&buffer);
let sha384 = sha384hasher.finalize();
let sha384 = hex::encode(sha384);
let json = format!(
r#"{{"sha384": "{}", "command": "{} -l"}}"#,
sha384,
&filename.display()
);
let mut final_path = None;
let mut cmd_min = CmdMin::default();
let deserializer = DCommandDeserializer {
env_path: &["/usr/bin"],
cmd_path: &PathBuf::from(&filename).canonicalize().unwrap(),
cmd_args: &["-l".to_string()],
final_path: &mut final_path,
cmd_min: &mut cmd_min,
};
let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(&json));
assert!(result.is_ok());
assert_eq!(final_path, Some(PathBuf::from(&filename)));
assert_eq!(cmd_min, CmdMin::MATCH);
let mut sha512hasher = Sha512::new();
sha512hasher.update(&buffer);
let sha512 = sha512hasher.finalize();
let sha512 = hex::encode(sha512);
let json = format!(
r#"{{"sha512": "{}", "command": "{} -l"}}"#,
sha512,
&filename.display()
);
let mut final_path = None;
let mut cmd_min = CmdMin::default();
let deserializer = DCommandDeserializer {
env_path: &["/usr/bin"],
cmd_path: &PathBuf::from(&filename).canonicalize().unwrap(),
cmd_args: &["-l".to_string()],
final_path: &mut final_path,
cmd_min: &mut cmd_min,
};
let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(&json));
assert!(result.is_ok());
assert_eq!(final_path, Some(PathBuf::from(&filename)));
assert_eq!(cmd_min, CmdMin::MATCH);
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_read_only_immutable() {
register();
let current = CapState::get_current();
let mut current = current.unwrap();
current.effective = CapSet::empty();
current.permitted = CapSet::empty();
current.inheritable = CapSet::empty();
current.set_current().unwrap();
let filename = "/tmp/test_ro.sh";
let _cleanup = defer(|| {
let filename = PathBuf::from(filename)
.canonicalize()
.unwrap_or_else(|_| filename.into());
if std::fs::remove_file(&filename).is_err() {
debug!("Failed to delete the file: {}", filename.display());
}
});
File::create(filename).unwrap();
let filename = PathBuf::from(filename).canonicalize().unwrap();
let check_config = |json: &str, expected_path: Option<PathBuf>, expected_min: CmdMin| {
debug!("json: {json}");
let mut final_path = None;
let mut cmd_min = CmdMin::default();
let deserializer = DCommandDeserializer {
env_path: &["/usr/bin"],
cmd_path: &PathBuf::from(&filename).canonicalize().unwrap(),
cmd_args: &["-l".to_string()],
final_path: &mut final_path,
cmd_min: &mut cmd_min,
};
let result = deserializer.deserialize(&mut serde_json::Deserializer::from_str(json));
if let Err(e) = &result {
debug!("Error: {e}");
}
assert!(result.is_ok());
assert_eq!(final_path, expected_path);
assert_eq!(cmd_min, expected_min);
};
check_config(
&format!(
r#"{{"read-only": true, "immutable": true, "command": "{}"}}"#,
&filename.display()
),
None,
CmdMin::empty(),
);
check_config(
&format!(
r#"{{"read-only": true, "immutable": false, "command": "{}"}}"#,
&filename.display()
),
None,
CmdMin::empty(),
);
check_config(
&format!(
r#"{{"read-only": false, "immutable": true, "command": "{}"}}"#,
&filename.display()
),
None,
CmdMin::empty(),
);
check_config(
&format!(
r#"{{"read-only": false, "immutable": false, "command": "{}"}}"#,
&filename.display()
),
PathBuf::from(&filename).canonicalize().ok(),
CmdMin::MATCH,
);
set_read_only(filename.as_path()).unwrap();
check_config(
&format!(
r#"{{"read-only": true, "immutable": false, "command": "{}"}}"#,
&filename.display()
),
PathBuf::from(&filename).canonicalize().ok(),
CmdMin::MATCH,
);
check_config(
&format!(
r#"{{"read-only": true, "immutable": true, "command": "{}"}}"#,
&filename.display()
),
None,
CmdMin::empty(),
);
check_config(
&format!(
r#"{{"read-only": true, "immutable": true, "command": "{}"}}"#,
&filename.display()
),
None,
CmdMin::empty(),
);
let immutable = if has_privileges(&[Cap::LINUX_IMMUTABLE]).is_ok_and(|b| b) {
toggle_immutable_config(&filename, false).unwrap();
true
} else {
debug!("No privileges to set immutable flag, skipping immutable tests");
false
};
check_config(
&format!(
r#"{{"read-only": true, "immutable": {}, "command": "{}"}}"#,
immutable,
&filename.display()
),
PathBuf::from(&filename).canonicalize().ok(),
CmdMin::MATCH,
);
if immutable {
check_config(
&format!(
r#"{{"read-only": true, "immutable": true, "command": "{}"}}"#,
&filename.display()
),
PathBuf::from(&filename).canonicalize().ok(),
CmdMin::MATCH,
);
} else {
check_config(
&format!(
r#"{{"read-only": true, "immutable": true, "command": "{}"}}"#,
&filename.display()
),
None,
CmdMin::empty(),
);
}
}
fn toggle_immutable_config(path: &impl AsRef<Path>, lock: bool) -> io::Result<()> {
let file = File::open(path)?;
let mut val = 0;
if unsafe { nix::libc::ioctl(file.as_raw_fd(), FS_IOC_GETFLAGS, &mut val) } < 0 {
return Err(std::io::Error::last_os_error());
}
if lock {
val |= FS_IMMUTABLE_FL;
} else {
val &= !(FS_IMMUTABLE_FL);
}
immutable_required_privileges(&file, || {
if unsafe { nix::libc::ioctl(file.as_raw_fd(), FS_IOC_SETFLAGS, &mut val) } < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
})
}
}