#![forbid(unsafe_code)]
use forensicnomicon::report::{Category, Finding, Observation, Severity, Source, SubjectRef};
use prefetch_core::{PrefetchError, PrefetchInfo};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutionRecord {
pub executable: String,
pub run_count: u32,
pub last_run_filetimes: Vec<i64>,
pub image_path: Option<String>,
pub volume_serial: Option<u32>,
pub loaded_file_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrefetchAnomaly {
SystemBinaryRelocated {
name: String,
image_path: String,
},
SuspiciousExecutionPath {
executable: String,
image_path: String,
},
}
#[must_use]
pub fn execution_record(info: &PrefetchInfo) -> ExecutionRecord {
ExecutionRecord {
executable: info.executable.clone(),
run_count: info.run_count,
last_run_filetimes: info.last_run_times.clone(),
image_path: image_path_of(info),
volume_serial: info.volumes.first().map(|v| v.serial),
loaded_file_count: info.filenames.len(),
}
}
fn image_path_of(info: &PrefetchInfo) -> Option<String> {
let exe = info.executable.to_uppercase();
info.filenames
.iter()
.find(|f| f.to_uppercase().ends_with(&exe))
.cloned()
}
#[must_use]
pub fn audit(info: &PrefetchInfo) -> Vec<PrefetchAnomaly> {
let mut out = Vec::new();
let Some(image_path) = image_path_of(info) else {
return out;
};
let upper = image_path.to_uppercase();
let in_system32 = upper.contains(r"\SYSTEM32\") || upper.contains(r"\SYSWOW64\");
if forensicnomicon::processes::is_system32_binary(&info.executable) && !in_system32 {
out.push(PrefetchAnomaly::SystemBinaryRelocated {
name: info.executable.to_uppercase(),
image_path: image_path.clone(),
});
}
if forensicnomicon::heuristics::paths::is_suspicious_exec_path(&image_path) {
out.push(PrefetchAnomaly::SuspiciousExecutionPath {
executable: info.executable.clone(),
image_path,
});
}
out
}
pub fn audit_bytes(
file_bytes: &[u8],
) -> Result<(ExecutionRecord, Vec<PrefetchAnomaly>), PrefetchError> {
let info = prefetch_core::parse(file_bytes)?;
Ok((execution_record(&info), audit(&info)))
}
impl Observation for PrefetchAnomaly {
fn severity(&self) -> Option<Severity> {
Some(match self {
PrefetchAnomaly::SystemBinaryRelocated { .. } => Severity::High,
PrefetchAnomaly::SuspiciousExecutionPath { .. } => Severity::Medium,
})
}
fn category(&self) -> Category {
match self {
PrefetchAnomaly::SystemBinaryRelocated { .. } => Category::Concealment,
PrefetchAnomaly::SuspiciousExecutionPath { .. } => Category::Threat,
}
}
fn code(&self) -> &'static str {
match self {
PrefetchAnomaly::SystemBinaryRelocated { .. } => "PREFETCH-SYSTEM-BINARY-RELOCATED",
PrefetchAnomaly::SuspiciousExecutionPath { .. } => "PREFETCH-SUSPICIOUS-EXEC-PATH",
}
}
fn note(&self) -> String {
match self {
PrefetchAnomaly::SystemBinaryRelocated { name, image_path } => format!(
"{name} is a Windows system binary, but prefetch traced its image load \
from {image_path} — consistent with masquerading."
),
PrefetchAnomaly::SuspiciousExecutionPath {
executable,
image_path,
} => format!(
"{executable} executed from {image_path}, a directory commonly used to \
stage malware — consistent with suspicious execution."
),
}
}
fn mitre(&self) -> &'static [&'static str] {
match self {
PrefetchAnomaly::SystemBinaryRelocated { .. } => &["T1036.005"],
PrefetchAnomaly::SuspiciousExecutionPath { .. } => &["T1204"],
}
}
fn subjects(&self) -> Vec<SubjectRef> {
let (name, path) = match self {
PrefetchAnomaly::SystemBinaryRelocated { name, image_path } => (name, image_path),
PrefetchAnomaly::SuspiciousExecutionPath {
executable,
image_path,
} => (executable, image_path),
};
vec![SubjectRef {
scheme: "filesystem".to_string(),
kind: "executable".to_string(),
id: path.clone(),
label: Some(name.clone()),
}]
}
}
#[must_use]
pub fn to_finding(anomaly: &PrefetchAnomaly, scope: impl Into<String>) -> Finding {
anomaly.to_finding(Source {
analyzer: "prefetch-forensic".to_string(),
scope: scope.into(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const COREUPDATER: &[u8] = include_bytes!("../../tests/data/COREUPDATER.EXE-157C54BB.pf");
#[test]
fn coreupdater_yields_execution_evidence_and_no_fp() {
let (rec, anomalies) = audit_bytes(COREUPDATER).unwrap();
assert_eq!(rec.executable, "COREUPDATER.EXE");
assert_eq!(rec.run_count, 1);
assert_eq!(rec.last_run_filetimes, vec![132_449_604_494_103_203]);
assert_eq!(rec.volume_serial, Some(0xB0E0_E8FF));
assert_eq!(rec.loaded_file_count, 51);
assert!(rec
.image_path
.unwrap()
.ends_with(r"\SYSTEM32\COREUPDATER.EXE"));
assert!(anomalies.is_empty());
}
fn info_with(exe: &str, image_path: &str) -> PrefetchInfo {
PrefetchInfo {
version: 30,
executable: exe.to_string(),
run_count: 2,
last_run_times: vec![1],
volumes: Vec::new(),
filenames: vec![image_path.to_string()],
}
}
#[test]
fn masqueraded_system_binary_is_high() {
let info = info_with("SVCHOST.EXE", r"\VOLUME{x}\WINDOWS\TEMP\SVCHOST.EXE");
let anomalies = audit(&info);
assert!(anomalies
.iter()
.any(|a| matches!(a, PrefetchAnomaly::SystemBinaryRelocated { .. })));
let f = to_finding(
anomalies
.iter()
.find(|a| matches!(a, PrefetchAnomaly::SystemBinaryRelocated { .. }))
.unwrap(),
"Desktop",
);
assert_eq!(f.severity, Some(Severity::High));
assert_eq!(f.code, "PREFETCH-SYSTEM-BINARY-RELOCATED");
assert_eq!(f.category, Category::Concealment);
}
#[test]
fn legit_system_binary_in_system32_is_clean() {
let info = info_with("SVCHOST.EXE", r"\VOLUME{x}\WINDOWS\SYSTEM32\SVCHOST.EXE");
assert!(audit(&info).is_empty());
}
#[test]
fn execution_from_downloads_is_medium_threat() {
let info = info_with("INVOICE.EXE", r"\VOLUME{x}\USERS\BOB\DOWNLOADS\INVOICE.EXE");
let anomalies = audit(&info);
let a = anomalies
.iter()
.find(|a| matches!(a, PrefetchAnomaly::SuspiciousExecutionPath { .. }))
.expect("downloads path should be flagged");
let f = to_finding(a, "Desktop");
assert_eq!(f.severity, Some(Severity::Medium));
assert_eq!(f.category, Category::Threat);
assert_eq!(f.code, "PREFETCH-SUSPICIOUS-EXEC-PATH");
assert!(f.note.contains("INVOICE.EXE"));
}
#[test]
fn no_image_path_yields_no_anomaly() {
let info = info_with("FOO.EXE", r"\VOLUME{x}\WINDOWS\SYSTEM32\NTDLL.DLL");
assert!(audit(&info).is_empty());
}
}