use std::io::Cursor;
use winreg_core::hive::Hive;
const SERVICES_KEY: &str = "CurrentControlSet\\Services";
#[derive(Debug, Clone, serde::Serialize)]
pub struct ServiceEntry {
pub name: String,
pub display_name: String,
pub image_path: String,
pub service_dll: Option<String>,
pub failure_command: Option<String>,
pub start_type: u32,
pub service_type: u32,
pub object_name: String,
pub description: String,
pub is_suspicious: bool,
pub suspicious_reason: Option<String>,
pub last_written: Option<jiff::Timestamp>,
}
const USER_WRITABLE_DIRS: &[&str] = &[r"\temp\", r"\appdata\", r"\users\public\", r"\programdata\"];
const INTERPRETERS: &[&str] = &["cmd.exe", "powershell.exe", "wscript.exe", "mshta.exe"];
fn classify_path(path_lower: &str, label: &str) -> Option<String> {
for suspect_dir in USER_WRITABLE_DIRS {
if path_lower.contains(suspect_dir) {
return Some(format!(
"{label} is in user-writable directory: {suspect_dir}"
));
}
}
for interpreter in INTERPRETERS {
if path_lower.contains(interpreter) {
return Some(format!("{label} contains interpreter: {interpreter}"));
}
}
None
}
pub fn classify_service(
image_path: &str,
start_type: u32,
description: &str,
object_name: &str,
service_dll: Option<&str>,
failure_command: Option<&str>,
) -> (bool, Option<String>) {
let lower = image_path.to_ascii_lowercase();
if let Some(reason) = classify_path(&lower, "image path") {
return (true, Some(reason));
}
if let Some(dll) = service_dll {
if let Some(reason) = classify_path(&dll.to_ascii_lowercase(), "ServiceDll") {
return (true, Some(reason));
}
}
if start_type == 2
&& description.is_empty()
&& !lower.contains(r"\system32\")
&& !lower.contains(r"\syswow64\")
{
return (
true,
Some(
"auto-start service has no description and image path is not under \\system32\\ or \\syswow64\\"
.to_string(),
),
);
}
if object_name.is_empty() {
return (
true,
Some("service has no configured account (ObjectName is empty)".to_string()),
);
}
if let Some(fc) = failure_command.filter(|fc| !fc.is_empty()) {
return (
true,
Some(format!(
"service has a FailureCommand recovery action: {fc}"
)),
);
}
(false, None)
}
pub fn parse(hive: &Hive<Cursor<Vec<u8>>>) -> Vec<ServiceEntry> {
let active = hive
.open_key("Select")
.ok()
.flatten()
.and_then(|k| k.value("Current").ok().flatten())
.and_then(|v| v.as_u32().ok())
.unwrap_or(1);
let services_key = [
format!("ControlSet{active:03}\\Services"),
"ControlSet001\\Services".to_string(),
SERVICES_KEY.to_string(),
]
.iter()
.find_map(|path| hive.open_key(path).ok().flatten());
let Some(services_key) = services_key else {
return Vec::new();
};
let Ok(subkeys) = services_key.subkeys() else {
return Vec::new(); };
let mut entries = Vec::with_capacity(subkeys.len());
for svc_key in subkeys {
let name = svc_key.name();
let image_path = svc_key
.value("ImagePath")
.ok()
.flatten()
.and_then(|v| v.as_string().ok())
.unwrap_or_default();
let display_name = svc_key
.value("DisplayName")
.ok()
.flatten()
.and_then(|v| v.as_string().ok())
.unwrap_or_default();
let description = svc_key
.value("Description")
.ok()
.flatten()
.and_then(|v| v.as_string().ok())
.unwrap_or_default();
let start_type = svc_key
.value("Start")
.ok()
.flatten()
.and_then(|v| v.as_u32().ok())
.unwrap_or(3);
let service_type = svc_key
.value("Type")
.ok()
.flatten()
.and_then(|v| v.as_u32().ok())
.unwrap_or(0);
let object_name = svc_key
.value("ObjectName")
.ok()
.flatten()
.and_then(|v| v.as_string().ok())
.unwrap_or_default();
let service_dll = svc_key
.subkey("Parameters")
.ok()
.flatten()
.and_then(|p| p.value("ServiceDll").ok().flatten())
.and_then(|v| v.as_string().ok());
let failure_command = svc_key
.value("FailureCommand")
.ok()
.flatten()
.and_then(|v| v.as_string().ok());
let (is_suspicious, suspicious_reason) = classify_service(
&image_path,
start_type,
&description,
&object_name,
service_dll.as_deref(),
failure_command.as_deref(),
);
entries.push(ServiceEntry {
name,
display_name,
image_path,
service_dll,
failure_command,
start_type,
service_type,
object_name,
description,
is_suspicious,
suspicious_reason,
last_written: svc_key.last_written(),
});
}
entries
}