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}