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}