Skip to main content

prefetch_forensic/
lib.rs

1//! Windows **Prefetch** forensic analyzer.
2//!
3//! Prefetch's primary forensic value is **execution evidence**: it proves a
4//! program ran, how many times, when (the last eight runs), from where, and what
5//! it loaded. [`execution_record`] extracts that evidence; [`audit`] adds a small
6//! set of *high-precision* graded findings — a Windows system-binary name loaded
7//! from outside `System32` (masquerading) and execution from a known-suspicious
8//! directory.
9//!
10//! Findings are observations, never verdicts: prefetch establishes that
11//! `coreupdater.exe` ran from `System32` at a given time — whether that is
12//! malicious is a correlation/tribunal question, not one prefetch answers alone.
13//!
14//! Built on [`prefetch_core`]; findings use [`forensicnomicon::report`].
15
16#![forbid(unsafe_code)]
17
18use forensicnomicon::report::{Category, Finding, Observation, Severity, Source, SubjectRef};
19use prefetch_core::{PrefetchError, PrefetchInfo};
20
21/// The execution evidence a single prefetch file establishes.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ExecutionRecord {
24    /// Executable base name (as Windows recorded it, upper-cased).
25    pub executable: String,
26    /// Number of recorded executions.
27    pub run_count: u32,
28    /// Up to eight most-recent run times, as raw Windows `FILETIME` values.
29    pub last_run_filetimes: Vec<i64>,
30    /// The executable's own on-disk path (the loaded file whose name matches the
31    /// executable), if present in the loaded-file list.
32    pub image_path: Option<String>,
33    /// Serial of the first referenced volume, if any.
34    pub volume_serial: Option<u32>,
35    /// Number of files loaded during the traced runs.
36    pub loaded_file_count: usize,
37}
38
39/// A graded prefetch finding. Each variant is a *high-precision* triage signal —
40/// it stays quiet on benign prefetch (e.g. a normal `System32` program) and fires
41/// only on a genuinely anomalous pattern.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum PrefetchAnomaly {
44    /// A Windows system-binary *name* whose traced image path is not under
45    /// `System32`/`SysWOW64` — consistent with masquerading (`T1036.005`).
46    SystemBinaryRelocated {
47        /// The system-binary base name (e.g. `SVCHOST.EXE`).
48        name: String,
49        /// Where it was actually loaded from.
50        image_path: String,
51    },
52    /// The program executed from a directory that is a common staging ground for
53    /// malware (Temp, Downloads, `$Recycle.Bin`, …) — `T1204`.
54    SuspiciousExecutionPath {
55        /// Executable base name.
56        executable: String,
57        /// The suspicious load path.
58        image_path: String,
59    },
60}
61
62/// Windows binaries that legitimately run only from `System32` / `SysWOW64`.
63/// A copy of any of these under another path is the classic masquerade. (Source:
64/// MITRE T1036.005; the DFIR "system-binary" baseline.)
65const 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
81/// Directory fragments that are common malware staging grounds — the DFIR
82/// "execution from an unusual location" triage rule (SANS/13Cubed). Matched
83/// case-insensitively as a substring of the load path.
84const 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/// Extract the execution evidence from parsed prefetch info.
95#[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
107/// The executable's own load path: the loaded file whose name ends with the
108/// executable's base name.
109fn 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/// Audit parsed prefetch info for graded anomalies (may be empty — benign
118/// prefetch yields no findings).
119#[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
145/// Parse and audit a prefetch file (`MAM`-compressed or raw `SCCA`) in one call:
146/// returns the execution evidence and any graded anomalies. This is the headline
147/// entry point.
148pub 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/// Convenience: produce a [`Finding`] for an anomaly under the given scope.
218#[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    /// Real malware prefetch: the execution evidence is recovered, and — because
235    /// coreupdater ran from System32 under a novel name — NO false-positive
236    /// anomaly fires. (Its maliciousness is a correlation finding, not prefetch's.)
237    #[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        // System32 + a novel name must not raise an anomaly (high precision).
250        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        // Both a relocated system binary AND a suspicious dir (\TEMP\).
269        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        // Loaded-file list without the executable itself → nothing to locate.
308        let info = info_with("FOO.EXE", r"\VOLUME{x}\WINDOWS\SYSTEM32\NTDLL.DLL");
309        assert!(audit(&info).is_empty());
310    }
311}