1#![forbid(unsafe_code)]
17
18use forensicnomicon::report::{Category, Finding, Observation, Severity, Source, SubjectRef};
19use prefetch_core::{PrefetchError, PrefetchInfo};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ExecutionRecord {
24 pub executable: String,
26 pub run_count: u32,
28 pub last_run_filetimes: Vec<i64>,
30 pub image_path: Option<String>,
33 pub volume_serial: Option<u32>,
35 pub loaded_file_count: usize,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum PrefetchAnomaly {
44 SystemBinaryRelocated {
47 name: String,
49 image_path: String,
51 },
52 SuspiciousExecutionPath {
55 executable: String,
57 image_path: String,
59 },
60}
61
62const SYSTEM32_BINARIES: &[&str] = &[
66 "SVCHOST.EXE",
67 "LSASS.EXE",
68 "SERVICES.EXE",
69 "CSRSS.EXE",
70 "SMSS.EXE",
71 "WININIT.EXE",
72 "WINLOGON.EXE",
73 "TASKHOSTW.EXE",
74 "DLLHOST.EXE",
75 "CONHOST.EXE",
76 "RUNDLL32.EXE",
77 "SPOOLSV.EXE",
78 "LSAISO.EXE",
79];
80
81const SUSPICIOUS_DIRS: &[&str] = &[
85 r"\TEMP\",
86 r"\WINDOWS\TEMP\",
87 r"\APPDATA\LOCAL\TEMP\",
88 r"\DOWNLOADS\",
89 r"\USERS\PUBLIC\",
90 r"\$RECYCLE.BIN\",
91 r"\PERFLOGS\",
92];
93
94#[must_use]
96pub fn execution_record(info: &PrefetchInfo) -> ExecutionRecord {
97 ExecutionRecord {
98 executable: info.executable.clone(),
99 run_count: info.run_count,
100 last_run_filetimes: info.last_run_times.clone(),
101 image_path: image_path_of(info),
102 volume_serial: info.volumes.first().map(|v| v.serial),
103 loaded_file_count: info.filenames.len(),
104 }
105}
106
107fn image_path_of(info: &PrefetchInfo) -> Option<String> {
110 let exe = info.executable.to_uppercase();
111 info.filenames
112 .iter()
113 .find(|f| f.to_uppercase().ends_with(&exe))
114 .cloned()
115}
116
117#[must_use]
120pub fn audit(info: &PrefetchInfo) -> Vec<PrefetchAnomaly> {
121 let mut out = Vec::new();
122 let Some(image_path) = image_path_of(info) else {
123 return out;
124 };
125 let upper = image_path.to_uppercase();
126 let name = info.executable.to_uppercase();
127
128 let in_system32 = upper.contains(r"\SYSTEM32\") || upper.contains(r"\SYSWOW64\");
129 if SYSTEM32_BINARIES.contains(&name.as_str()) && !in_system32 {
130 out.push(PrefetchAnomaly::SystemBinaryRelocated {
131 name,
132 image_path: image_path.clone(),
133 });
134 }
135
136 if SUSPICIOUS_DIRS.iter().any(|d| upper.contains(d)) {
137 out.push(PrefetchAnomaly::SuspiciousExecutionPath {
138 executable: info.executable.clone(),
139 image_path,
140 });
141 }
142 out
143}
144
145pub fn audit_bytes(
149 file_bytes: &[u8],
150) -> Result<(ExecutionRecord, Vec<PrefetchAnomaly>), PrefetchError> {
151 let info = prefetch_core::parse(file_bytes)?;
152 Ok((execution_record(&info), audit(&info)))
153}
154
155impl Observation for PrefetchAnomaly {
156 fn severity(&self) -> Option<Severity> {
157 Some(match self {
158 PrefetchAnomaly::SystemBinaryRelocated { .. } => Severity::High,
159 PrefetchAnomaly::SuspiciousExecutionPath { .. } => Severity::Medium,
160 })
161 }
162
163 fn category(&self) -> Category {
164 match self {
165 PrefetchAnomaly::SystemBinaryRelocated { .. } => Category::Concealment,
166 PrefetchAnomaly::SuspiciousExecutionPath { .. } => Category::Threat,
167 }
168 }
169
170 fn code(&self) -> &'static str {
171 match self {
172 PrefetchAnomaly::SystemBinaryRelocated { .. } => "PREFETCH-SYSTEM-BINARY-RELOCATED",
173 PrefetchAnomaly::SuspiciousExecutionPath { .. } => "PREFETCH-SUSPICIOUS-EXEC-PATH",
174 }
175 }
176
177 fn note(&self) -> String {
178 match self {
179 PrefetchAnomaly::SystemBinaryRelocated { name, image_path } => format!(
180 "{name} is a Windows system binary, but prefetch traced its image load \
181 from {image_path} — consistent with masquerading."
182 ),
183 PrefetchAnomaly::SuspiciousExecutionPath {
184 executable,
185 image_path,
186 } => format!(
187 "{executable} executed from {image_path}, a directory commonly used to \
188 stage malware — consistent with suspicious execution."
189 ),
190 }
191 }
192
193 fn mitre(&self) -> &'static [&'static str] {
194 match self {
195 PrefetchAnomaly::SystemBinaryRelocated { .. } => &["T1036.005"],
196 PrefetchAnomaly::SuspiciousExecutionPath { .. } => &["T1204"],
197 }
198 }
199
200 fn subjects(&self) -> Vec<SubjectRef> {
201 let (name, path) = match self {
202 PrefetchAnomaly::SystemBinaryRelocated { name, image_path } => (name, image_path),
203 PrefetchAnomaly::SuspiciousExecutionPath {
204 executable,
205 image_path,
206 } => (executable, image_path),
207 };
208 vec![SubjectRef {
209 scheme: "filesystem".to_string(),
210 kind: "executable".to_string(),
211 id: path.clone(),
212 label: Some(name.clone()),
213 }]
214 }
215}
216
217#[must_use]
219pub fn to_finding(anomaly: &PrefetchAnomaly, scope: impl Into<String>) -> Finding {
220 anomaly.to_finding(Source {
221 analyzer: "prefetch-forensic".to_string(),
222 scope: scope.into(),
223 version: Some(env!("CARGO_PKG_VERSION").to_string()),
224 })
225}
226
227#[cfg(test)]
228#[allow(clippy::unwrap_used, clippy::expect_used)]
229mod tests {
230 use super::*;
231
232 const COREUPDATER: &[u8] = include_bytes!("../../tests/data/COREUPDATER.EXE-157C54BB.pf");
233
234 #[test]
238 fn coreupdater_yields_execution_evidence_and_no_fp() {
239 let (rec, anomalies) = audit_bytes(COREUPDATER).unwrap();
240 assert_eq!(rec.executable, "COREUPDATER.EXE");
241 assert_eq!(rec.run_count, 1);
242 assert_eq!(rec.last_run_filetimes, vec![132_449_604_494_103_203]);
243 assert_eq!(rec.volume_serial, Some(0xB0E0_E8FF));
244 assert_eq!(rec.loaded_file_count, 51);
245 assert!(rec
246 .image_path
247 .unwrap()
248 .ends_with(r"\SYSTEM32\COREUPDATER.EXE"));
249 assert!(anomalies.is_empty());
251 }
252
253 fn info_with(exe: &str, image_path: &str) -> PrefetchInfo {
254 PrefetchInfo {
255 version: 30,
256 executable: exe.to_string(),
257 run_count: 2,
258 last_run_times: vec![1],
259 volumes: Vec::new(),
260 filenames: vec![image_path.to_string()],
261 }
262 }
263
264 #[test]
265 fn masqueraded_system_binary_is_high() {
266 let info = info_with("SVCHOST.EXE", r"\VOLUME{x}\WINDOWS\TEMP\SVCHOST.EXE");
267 let anomalies = audit(&info);
268 assert!(anomalies
270 .iter()
271 .any(|a| matches!(a, PrefetchAnomaly::SystemBinaryRelocated { .. })));
272 let f = to_finding(
273 anomalies
274 .iter()
275 .find(|a| matches!(a, PrefetchAnomaly::SystemBinaryRelocated { .. }))
276 .unwrap(),
277 "Desktop",
278 );
279 assert_eq!(f.severity, Some(Severity::High));
280 assert_eq!(f.code, "PREFETCH-SYSTEM-BINARY-RELOCATED");
281 assert_eq!(f.category, Category::Concealment);
282 }
283
284 #[test]
285 fn legit_system_binary_in_system32_is_clean() {
286 let info = info_with("SVCHOST.EXE", r"\VOLUME{x}\WINDOWS\SYSTEM32\SVCHOST.EXE");
287 assert!(audit(&info).is_empty());
288 }
289
290 #[test]
291 fn execution_from_downloads_is_medium_threat() {
292 let info = info_with("INVOICE.EXE", r"\VOLUME{x}\USERS\BOB\DOWNLOADS\INVOICE.EXE");
293 let anomalies = audit(&info);
294 let a = anomalies
295 .iter()
296 .find(|a| matches!(a, PrefetchAnomaly::SuspiciousExecutionPath { .. }))
297 .expect("downloads path should be flagged");
298 let f = to_finding(a, "Desktop");
299 assert_eq!(f.severity, Some(Severity::Medium));
300 assert_eq!(f.category, Category::Threat);
301 assert_eq!(f.code, "PREFETCH-SUSPICIOUS-EXEC-PATH");
302 assert!(f.note.contains("INVOICE.EXE"));
303 }
304
305 #[test]
306 fn no_image_path_yields_no_anomaly() {
307 let info = info_with("FOO.EXE", r"\VOLUME{x}\WINDOWS\SYSTEM32\NTDLL.DLL");
309 assert!(audit(&info).is_empty());
310 }
311}