#![forbid(unsafe_code)]
use forensicnomicon::jumplist::appid_name;
use forensicnomicon::report::{Category, Finding, Observation, Severity, Source};
use lnk_core::{drive_type, JumpList, ShellLink};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LnkAnomaly {
RemovableMediaTarget {
drive_type: u32,
drive_serial: u32,
path: Option<String>,
},
NetworkTarget {
net_name: Option<String>,
},
TrackerMachine {
machine_id: String,
},
}
impl LnkAnomaly {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::RemovableMediaTarget { .. } => "LNK-REMOVABLE-MEDIA-TARGET",
Self::NetworkTarget { .. } => "LNK-NETWORK-TARGET",
Self::TrackerMachine { .. } => "LNK-TRACKER-MACHINE",
}
}
}
impl Observation for LnkAnomaly {
fn severity(&self) -> Option<Severity> {
Some(match self {
Self::RemovableMediaTarget { .. } => Severity::Medium,
Self::NetworkTarget { .. } => Severity::Low,
Self::TrackerMachine { .. } => Severity::Info,
})
}
fn code(&self) -> &'static str {
LnkAnomaly::code(self)
}
fn category(&self) -> Category {
match self {
Self::RemovableMediaTarget { .. } | Self::NetworkTarget { .. } => Category::Threat,
Self::TrackerMachine { .. } => Category::Provenance,
}
}
fn mitre(&self) -> &'static [&'static str] {
match self {
Self::RemovableMediaTarget { .. } => &["T1052.001", "T1091"],
Self::NetworkTarget { .. } => &["T1021"],
Self::TrackerMachine { .. } => &[],
}
}
fn note(&self) -> String {
match self {
Self::RemovableMediaTarget {
drive_type,
drive_serial,
path,
} => format!(
"the link target resolves to a removable/external volume \
(drive_type {drive_type}, drive serial {drive_serial:#010X}{}); consistent with a \
file opened from external media (MITRE T1052.001 / T1091). The volume serial is \
the join key to a peripheral device connection",
path.as_deref()
.map_or_else(String::new, |p| format!(", path {p:?}"))
),
Self::NetworkTarget { net_name } => format!(
"the link carries a network relative link{}; consistent with a file opened from a \
network share (MITRE T1021)",
net_name
.as_deref()
.map_or_else(String::new, |n| format!(" to {n}"))
),
Self::TrackerMachine { machine_id } => format!(
"the tracker block records the origin machine {machine_id:?}; consistent with the \
link having been authored on that machine (attribution)"
),
}
}
}
#[must_use]
pub fn audit(link: &ShellLink) -> Vec<LnkAnomaly> {
let mut out = Vec::new();
if let Some(info) = &link.link_info {
if let Some(vol) = &info.volume_id {
if is_removable_volume(vol.drive_type, vol.drive_serial_number) {
out.push(LnkAnomaly::RemovableMediaTarget {
drive_type: vol.drive_type,
drive_serial: vol.drive_serial_number,
path: info.local_base_path.clone(),
});
}
}
if let Some(cnrl) = &info.common_network_relative_link {
out.push(LnkAnomaly::NetworkTarget {
net_name: cnrl.net_name.clone(),
});
}
}
if let Some(tracker) = &link.tracker {
if !tracker.machine_id.is_empty() {
out.push(LnkAnomaly::TrackerMachine {
machine_id: tracker.machine_id.clone(),
});
}
}
out
}
fn is_removable_volume(drive_type: u32, _drive_serial: u32) -> bool {
drive_type == drive_type::REMOVABLE
}
#[must_use]
pub fn audit_findings(link: &ShellLink, scope: impl Into<String>) -> Vec<Finding> {
let src = source(scope);
audit(link)
.iter()
.map(|a| a.to_finding(src.clone()))
.collect()
}
#[must_use]
pub fn source(scope: impl Into<String>) -> Source {
Source {
analyzer: "lnk-forensic".to_string(),
scope: scope.into(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JumpListAnomaly {
PinnedTarget {
path: String,
},
CrossMachine {
hostname: String,
acquisition_host: String,
},
MruRecency {
path: String,
access_count: Option<u32>,
last_access: i64,
},
AppIdIdentified {
app_id: String,
application: &'static str,
},
}
impl JumpListAnomaly {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::PinnedTarget { .. } => "JUMPLIST-PINNED-TARGET",
Self::CrossMachine { .. } => "JUMPLIST-CROSS-MACHINE",
Self::MruRecency { .. } => "JUMPLIST-MRU-RECENCY",
Self::AppIdIdentified { .. } => "JUMPLIST-APPID-IDENTIFIED",
}
}
}
impl Observation for JumpListAnomaly {
fn severity(&self) -> Option<Severity> {
Some(match self {
Self::PinnedTarget { .. } | Self::CrossMachine { .. } => Severity::Low,
Self::MruRecency { .. } | Self::AppIdIdentified { .. } => Severity::Info,
})
}
fn code(&self) -> &'static str {
JumpListAnomaly::code(self)
}
fn category(&self) -> Category {
match self {
Self::MruRecency { .. } => Category::History,
Self::PinnedTarget { .. }
| Self::CrossMachine { .. }
| Self::AppIdIdentified { .. } => Category::Provenance,
}
}
fn note(&self) -> String {
match self {
Self::PinnedTarget { path } => format!(
"the Jump List entry for {path:?} is pinned; consistent with the user having \
deliberately fixed this target to the application's Jump List"
),
Self::CrossMachine {
hostname,
acquisition_host,
} => format!(
"the Jump List entry records origin hostname {hostname:?}, which has no match to \
the acquisition host {acquisition_host:?}; consistent with the target having been \
accessed from, or the artifact having originated on, a different machine"
),
Self::MruRecency {
path,
access_count,
last_access,
} => format!(
"the Jump List records MRU recency for {path:?} (access count {}, last access \
{last_access}); consistent with the application's own usage history for this \
target",
access_count.map_or_else(|| "unknown".to_string(), |c| c.to_string())
),
Self::AppIdIdentified {
app_id,
application,
} => format!(
"the Jump List AppID {app_id} is consistent with the application {application:?}"
),
}
}
}
#[must_use]
pub fn audit_jumplist(
jl: &JumpList,
acquisition_host: Option<&str>,
scope: impl Into<String>,
) -> Vec<Finding> {
let src = source(scope);
let mut out = Vec::new();
if let Some(app_id) = &jl.app_id {
if let Some(application) = appid_name(app_id) {
out.push(
JumpListAnomaly::AppIdIdentified {
app_id: app_id.clone(),
application,
}
.to_finding(src.clone()),
);
}
}
for entry in &jl.entries {
for anomaly in audit(&entry.link) {
out.push(anomaly.to_finding(src.clone()));
}
let Some(dl) = &entry.destlist else {
continue;
};
if dl.pinned {
out.push(
JumpListAnomaly::PinnedTarget {
path: dl.path.clone(),
}
.to_finding(src.clone()),
);
}
if let Some(host) = acquisition_host {
if !dl.hostname.is_empty() && !dl.hostname.eq_ignore_ascii_case(host) {
out.push(
JumpListAnomaly::CrossMachine {
hostname: dl.hostname.clone(),
acquisition_host: host.to_string(),
}
.to_finding(src.clone()),
);
}
}
if dl.access_count.is_some() || dl.last_access > 0 {
out.push(
JumpListAnomaly::MruRecency {
path: dl.path.clone(),
access_count: dl.access_count,
last_access: dl.last_access,
}
.to_finding(src.clone()),
);
}
}
out
}
#[cfg(test)]
mod tests {
include!("tests.rs");
}