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    /// The DLL a shared-process / svchost-hosted service loads, read from
31    /// `Parameters\ServiceDll`. `None` when the service hosts no DLL. Kept raw
32    /// (`REG_EXPAND_SZ` env vars like `%SystemRoot%` are NOT pre-expanded),
33    /// matching how `image_path` is handled. This is the actual code a
34    /// svchost-hosted service runs — and a common persistence vector (T1543.003).
35    pub service_dll: Option<String>,
36    /// A recovery-action command line (`FailureCommand` value) run when the
37    /// service fails — abused for persistence. `None` when absent.
38    pub failure_command: Option<String>,
39    /// Numeric start type: 0=Boot, 1=System, 2=Auto, 3=Manual, 4=Disabled.
40    pub start_type: u32,
41    /// Numeric service type: 1=KernelDriver, 2=FsDriver, 16=OwnProcess, 32=ShareProcess.
42    pub service_type: u32,
43    /// Account the service runs as (`ObjectName` value), e.g. `"LocalSystem"`.
44    pub object_name: String,
45    /// Service description (`Description` value); empty string when absent.
46    pub description: String,
47    /// `true` when the service matches one or more anomaly patterns.
48    pub is_suspicious: bool,
49    /// Human-readable explanation when `is_suspicious` is `true`.
50    pub suspicious_reason: Option<String>,
51    /// The service key's `LastWriteTime` — approximately the service
52    /// install/modify time. `None` when the key carries no timestamp.
53    pub last_written: Option<chrono::DateTime<chrono::Utc>>,
54}
55
56// ── Classification ────────────────────────────────────────────────────────────
57
58/// User-writable directories a system service binary/DLL should never live in.
59const USER_WRITABLE_DIRS: &[&str] = &[r"\temp\", r"\appdata\", r"\users\public\", r"\programdata\"];
60/// Interpreters/LOLBins abused for living-off-the-land persistence.
61const INTERPRETERS: &[&str] = &["cmd.exe", "powershell.exe", "wscript.exe", "mshta.exe"];
62
63/// Flag a service-loadable path (an `ImagePath` or a `ServiceDll`) that lives in
64/// a user-writable directory or names a known interpreter/LOLBin. `label` names
65/// which field is being checked so the reason is self-describing. Returns the
66/// reason string for the first matching rule, else `None`.
67fn classify_path(path_lower: &str, label: &str) -> Option<String> {
68    for suspect_dir in USER_WRITABLE_DIRS {
69        if path_lower.contains(suspect_dir) {
70            return Some(format!(
71                "{label} is in user-writable directory: {suspect_dir}"
72            ));
73        }
74    }
75    for interpreter in INTERPRETERS {
76        if path_lower.contains(interpreter) {
77            return Some(format!("{label} contains interpreter: {interpreter}"));
78        }
79    }
80    None
81}
82
83/// Classify a service entry for forensic anomalies.
84///
85/// Returns `(is_suspicious, reason)`.
86///
87/// A service is suspicious when **any** of the following is true:
88///
89/// 1. `image_path` contains `\temp\`, `\appdata\`, `\users\public\`, or
90///    `\programdata\` (user-writable directories, not system paths).
91/// 2. `image_path` contains `cmd.exe`, `powershell.exe`, `wscript.exe`, or
92///    `mshta.exe` (interpreters abused for living-off-the-land persistence).
93/// 3. `service_dll` (the DLL a svchost-hosted service loads) matches rule 1 or 2
94///    — a malicious `ServiceDll` is at least as suspicious as a malicious
95///    `ImagePath`, and is the more common svchost-persistence vector (T1543.003).
96/// 4. `start_type == 2` (Auto) AND `description` is empty AND `image_path`
97///    does not contain `\system32\` or `\syswow64\`.
98/// 5. `object_name` is empty (service has no configured account).
99/// 6. `failure_command` is present and non-empty (a recovery-action command —
100///    abused for persistence; surfaced as noteworthy, never a verdict).
101pub fn classify_service(
102    image_path: &str,
103    start_type: u32,
104    description: &str,
105    object_name: &str,
106    service_dll: Option<&str>,
107    failure_command: Option<&str>,
108) -> (bool, Option<String>) {
109    let lower = image_path.to_ascii_lowercase();
110
111    // Rules 1 & 2: user-writable path / interpreter abuse in ImagePath.
112    if let Some(reason) = classify_path(&lower, "image path") {
113        return (true, Some(reason));
114    }
115
116    // Rule 3: same checks against the svchost-hosted ServiceDll.
117    if let Some(dll) = service_dll {
118        if let Some(reason) = classify_path(&dll.to_ascii_lowercase(), "ServiceDll") {
119            return (true, Some(reason));
120        }
121    }
122
123    // Rule 4: Auto-start with no description and non-system32 path
124    if start_type == 2
125        && description.is_empty()
126        && !lower.contains(r"\system32\")
127        && !lower.contains(r"\syswow64\")
128    {
129        return (
130            true,
131            Some(
132                "auto-start service has no description and image path is not under \\system32\\ or \\syswow64\\"
133                    .to_string(),
134            ),
135        );
136    }
137
138    // Rule 5: no configured account
139    if object_name.is_empty() {
140        return (
141            true,
142            Some("service has no configured account (ObjectName is empty)".to_string()),
143        );
144    }
145
146    // Rule 6: a configured FailureCommand recovery action.
147    if let Some(fc) = failure_command.filter(|fc| !fc.is_empty()) {
148        return (
149            true,
150            Some(format!(
151                "service has a FailureCommand recovery action: {fc}"
152            )),
153        );
154    }
155
156    (false, None)
157}
158
159// ── Public parse function ─────────────────────────────────────────────────────
160
161/// Extract all service entries from a SYSTEM hive.
162///
163/// Walks `SYSTEM\CurrentControlSet\Services`, enumerates every direct subkey,
164/// extracts relevant values (with safe defaults for missing values), classifies
165/// each entry, and returns the full list (both suspicious and benign).
166pub fn parse(hive: &Hive<Cursor<Vec<u8>>>) -> Vec<ServiceEntry> {
167    // `CurrentControlSet` is a volatile symlink the running kernel builds; it is
168    // absent from offline hives. Resolve `Select\Current` → `ControlSet00N`,
169    // falling back to `ControlSet001` then the (live-only) `CurrentControlSet`.
170    let active = hive
171        .open_key("Select")
172        .ok()
173        .flatten()
174        .and_then(|k| k.value("Current").ok().flatten())
175        .and_then(|v| v.as_u32().ok())
176        .unwrap_or(1);
177    let services_key = [
178        format!("ControlSet{active:03}\\Services"),
179        "ControlSet001\\Services".to_string(),
180        SERVICES_KEY.to_string(),
181    ]
182    .iter()
183    .find_map(|path| hive.open_key(path).ok().flatten());
184    let Some(services_key) = services_key else {
185        return Vec::new();
186    };
187
188    let Ok(subkeys) = services_key.subkeys() else {
189        return Vec::new(); // cov:unreachable: a Services key opened from a valid hive has a readable subkey list
190    };
191
192    let mut entries = Vec::with_capacity(subkeys.len());
193
194    for svc_key in subkeys {
195        let name = svc_key.name();
196
197        // Read values with safe defaults.
198        let image_path = svc_key
199            .value("ImagePath")
200            .ok()
201            .flatten()
202            .and_then(|v| v.as_string().ok())
203            .unwrap_or_default();
204
205        let display_name = svc_key
206            .value("DisplayName")
207            .ok()
208            .flatten()
209            .and_then(|v| v.as_string().ok())
210            .unwrap_or_default();
211
212        let description = svc_key
213            .value("Description")
214            .ok()
215            .flatten()
216            .and_then(|v| v.as_string().ok())
217            .unwrap_or_default();
218
219        let start_type = svc_key
220            .value("Start")
221            .ok()
222            .flatten()
223            .and_then(|v| v.as_u32().ok())
224            .unwrap_or(3); // default: Manual
225
226        let service_type = svc_key
227            .value("Type")
228            .ok()
229            .flatten()
230            .and_then(|v| v.as_u32().ok())
231            .unwrap_or(0);
232
233        let object_name = svc_key
234            .value("ObjectName")
235            .ok()
236            .flatten()
237            .and_then(|v| v.as_string().ok())
238            .unwrap_or_default();
239
240        // ServiceDll lives one level down, under the `Parameters` subkey — the
241        // real code a svchost-hosted (ShareProcess) service loads. Kept raw
242        // (REG_EXPAND_SZ env vars not pre-expanded), like ImagePath.
243        let service_dll = svc_key
244            .subkey("Parameters")
245            .ok()
246            .flatten()
247            .and_then(|p| p.value("ServiceDll").ok().flatten())
248            .and_then(|v| v.as_string().ok());
249
250        // FailureCommand is a recovery-action command line directly under the
251        // service key.
252        let failure_command = svc_key
253            .value("FailureCommand")
254            .ok()
255            .flatten()
256            .and_then(|v| v.as_string().ok());
257
258        let (is_suspicious, suspicious_reason) = classify_service(
259            &image_path,
260            start_type,
261            &description,
262            &object_name,
263            service_dll.as_deref(),
264            failure_command.as_deref(),
265        );
266
267        entries.push(ServiceEntry {
268            name,
269            display_name,
270            image_path,
271            service_dll,
272            failure_command,
273            start_type,
274            service_type,
275            object_name,
276            description,
277            is_suspicious,
278            suspicious_reason,
279            last_written: svc_key.last_written(),
280        });
281    }
282
283    entries
284}