Skip to main content

winreg_artifacts/
svc_diff.rs

1//! Windows service anomaly detector (`svc_diff`).
2//!
3//! Reads service configurations from the SYSTEM hive at
4//! `SYSTEM\CurrentControlSet\Services` and classifies each service entry
5//! for forensic anomalies such as suspicious image paths, missing descriptions,
6//! or unusual start types.
7//!
8//! Maps to MITRE ATT&CK T1543.003 (Create or Modify System Process:
9//! Windows Service).
10
11use std::io::Cursor;
12
13use winreg_core::hive::Hive;
14
15// ── Key path ──────────────────────────────────────────────────────────────────
16
17const SERVICES_KEY: &str = "CurrentControlSet\\Services";
18
19// ── Output type ───────────────────────────────────────────────────────────────
20
21/// A single service entry extracted from the SYSTEM registry hive.
22#[derive(Debug, Clone, serde::Serialize)]
23pub struct ServiceEntry {
24    /// Subkey name (the internal service name, e.g. `"Dnscache"`).
25    pub name: String,
26    /// Human-readable display name (`DisplayName` value).
27    pub display_name: String,
28    /// Path to the service binary (`ImagePath` value).
29    pub image_path: String,
30    /// Numeric start type: 0=Boot, 1=System, 2=Auto, 3=Manual, 4=Disabled.
31    pub start_type: u32,
32    /// Numeric service type: 1=KernelDriver, 2=FsDriver, 16=OwnProcess, 32=ShareProcess.
33    pub service_type: u32,
34    /// Account the service runs as (`ObjectName` value), e.g. `"LocalSystem"`.
35    pub object_name: String,
36    /// Service description (`Description` value); empty string when absent.
37    pub description: String,
38    /// `true` when the service matches one or more anomaly patterns.
39    pub is_suspicious: bool,
40    /// Human-readable explanation when `is_suspicious` is `true`.
41    pub suspicious_reason: Option<String>,
42}
43
44// ── Classification ────────────────────────────────────────────────────────────
45
46/// Classify a service entry for forensic anomalies.
47///
48/// Returns `(is_suspicious, reason)`.
49///
50/// A service is suspicious when **any** of the following is true:
51///
52/// 1. `image_path` contains `\temp\`, `\appdata\`, `\users\public\`, or
53///    `\programdata\` (user-writable directories, not system paths).
54/// 2. `image_path` contains `cmd.exe`, `powershell.exe`, `wscript.exe`, or
55///    `mshta.exe` (interpreters abused for living-off-the-land persistence).
56/// 3. `start_type == 2` (Auto) AND `description` is empty AND `image_path`
57///    does not contain `\system32\` or `\syswow64\`.
58/// 4. `object_name` is empty (service has no configured account).
59pub fn classify_service(
60    image_path: &str,
61    start_type: u32,
62    description: &str,
63    object_name: &str,
64) -> (bool, Option<String>) {
65    let lower = image_path.to_ascii_lowercase();
66
67    // Rule 1: user-writable path
68    for suspect_dir in &[r"\temp\", r"\appdata\", r"\users\public\", r"\programdata\"] {
69        if lower.contains(suspect_dir) {
70            return (
71                true,
72                Some(format!(
73                    "image path is in user-writable directory: {suspect_dir}"
74                )),
75            );
76        }
77    }
78
79    // Rule 2: interpreter abuse
80    for interpreter in &["cmd.exe", "powershell.exe", "wscript.exe", "mshta.exe"] {
81        if lower.contains(interpreter) {
82            return (
83                true,
84                Some(format!("image path contains interpreter: {interpreter}")),
85            );
86        }
87    }
88
89    // Rule 3: Auto-start with no description and non-system32 path
90    if start_type == 2
91        && description.is_empty()
92        && !lower.contains(r"\system32\")
93        && !lower.contains(r"\syswow64\")
94    {
95        return (
96            true,
97            Some(
98                "auto-start service has no description and image path is not under \\system32\\ or \\syswow64\\"
99                    .to_string(),
100            ),
101        );
102    }
103
104    // Rule 4: no configured account
105    if object_name.is_empty() {
106        return (
107            true,
108            Some("service has no configured account (ObjectName is empty)".to_string()),
109        );
110    }
111
112    (false, None)
113}
114
115// ── Public parse function ─────────────────────────────────────────────────────
116
117/// Extract all service entries from a SYSTEM hive.
118///
119/// Walks `SYSTEM\CurrentControlSet\Services`, enumerates every direct subkey,
120/// extracts relevant values (with safe defaults for missing values), classifies
121/// each entry, and returns the full list (both suspicious and benign).
122pub fn parse(hive: &Hive<Cursor<Vec<u8>>>) -> Vec<ServiceEntry> {
123    let services_key = match hive.open_key(SERVICES_KEY) {
124        Ok(Some(k)) => k,
125        _ => return Vec::new(),
126    };
127
128    let subkeys = match services_key.subkeys() {
129        Ok(k) => k,
130        Err(_) => return Vec::new(),
131    };
132
133    let mut entries = Vec::with_capacity(subkeys.len());
134
135    for svc_key in subkeys {
136        let name = svc_key.name();
137
138        // Read values with safe defaults.
139        let image_path = svc_key
140            .value("ImagePath")
141            .ok()
142            .flatten()
143            .and_then(|v| v.as_string().ok())
144            .unwrap_or_default();
145
146        let display_name = svc_key
147            .value("DisplayName")
148            .ok()
149            .flatten()
150            .and_then(|v| v.as_string().ok())
151            .unwrap_or_default();
152
153        let description = svc_key
154            .value("Description")
155            .ok()
156            .flatten()
157            .and_then(|v| v.as_string().ok())
158            .unwrap_or_default();
159
160        let start_type = svc_key
161            .value("Start")
162            .ok()
163            .flatten()
164            .and_then(|v| v.as_u32().ok())
165            .unwrap_or(3); // default: Manual
166
167        let service_type = svc_key
168            .value("Type")
169            .ok()
170            .flatten()
171            .and_then(|v| v.as_u32().ok())
172            .unwrap_or(0);
173
174        let object_name = svc_key
175            .value("ObjectName")
176            .ok()
177            .flatten()
178            .and_then(|v| v.as_string().ok())
179            .unwrap_or_default();
180
181        let (is_suspicious, suspicious_reason) =
182            classify_service(&image_path, start_type, &description, &object_name);
183
184        entries.push(ServiceEntry {
185            name,
186            display_name,
187            image_path,
188            start_type,
189            service_type,
190            object_name,
191            description,
192            is_suspicious,
193            suspicious_reason,
194        });
195    }
196
197    entries
198}