use forensicnomicon::ntfs::{attr_types, SIGNATURE_BAAD, SIGNATURE_FILE};
use crate::attribute::Attribute;
use crate::file_name::FileName;
use crate::record::MftRecordHeader;
use crate::standard_information::StandardInformation;
use crate::time::Filetime;
const TICKS_PER_SECOND: u64 = 10_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TimestompIndicators {
pub si_created_before_fn: bool,
pub created_mismatch: bool,
pub si_whole_second: bool,
}
impl TimestompIndicators {
#[must_use]
pub fn is_suspicious(&self) -> bool {
self.si_created_before_fn || self.si_whole_second
}
}
#[must_use]
pub fn detect_timestomp(si: &StandardInformation, file_name: &FileName) -> TimestompIndicators {
TimestompIndicators {
si_created_before_fn: si.created.0 < file_name.created.0,
created_mismatch: si.created.0 != file_name.created.0,
si_whole_second: whole_second(si.created)
|| whole_second(si.modified)
|| whole_second(si.mft_modified)
|| whole_second(si.accessed),
}
}
fn whole_second(ft: Filetime) -> bool {
ft.0 != 0 && ft.0 % TICKS_PER_SECOND == 0
}
#[must_use]
pub fn alternate_data_streams(attributes: &[Attribute]) -> Vec<&Attribute> {
attributes
.iter()
.filter(|a| a.type_code == attr_types::DATA && a.name.is_some())
.collect()
}
#[must_use]
pub fn record_slack<'a>(record: &'a [u8], header: &MftRecordHeader) -> &'a [u8] {
let used = header.used_size as usize;
record.get(used..).unwrap_or(&[])
}
#[must_use]
pub fn is_deleted(header: &MftRecordHeader) -> bool {
!header.is_in_use()
}
#[must_use]
pub fn carve_file_records(mft: &[u8], record_size: usize) -> Vec<usize> {
if record_size == 0 {
return Vec::new();
}
let mut offsets = Vec::new();
let mut pos = 0;
while pos + 4 <= mft.len() {
let sig = &mft[pos..pos + 4];
if sig == SIGNATURE_FILE || sig == SIGNATURE_BAAD {
offsets.push(pos);
}
pos += record_size;
}
offsets
}
pub use forensicnomicon::report::Severity;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum AnomalyKind {
Timestomp {
record: u64,
signal: &'static str,
},
AlternateDataStream {
record: u64,
stream: String,
},
DeletedRecord {
record: u64,
},
RecordSlackResidue {
record: u64,
residue_len: usize,
},
}
impl AnomalyKind {
#[must_use]
pub fn record(&self) -> u64 {
match self {
AnomalyKind::Timestomp { record, .. }
| AnomalyKind::AlternateDataStream { record, .. }
| AnomalyKind::DeletedRecord { record }
| AnomalyKind::RecordSlackResidue { record, .. } => *record,
}
}
#[must_use]
pub fn severity(&self) -> Severity {
match self {
AnomalyKind::Timestomp { .. } => Severity::High,
AnomalyKind::AlternateDataStream { .. }
| AnomalyKind::RecordSlackResidue { .. } => Severity::Low,
AnomalyKind::DeletedRecord { .. } => Severity::Info,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
match self {
AnomalyKind::Timestomp { .. } => "NTFS-TIMESTOMP",
AnomalyKind::AlternateDataStream { .. } => "NTFS-ADS",
AnomalyKind::DeletedRecord { .. } => "NTFS-DELETED-RECORD",
AnomalyKind::RecordSlackResidue { .. } => "NTFS-SLACK-RESIDUE",
}
}
#[must_use]
pub fn note(&self) -> String {
match self {
AnomalyKind::Timestomp { record, signal } => format!(
"record {record}: $STANDARD_INFORMATION timestamps consistent with tampering ({signal})"
),
AnomalyKind::AlternateDataStream { record, stream } => format!(
"record {record}: named $DATA stream `{stream}` — consistent with data carried in an alternate data stream"
),
AnomalyKind::DeletedRecord { record } => {
format!("record {record}: MFT entry not in use — a recoverable deleted file")
}
AnomalyKind::RecordSlackResidue { record, residue_len } => format!(
"record {record}: {residue_len} non-zero byte(s) in MFT-record slack — consistent with residue from an overwritten resident attribute"
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Anomaly {
pub severity: Severity,
pub code: &'static str,
pub kind: AnomalyKind,
pub note: String,
}
impl Anomaly {
#[must_use]
pub fn new(kind: AnomalyKind) -> Self {
Anomaly {
severity: kind.severity(),
code: kind.code(),
note: kind.note(),
kind,
}
}
}
impl forensicnomicon::report::Observation for Anomaly {
fn severity(&self) -> Option<Severity> {
Some(self.severity)
}
fn code(&self) -> &'static str {
self.code
}
fn note(&self) -> String {
self.note.clone()
}
fn evidence(&self) -> Vec<forensicnomicon::report::Evidence> {
let record = self.kind.record();
vec![forensicnomicon::report::Evidence {
field: "mft record".to_string(),
value: record.to_string(),
location: Some(forensicnomicon::report::Location::RecordId(record)),
}]
}
}
#[must_use]
pub fn audit_components(
record_number: u64,
header: &MftRecordHeader,
record: &[u8],
attributes: &[Attribute],
standard_information: Option<&StandardInformation>,
primary_file_name: Option<&FileName>,
) -> Vec<Anomaly> {
let mut out = Vec::new();
if is_deleted(header) {
out.push(Anomaly::new(AnomalyKind::DeletedRecord {
record: record_number,
}));
}
let residue = record_slack(record, header)
.iter()
.filter(|&&b| b != 0)
.count();
if residue > 0 {
out.push(Anomaly::new(AnomalyKind::RecordSlackResidue {
record: record_number,
residue_len: residue,
}));
}
for ads in alternate_data_streams(attributes) {
out.push(Anomaly::new(AnomalyKind::AlternateDataStream {
record: record_number,
stream: ads.name.clone().unwrap_or_default(),
}));
}
if let (Some(si), Some(fname)) = (standard_information, primary_file_name) {
let ind = detect_timestomp(si, fname);
if ind.si_created_before_fn {
out.push(Anomaly::new(AnomalyKind::Timestomp {
record: record_number,
signal: "$SI created before $FN",
}));
}
if ind.si_whole_second {
out.push(Anomaly::new(AnomalyKind::Timestomp {
record: record_number,
signal: "$SI timestamp on a whole second",
}));
}
}
out
}
#[must_use]
pub fn audit_record(record: &[u8]) -> Vec<Anomaly> {
let Ok(header) = MftRecordHeader::parse(record) else {
return Vec::new();
};
let attributes =
crate::attribute::parse_attributes(record, header.first_attribute_offset as usize)
.unwrap_or_default();
let resident = |type_code: u32| {
attributes
.iter()
.find(|a| a.type_code == type_code)
.and_then(|a| a.resident_content(record))
};
let si = resident(attr_types::STANDARD_INFORMATION)
.and_then(|c| StandardInformation::parse(c).ok());
let fname = resident(attr_types::FILE_NAME).and_then(|c| FileName::parse(c).ok());
audit_components(
u64::from(header.record_number),
&header,
record,
&attributes,
si.as_ref(),
fname.as_ref(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attribute::AttributeBody;
fn si(created: u64, modified: u64, mft_modified: u64, accessed: u64) -> StandardInformation {
StandardInformation {
created: Filetime(created),
modified: Filetime(modified),
mft_modified: Filetime(mft_modified),
accessed: Filetime(accessed),
file_attributes: 0,
security_id: None,
usn: None,
}
}
fn fname(created: u64) -> FileName {
use crate::file_name::FileReference;
FileName {
parent: FileReference::from_u64(5),
created: Filetime(created),
modified: Filetime(created),
mft_modified: Filetime(created),
accessed: Filetime(created),
allocated_size: 0,
real_size: 0,
flags: 0,
namespace: 1,
name: "f".to_string(),
}
}
fn data_attr(name: Option<&str>) -> Attribute {
Attribute {
type_code: attr_types::DATA,
length: 0,
non_resident: false,
name: name.map(str::to_string),
flags: 0,
attribute_id: 0,
offset: 0,
body: AttributeBody::Resident {
content_offset: 0,
content_length: 0,
},
}
}
#[test]
fn timestomp_si_before_fn_is_suspicious() {
let ind = detect_timestomp(&si(1_000, 1_000, 1_000, 1_000), &fname(2_000_000_000));
assert!(ind.si_created_before_fn);
assert!(ind.is_suspicious());
}
#[test]
fn timestomp_whole_second_is_suspicious() {
let t = 5 * TICKS_PER_SECOND;
let ind = detect_timestomp(&si(t, t, t, t), &fname(t));
assert!(ind.si_whole_second);
assert!(ind.is_suspicious());
}
#[test]
fn matching_subsecond_times_are_clean() {
let t = 129_067_776_000_000_123; let ind = detect_timestomp(&si(t, t, t, t), &fname(t));
assert!(!ind.is_suspicious());
assert!(!ind.created_mismatch);
}
#[test]
fn finds_alternate_data_streams() {
let attrs = [
data_attr(None),
data_attr(Some("Zone.Identifier")),
data_attr(Some("evil")),
];
let ads = alternate_data_streams(&attrs);
assert_eq!(ads.len(), 2);
assert_eq!(ads[0].name.as_deref(), Some("Zone.Identifier"));
}
#[test]
fn slack_is_the_tail_after_used_size() {
let mut record = vec![0u8; 1024];
record[600..610].copy_from_slice(b"RESIDUEXYZ");
let header = MftRecordHeader {
signature: *b"FILE",
usa_offset: 0x30,
usa_count: 3,
lsn: 0,
sequence_number: 1,
hard_link_count: 1,
first_attribute_offset: 0x38,
flags: 0x01,
used_size: 600,
allocated_size: 1024,
base_record: 0,
next_attr_id: 1,
record_number: 0,
};
let slack = record_slack(&record, &header);
assert_eq!(slack.len(), 1024 - 600);
assert_eq!(&slack[0..10], b"RESIDUEXYZ");
}
#[test]
fn deleted_when_not_in_use() {
let mut header = MftRecordHeader {
signature: *b"FILE",
usa_offset: 0x30,
usa_count: 3,
lsn: 0,
sequence_number: 1,
hard_link_count: 1,
first_attribute_offset: 0x38,
flags: 0x00, used_size: 0x100,
allocated_size: 1024,
base_record: 0,
next_attr_id: 1,
record_number: 0,
};
assert!(is_deleted(&header));
header.flags = 0x01;
assert!(!is_deleted(&header));
}
#[test]
fn carve_with_zero_record_size_is_empty() {
assert!(carve_file_records(b"FILE....", 0).is_empty());
}
#[test]
fn carves_file_records_at_boundaries() {
let rec = 1024usize;
let mut mft = vec![0u8; rec * 4];
mft[0..4].copy_from_slice(b"FILE"); mft[2 * rec..2 * rec + 4].copy_from_slice(b"BAAD"); let offsets = carve_file_records(&mft, rec);
assert_eq!(offsets, vec![0, 2 * rec]);
}
fn hdr(flags: u16, used_size: u32, record_number: u32) -> MftRecordHeader {
MftRecordHeader {
signature: *b"FILE",
usa_offset: 0x30,
usa_count: 3,
lsn: 0,
sequence_number: 1,
hard_link_count: 1,
first_attribute_offset: 0x38,
flags,
used_size,
allocated_size: 1024,
base_record: 0,
next_attr_id: 1,
record_number,
}
}
#[test]
fn audit_flags_deleted_record() {
let header = hdr(0x00, 0x100, 42); let an = audit_components(42, &header, &vec![0u8; 1024], &[], None, None);
assert!(an
.iter()
.any(|a| matches!(a.kind, AnomalyKind::DeletedRecord { record: 42 })));
}
#[test]
fn audit_flags_timestomp() {
let header = hdr(0x01, 0x100, 7);
let si = si(1_000, 1_000, 1_000, 1_000);
let fnm = fname(2_000_000_000);
let an = audit_components(7, &header, &vec![0u8; 1024], &[], Some(&si), Some(&fnm));
assert!(an
.iter()
.any(|a| matches!(a.kind, AnomalyKind::Timestomp { .. })));
}
#[test]
fn audit_flags_alternate_data_stream() {
let header = hdr(0x01, 0x100, 9);
let attrs = [data_attr(None), data_attr(Some("evil"))];
let an = audit_components(9, &header, &vec![0u8; 1024], &attrs, None, None);
assert!(an.iter().any(
|a| matches!(&a.kind, AnomalyKind::AlternateDataStream { stream, .. } if stream == "evil")
));
}
#[test]
fn audit_flags_slack_residue() {
let header = hdr(0x01, 600, 3);
let mut record = vec![0u8; 1024];
record[700] = 0xAA;
let an = audit_components(3, &header, &record, &[], None, None);
assert!(an
.iter()
.any(|a| matches!(a.kind, AnomalyKind::RecordSlackResidue { .. })));
}
#[test]
fn audit_clean_record_has_no_anomalies() {
let header = hdr(0x01, 1024, 1); let an = audit_components(1, &header, &vec![0u8; 1024], &[], None, None);
assert!(an.is_empty(), "clean record: {an:?}");
}
#[test]
fn audit_record_on_non_record_bytes_is_empty_not_panic() {
assert!(audit_record(&[0u8; 16]).is_empty());
assert!(audit_record(b"not even a FILE record").is_empty());
}
#[test]
fn anomaly_converts_to_canonical_finding() {
use forensicnomicon::report::{Observation, Source};
let a = Anomaly::new(AnomalyKind::Timestomp {
record: 5,
signal: "test",
});
let f = a.to_finding(Source {
analyzer: "ntfs-forensic".to_string(),
scope: "NTFS".to_string(),
version: None,
});
assert!(f.code.starts_with("NTFS-"));
assert!(f.severity.is_some());
}
}