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/// Extract the execution evidence from parsed prefetch info.
63#[must_use]
64pub fn execution_record(info: &PrefetchInfo) -> ExecutionRecord {
65    ExecutionRecord {
66        executable: info.executable.clone(),
67        run_count: info.run_count,
68        last_run_filetimes: info.last_run_times.clone(),
69        image_path: image_path_of(info),
70        volume_serial: info.volumes.first().map(|v| v.serial),
71        loaded_file_count: info.filenames.len(),
72    }
73}
74
75/// The executable's own load path: the loaded file whose name ends with the
76/// executable's base name.
77fn image_path_of(info: &PrefetchInfo) -> Option<String> {
78    let exe = info.executable.to_uppercase();
79    info.filenames
80        .iter()
81        .find(|f| f.to_uppercase().ends_with(&exe))
82        .cloned()
83}
84
85/// Audit parsed prefetch info for graded anomalies (may be empty — benign
86/// prefetch yields no findings).
87#[must_use]
88pub fn audit(info: &PrefetchInfo) -> Vec<PrefetchAnomaly> {
89    let mut out = Vec::new();
90    let Some(image_path) = image_path_of(info) else {
91        return out;
92    };
93    let upper = image_path.to_uppercase();
94
95    // System-binary baseline and suspicious-location list are shared DFIR
96    // knowledge — they live in forensicnomicon, not baked in here.
97    let in_system32 = upper.contains(r"\SYSTEM32\") || upper.contains(r"\SYSWOW64\");
98    if forensicnomicon::processes::is_system32_binary(&info.executable) && !in_system32 {
99        out.push(PrefetchAnomaly::SystemBinaryRelocated {
100            name: info.executable.to_uppercase(),
101            image_path: image_path.clone(),
102        });
103    }
104
105    if forensicnomicon::heuristics::paths::is_suspicious_exec_path(&image_path) {
106        out.push(PrefetchAnomaly::SuspiciousExecutionPath {
107            executable: info.executable.clone(),
108            image_path,
109        });
110    }
111    out
112}
113
114/// Parse and audit a prefetch file (`MAM`-compressed or raw `SCCA`) in one call:
115/// returns the execution evidence and any graded anomalies. This is the headline
116/// entry point.
117pub fn audit_bytes(
118    file_bytes: &[u8],
119) -> Result<(ExecutionRecord, Vec<PrefetchAnomaly>), PrefetchError> {
120    let info = prefetch_core::parse(file_bytes)?;
121    Ok((execution_record(&info), audit(&info)))
122}
123
124impl Observation for PrefetchAnomaly {
125    fn severity(&self) -> Option<Severity> {
126        Some(match self {
127            PrefetchAnomaly::SystemBinaryRelocated { .. } => Severity::High,
128            PrefetchAnomaly::SuspiciousExecutionPath { .. } => Severity::Medium,
129        })
130    }
131
132    fn category(&self) -> Category {
133        match self {
134            PrefetchAnomaly::SystemBinaryRelocated { .. } => Category::Concealment,
135            PrefetchAnomaly::SuspiciousExecutionPath { .. } => Category::Threat,
136        }
137    }
138
139    fn code(&self) -> &'static str {
140        match self {
141            PrefetchAnomaly::SystemBinaryRelocated { .. } => "PREFETCH-SYSTEM-BINARY-RELOCATED",
142            PrefetchAnomaly::SuspiciousExecutionPath { .. } => "PREFETCH-SUSPICIOUS-EXEC-PATH",
143        }
144    }
145
146    fn note(&self) -> String {
147        match self {
148            PrefetchAnomaly::SystemBinaryRelocated { name, image_path } => format!(
149                "{name} is a Windows system binary, but prefetch traced its image load \
150                 from {image_path} — consistent with masquerading."
151            ),
152            PrefetchAnomaly::SuspiciousExecutionPath {
153                executable,
154                image_path,
155            } => format!(
156                "{executable} executed from {image_path}, a directory commonly used to \
157                 stage malware — consistent with suspicious execution."
158            ),
159        }
160    }
161
162    fn mitre(&self) -> &'static [&'static str] {
163        match self {
164            PrefetchAnomaly::SystemBinaryRelocated { .. } => &["T1036.005"],
165            PrefetchAnomaly::SuspiciousExecutionPath { .. } => &["T1204"],
166        }
167    }
168
169    fn subjects(&self) -> Vec<SubjectRef> {
170        let (name, path) = match self {
171            PrefetchAnomaly::SystemBinaryRelocated { name, image_path } => (name, image_path),
172            PrefetchAnomaly::SuspiciousExecutionPath {
173                executable,
174                image_path,
175            } => (executable, image_path),
176        };
177        vec![SubjectRef {
178            scheme: "filesystem".to_string(),
179            kind: "executable".to_string(),
180            id: path.clone(),
181            label: Some(name.clone()),
182        }]
183    }
184}
185
186/// Convenience: produce a [`Finding`] for an anomaly under the given scope.
187#[must_use]
188pub fn to_finding(anomaly: &PrefetchAnomaly, scope: impl Into<String>) -> Finding {
189    anomaly.to_finding(Source {
190        analyzer: "prefetch-forensic".to_string(),
191        scope: scope.into(),
192        version: Some(env!("CARGO_PKG_VERSION").to_string()),
193    })
194}
195
196#[cfg(test)]
197#[allow(clippy::unwrap_used, clippy::expect_used)]
198mod tests {
199    use super::*;
200
201    const COREUPDATER: &[u8] = include_bytes!("../../tests/data/COREUPDATER.EXE-157C54BB.pf");
202
203    /// Real malware prefetch: the execution evidence is recovered, and — because
204    /// coreupdater ran from System32 under a novel name — NO false-positive
205    /// anomaly fires. (Its maliciousness is a correlation finding, not prefetch's.)
206    #[test]
207    fn coreupdater_yields_execution_evidence_and_no_fp() {
208        let (rec, anomalies) = audit_bytes(COREUPDATER).unwrap();
209        assert_eq!(rec.executable, "COREUPDATER.EXE");
210        assert_eq!(rec.run_count, 1);
211        assert_eq!(rec.last_run_filetimes, vec![132_449_604_494_103_203]);
212        assert_eq!(rec.volume_serial, Some(0xB0E0_E8FF));
213        assert_eq!(rec.loaded_file_count, 51);
214        assert!(rec
215            .image_path
216            .unwrap()
217            .ends_with(r"\SYSTEM32\COREUPDATER.EXE"));
218        // System32 + a novel name must not raise an anomaly (high precision).
219        assert!(anomalies.is_empty());
220    }
221
222    fn info_with(exe: &str, image_path: &str) -> PrefetchInfo {
223        PrefetchInfo {
224            version: 30,
225            executable: exe.to_string(),
226            run_count: 2,
227            last_run_times: vec![1],
228            volumes: Vec::new(),
229            filenames: vec![image_path.to_string()],
230        }
231    }
232
233    #[test]
234    fn masqueraded_system_binary_is_high() {
235        let info = info_with("SVCHOST.EXE", r"\VOLUME{x}\WINDOWS\TEMP\SVCHOST.EXE");
236        let anomalies = audit(&info);
237        // Both a relocated system binary AND a suspicious dir (\TEMP\).
238        assert!(anomalies
239            .iter()
240            .any(|a| matches!(a, PrefetchAnomaly::SystemBinaryRelocated { .. })));
241        let f = to_finding(
242            anomalies
243                .iter()
244                .find(|a| matches!(a, PrefetchAnomaly::SystemBinaryRelocated { .. }))
245                .unwrap(),
246            "Desktop",
247        );
248        assert_eq!(f.severity, Some(Severity::High));
249        assert_eq!(f.code, "PREFETCH-SYSTEM-BINARY-RELOCATED");
250        assert_eq!(f.category, Category::Concealment);
251    }
252
253    #[test]
254    fn legit_system_binary_in_system32_is_clean() {
255        let info = info_with("SVCHOST.EXE", r"\VOLUME{x}\WINDOWS\SYSTEM32\SVCHOST.EXE");
256        assert!(audit(&info).is_empty());
257    }
258
259    #[test]
260    fn execution_from_downloads_is_medium_threat() {
261        let info = info_with("INVOICE.EXE", r"\VOLUME{x}\USERS\BOB\DOWNLOADS\INVOICE.EXE");
262        let anomalies = audit(&info);
263        let a = anomalies
264            .iter()
265            .find(|a| matches!(a, PrefetchAnomaly::SuspiciousExecutionPath { .. }))
266            .expect("downloads path should be flagged");
267        let f = to_finding(a, "Desktop");
268        assert_eq!(f.severity, Some(Severity::Medium));
269        assert_eq!(f.category, Category::Threat);
270        assert_eq!(f.code, "PREFETCH-SUSPICIOUS-EXEC-PATH");
271        assert!(f.note.contains("INVOICE.EXE"));
272    }
273
274    #[test]
275    fn no_image_path_yields_no_anomaly() {
276        // Loaded-file list without the executable itself → nothing to locate.
277        let info = info_with("FOO.EXE", r"\VOLUME{x}\WINDOWS\SYSTEM32\NTDLL.DLL");
278        assert!(audit(&info).is_empty());
279    }
280}