use std::fmt;
use crate::boot_code::BootCodeId;
use crate::ebr::EbrChain;
use crate::entropy::HIGH_ENTROPY_THRESHOLD;
use crate::gap::Gap;
use crate::mbr::MbrSector;
use crate::partition::TypeCode;
use crate::signature::DetectedFs;
use crate::wipe::FillPattern;
const PRE_PARTITION_BENIGN_LBA: u64 = 63;
pub use forensicnomicon::report::Severity;
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> {
vec![forensicnomicon::report::Evidence {
field: "offset".to_string(),
value: format!("{:#x}", self.offset),
location: Some(forensicnomicon::report::Location::ByteOffset(self.offset)),
}]
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Anomaly {
pub severity: Severity,
pub code: &'static str,
pub kind: AnomalyKind,
pub offset: u64,
pub note: String,
}
impl Anomaly {
#[must_use]
pub fn new(kind: AnomalyKind, offset: u64) -> Self {
Anomaly {
severity: kind.severity(),
code: kind.code(),
note: kind.note(),
kind,
offset,
}
}
}
impl fmt::Display for Anomaly {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[{}] {} @ {:#x}: {}",
self.severity, self.code, self.offset, self.note
)
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum AnomalyKind {
NonZeroReserved { bytes: [u8; 2] },
MultipleBootable { count: usize },
NoBootablePartition,
ZeroDiskSignature,
KnownBootkit { name: &'static str },
ResidualEntry {
index: usize,
lba_start: u32,
lba_count: u32,
},
InvalidPartitionStatus { index: usize, status: u8 },
DuplicatePartitionEntry { a: usize, b: usize },
OverlappingPartitions {
a: usize,
b: usize,
a_end: u64,
b_start: u64,
},
OutOfBounds {
index: usize,
last_lba: u64,
disk_last_lba: u64,
},
ChsLbaInconsistency { index: usize },
HybridMbr { extra_partition_count: usize },
ProtectiveMbrUndersized {
covered_last_lba: u64,
disk_last_lba: u64,
},
HiddenGpt,
SpoofedProtectiveMbr,
EbrCycle,
EbrExcessiveDepth { depth: usize },
EbrSlackData { ebr_lba: u64, entropy: f64 },
PrePartitionSpace {
lba_start: u64,
lba_end: u64,
byte_size: u64,
},
InterPartitionGap {
lba_start: u64,
lba_end: u64,
byte_size: u64,
},
PostPartitionSpace {
lba_start: u64,
lba_end: u64,
byte_size: u64,
},
WipedRegion {
lba_start: u64,
pattern: FillPattern,
},
CarvedArtifact { kind: &'static str },
VbrHiddenSectorsMismatch {
index: usize,
bpb_hidden: u32,
lba_start: u64,
},
SignatureMismatch {
index: usize,
declared: TypeCode,
detected: DetectedFs,
},
WipedBootCode,
EmptyProtectiveBootCode,
ErasedBootCode,
UnknownBootCode,
HighEntropySlack { offset: u64, entropy: f64 },
}
impl AnomalyKind {
#[must_use]
pub fn severity(&self) -> Severity {
use AnomalyKind as K;
match self {
K::OverlappingPartitions { .. } | K::EbrCycle | K::KnownBootkit { .. } => {
Severity::Critical
}
K::OutOfBounds { .. }
| K::EbrExcessiveDepth { .. }
| K::WipedBootCode
| K::ErasedBootCode
| K::HybridMbr { .. }
| K::ProtectiveMbrUndersized { .. }
| K::HiddenGpt
| K::SpoofedProtectiveMbr
| K::WipedRegion { .. }
| K::VbrHiddenSectorsMismatch { .. }
| K::HighEntropySlack { .. } => Severity::High,
K::EbrSlackData { entropy, .. } => {
if *entropy > HIGH_ENTROPY_THRESHOLD {
Severity::High
} else {
Severity::Medium
}
}
K::PrePartitionSpace { lba_start, .. } => {
if *lba_start < PRE_PARTITION_BENIGN_LBA {
Severity::Low
} else {
Severity::Medium
}
}
K::NonZeroReserved { .. }
| K::MultipleBootable { .. }
| K::ResidualEntry { .. }
| K::ChsLbaInconsistency { .. }
| K::InvalidPartitionStatus { .. }
| K::DuplicatePartitionEntry { .. }
| K::ZeroDiskSignature
| K::InterPartitionGap { .. }
| K::SignatureMismatch { .. } => Severity::Medium,
K::UnknownBootCode | K::CarvedArtifact { .. } => Severity::Low,
K::NoBootablePartition | K::PostPartitionSpace { .. } | K::EmptyProtectiveBootCode => {
Severity::Info
}
}
}
#[must_use]
pub fn code(&self) -> &'static str {
use AnomalyKind as K;
match self {
K::NonZeroReserved { .. } => "MBR-RESERVED-NONZERO",
K::MultipleBootable { .. } => "MBR-BOOT-MULTI",
K::NoBootablePartition => "MBR-BOOT-NONE",
K::ZeroDiskSignature => "MBR-DISKSIG-ZERO",
K::KnownBootkit { .. } => "MBR-BOOT-MALWARE",
K::ResidualEntry { .. } => "MBR-PART-RESIDUAL",
K::InvalidPartitionStatus { .. } => "MBR-PART-STATUS",
K::DuplicatePartitionEntry { .. } => "MBR-PART-DUPLICATE",
K::OverlappingPartitions { .. } => "MBR-PART-OVERLAP",
K::OutOfBounds { .. } => "MBR-PART-OOB",
K::ChsLbaInconsistency { .. } => "MBR-PART-CHSLBA",
K::HybridMbr { .. } => "MBR-GPT-HYBRID",
K::ProtectiveMbrUndersized { .. } => "MBR-GPT-UNDERSIZED",
K::HiddenGpt => "MBR-GPT-HIDDEN",
K::SpoofedProtectiveMbr => "MBR-GPT-SPOOFED",
K::EbrCycle => "MBR-EBR-CYCLE",
K::EbrExcessiveDepth { .. } => "MBR-EBR-DEPTH",
K::EbrSlackData { .. } => "MBR-EBR-SLACK",
K::PrePartitionSpace { .. } => "MBR-GAP-PRE",
K::InterPartitionGap { .. } => "MBR-GAP-MID",
K::PostPartitionSpace { .. } => "MBR-GAP-POST",
K::WipedRegion { .. } => "MBR-GAP-WIPED",
K::CarvedArtifact { .. } => "MBR-CARVE-ARTIFACT",
K::SignatureMismatch { .. } => "MBR-PART-SIGMISMATCH",
K::VbrHiddenSectorsMismatch { .. } => "MBR-VBR-HIDDEN",
K::WipedBootCode => "MBR-BOOT-WIPED",
K::EmptyProtectiveBootCode => "MBR-BOOT-PROTECTIVE-EMPTY",
K::ErasedBootCode => "MBR-BOOT-ERASED",
K::UnknownBootCode => "MBR-BOOT-UNKNOWN",
K::HighEntropySlack { .. } => "MBR-SLACK-ENTROPY",
}
}
#[must_use]
pub fn note(&self) -> String {
use AnomalyKind as K;
match self {
K::NonZeroReserved { bytes } => format!(
"Reserved bytes at offset 444 are non-zero: [{:#04X}, {:#04X}]",
bytes[0], bytes[1]
),
K::MultipleBootable { count } => {
format!("{count} partition entries have the bootable flag set")
}
K::NoBootablePartition => "No partition is marked bootable".to_string(),
K::ZeroDiskSignature => {
"Windows MBR boot code present but NT disk signature (offset 440) is zero — \
consistent with a wiped or re-created boot record"
.to_string()
}
K::InvalidPartitionStatus { index, status } => format!(
"Entry {index}: invalid status byte {status:#04X} (expected 0x00 or 0x80)"
),
K::DuplicatePartitionEntry { a, b } => {
format!("Entries {a} and {b} describe the identical extent — duplicate entry")
}
K::ResidualEntry {
index,
lba_start,
lba_count,
} => format!(
"Entry {index}: type=0x00 but lba_start={lba_start} lba_count={lba_count} — possible deleted partition"
),
K::OverlappingPartitions {
a,
b,
a_end,
b_start,
} => format!(
"Partitions {a} and {b} overlap (entry {a} ends at LBA {a_end}, entry {b} starts at {b_start})"
),
K::OutOfBounds {
index,
last_lba,
disk_last_lba,
} => format!("Entry {index}: last LBA {last_lba} exceeds disk last LBA {disk_last_lba}"),
K::ChsLbaInconsistency { index } => {
format!("Entry {index}: CHS address inconsistent with LBA value")
}
K::HybridMbr {
extra_partition_count,
} => format!(
"Hybrid MBR: GPT protective entry (0xEE) coexists with {extra_partition_count} \
real partition entr{} — legacy-visible, GPT-invisible data-hiding vector",
if *extra_partition_count == 1 { "y" } else { "ies" }
),
K::ProtectiveMbrUndersized {
covered_last_lba,
disk_last_lba,
} => format!(
"Protective MBR (0xEE) covers only up to LBA {covered_last_lba} but the disk \
ends at LBA {disk_last_lba} — tail region hidden from GPT-aware tools"
),
K::HiddenGpt => {
"GPT header (\"EFI PART\") present at LBA 1 but no protective 0xEE entry \
advertises it — hidden GPT layout"
.to_string()
}
K::SpoofedProtectiveMbr => {
"Protective entry (0xEE) present but no GPT header at LBA 1 — spoofed protective MBR"
.to_string()
}
K::EbrCycle => "EBR chain contains a cycle".to_string(),
K::EbrExcessiveDepth { depth } => {
format!("EBR chain depth exceeded {depth} — possibly corrupt or adversarial")
}
K::EbrSlackData { ebr_lba, entropy } => {
format!("EBR at LBA {ebr_lba} has non-zero slack (entropy {entropy:.2})")
}
K::PrePartitionSpace {
lba_start,
lba_end,
byte_size,
} => gap_note("Pre-partition space", *lba_start, *lba_end, *byte_size),
K::InterPartitionGap {
lba_start,
lba_end,
byte_size,
} => gap_note("Gap between partitions", *lba_start, *lba_end, *byte_size),
K::PostPartitionSpace {
lba_start,
lba_end,
byte_size,
} => gap_note("Post-partition space", *lba_start, *lba_end, *byte_size),
K::WipedRegion { lba_start, pattern } => format!(
"Unpartitioned region at LBA {lba_start} shows a deliberate wipe pattern: {}",
pattern.label()
),
K::CarvedArtifact { kind } => {
format!("Recoverable {kind} file header found in unpartitioned space")
}
K::SignatureMismatch {
index,
declared,
detected,
} => format!(
"Entry {index}: declared type {:?} ({}) but detected {detected:?} from first sector",
declared.family(),
declared.name(),
),
K::VbrHiddenSectorsMismatch {
index,
bpb_hidden,
lba_start,
} => format!(
"Entry {index}: VBR hidden-sectors field ({bpb_hidden}) disagrees with \
partition-table LBA ({lba_start}) — volume relocated/copied or table edited"
),
K::KnownBootkit { name } => {
format!("Boot code contains a documented {name} boot-sector-malware marker")
}
K::WipedBootCode => "Boot code is all zeros — likely wiped or overwritten".to_string(),
K::EmptyProtectiveBootCode => {
"MBR boot code is empty (all zeros), which is expected on a GPT/UEFI disk — \
the protective MBR's boot code is never executed"
.to_string()
}
K::ErasedBootCode => {
"Boot code is all 0xFF — factory-erased or deliberate wipe".to_string()
}
K::UnknownBootCode => "Boot code signature not recognised".to_string(),
K::HighEntropySlack { offset, entropy } => {
format!("High-entropy slack at offset {offset} (entropy {entropy:.2})")
}
}
}
}
fn gap_note(label: &str, lba_start: u64, lba_end: u64, byte_size: u64) -> String {
let sectors = lba_end.saturating_sub(lba_start).saturating_add(1);
format!("{label}: LBA {lba_start}–{lba_end} ({sectors} sectors, {byte_size} bytes)")
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct PartitionSummary {
pub index: usize,
pub lba_start: u64,
pub lba_end: u64,
pub byte_offset: u64,
pub byte_size: u64,
pub declared_type: TypeCode,
pub detected_fs: Option<DetectedFs>,
}
#[derive(Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct MbrAnalysis {
pub mbr: MbrSector,
pub partitions: Vec<PartitionSummary>,
pub ebr_chain: EbrChain,
pub gaps: Vec<Gap>,
pub boot_code_id: BootCodeId,
pub disk_serial: u32,
pub era: crate::provenance::PartitioningEra,
#[cfg(feature = "gpt")]
pub gpt: Option<gpt_forensic::GptAnalysis>,
pub anomalies: Vec<Anomaly>,
}
impl MbrAnalysis {
#[must_use]
pub fn max_severity(&self) -> Option<Severity> {
self.anomalies.iter().map(|a| a.severity).max()
}
pub fn anomalies_at_least(&self, min: Severity) -> impl Iterator<Item = &Anomaly> {
self.anomalies.iter().filter(move |a| a.severity >= min)
}
}