#![forbid(unsafe_code)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub use forensicnomicon::report::Severity;
use forensicnomicon::report::{Evidence, Finding, Location, Observation, Source};
use zfs_core::{
checksum, dsl_dataset_bp, dsl_dataset_prev_snap, dsl_dir_head_dataset, mos_dnode, read_block,
read_zap_object, zap_lookup, zpl_list_dir, zpl_master_root, zpl_read_file, Blkptr,
ChecksumType, Dnode, Endian, ObjsetPhys, VdevLabel, LABEL_SIZE, NVLIST_OFFSET, NVLIST_SIZE,
};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AnomalyKind {
UberblockChecksumMismatch {
txg: u64,
slot: usize,
},
LabelDivergence {
field: &'static str,
label: usize,
reason: String,
},
BlkptrChecksumMismatch {
dva_offset: u64,
object_type: u8,
},
ImpossibleGeometry {
field: &'static str,
value: u64,
limit: u64,
},
}
impl AnomalyKind {
#[must_use]
pub fn severity(&self) -> Severity {
match self {
AnomalyKind::UberblockChecksumMismatch { .. }
| AnomalyKind::LabelDivergence { .. }
| AnomalyKind::BlkptrChecksumMismatch { .. }
| AnomalyKind::ImpossibleGeometry { .. } => Severity::High,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
match self {
AnomalyKind::UberblockChecksumMismatch { .. } => "ZFS-UBERBLOCK-CHECKSUM-MISMATCH",
AnomalyKind::LabelDivergence { .. } => "ZFS-LABEL-DIVERGENCE",
AnomalyKind::BlkptrChecksumMismatch { .. } => "ZFS-BLKPTR-CHECKSUM-MISMATCH",
AnomalyKind::ImpossibleGeometry { .. } => "ZFS-IMPOSSIBLE-GEOMETRY",
}
}
#[must_use]
pub fn note(&self) -> String {
match self {
AnomalyKind::UberblockChecksumMismatch { txg, slot } => format!(
"active uberblock (txg {txg}, ring slot {slot}): ub_rootbp checksum does not verify against the MOS block it points at — consistent with corruption or post-write tampering of the pool root"
),
AnomalyKind::LabelDivergence {
field,
label,
reason,
} => format!(
"vdev label L{label} {field}: {reason} — consistent with a torn or tampered vdev label"
),
AnomalyKind::BlkptrChecksumMismatch {
dva_offset,
object_type,
} => format!(
"metadata block (DMU type {object_type}) at byte {dva_offset}: blkptr checksum does not verify — consistent with a dead, corrupt, or tampered block"
),
AnomalyKind::ImpossibleGeometry {
field,
value,
limit,
} => format!(
"geometry field {field} = {value} exceeds the sane bound {limit} for this image — consistent with corruption or an allocation-bomb"
),
}
}
fn evidence(&self) -> Vec<Evidence> {
match self {
AnomalyKind::UberblockChecksumMismatch { txg, slot } => vec![Evidence {
field: "ub_rootbp".to_string(),
value: format!("txg {txg} slot {slot}: checksum mismatch"),
location: Some(Location::Other {
space: "zfs:uberblock_slot".to_string(),
value: *slot as u64,
}),
}],
AnomalyKind::LabelDivergence {
field,
label,
reason,
} => vec![Evidence {
field: (*field).to_string(),
value: format!("L{label}: {reason}"),
location: Some(Location::Other {
space: "zfs:vdev_label".to_string(),
value: *label as u64,
}),
}],
AnomalyKind::BlkptrChecksumMismatch {
dva_offset,
object_type,
} => vec![Evidence {
field: "blkptr".to_string(),
value: format!("DMU type {object_type}"),
location: Some(Location::ByteOffset(*dva_offset)),
}],
AnomalyKind::ImpossibleGeometry {
field,
value,
limit,
} => vec![Evidence {
field: (*field).to_string(),
value: format!("{value} (limit {limit})"),
location: None,
}],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
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 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<Evidence> {
self.kind.evidence()
}
}
#[must_use]
pub fn audit_image(image: &[u8]) -> Vec<Anomaly> {
let mut out = Vec::new();
let Some(l0_bytes) = image.get(0..LABEL_SIZE) else {
return out;
};
let Ok(l0) = VdevLabel::parse(l0_bytes) else {
return out;
};
check_uberblock_rootbp(&mut out, image, &l0);
check_label_divergence(&mut out, image, &l0);
sweep_reachable_blkptrs(&mut out, image, &l0);
out
}
fn check_uberblock_rootbp(out: &mut Vec<Anomaly>, image: &[u8], l0: &VdevLabel) {
let ub = &l0.active_uberblock;
let bp = ub.rootbp_full();
match blkptr_checksum_verdict(image, &bp) {
ChecksumVerdict::Mismatch => {
out.push(Anomaly::new(AnomalyKind::UberblockChecksumMismatch {
txg: ub.txg,
slot: l0.active_slot,
}));
}
ChecksumVerdict::AllocationBomb { value, cap } => push_impossible_rootbp(out, value, cap), ChecksumVerdict::Ok | ChecksumVerdict::Unverified | ChecksumVerdict::Unreadable => {}
}
}
fn push_impossible_rootbp(out: &mut Vec<Anomaly>, value: u64, cap: u64) {
out.push(Anomaly::new(AnomalyKind::ImpossibleGeometry {
field: "ub_rootbp LSIZE",
value,
limit: cap,
}));
}
enum ChecksumVerdict {
Ok,
Mismatch,
Unverified,
Unreadable,
AllocationBomb {
value: u64,
cap: u64,
},
}
const MAX_BLOCK_SIZE: u64 = 32 * 1024 * 1024;
fn blkptr_checksum_verdict(image: &[u8], bp: &Blkptr) -> ChecksumVerdict {
if bp.embedded || bp.is_hole() {
return ChecksumVerdict::Unverified;
}
let lsize = bp.lsize_bytes() as u64;
if lsize == 0 || lsize > MAX_BLOCK_SIZE {
return ChecksumVerdict::AllocationBomb {
value: lsize,
cap: MAX_BLOCK_SIZE,
};
}
let kind = ChecksumType::from_raw(bp.checksum);
if matches!(
kind,
ChecksumType::Off | ChecksumType::Inherit | ChecksumType::On
) {
return ChecksumVerdict::Unverified;
}
let psize = bp.psize_bytes();
for dva in &bp.dvas {
if dva.is_empty() {
continue;
}
let phys = dva.physical_byte_offset() as usize;
let Some(raw) = image.get(phys..phys.saturating_add(psize)) else {
continue;
};
if raw.iter().all(|&b| b == 0) {
return ChecksumVerdict::Unreadable;
}
return match checksum::verify(kind, bp.byteorder, raw, bp.checksum_words) {
Some(true) => ChecksumVerdict::Ok,
Some(false) => ChecksumVerdict::Mismatch,
None => ChecksumVerdict::Unverified,
};
}
ChecksumVerdict::Unreadable
}
fn label_identity(image: &[u8], off: u64) -> Option<(u64, u64, u64)> {
let start = usize::try_from(off).ok()?;
let bytes = image.get(start..start.saturating_add(LABEL_SIZE))?;
let _ = bytes.get(NVLIST_OFFSET..NVLIST_OFFSET.saturating_add(NVLIST_SIZE))?;
let label = VdevLabel::parse(bytes).ok()?;
let guid = label.config.get_u64("pool_guid")?;
let txg = label.config.get_u64("txg").unwrap_or(0);
let ashift = label.config.vdev_tree().map_or(0, |v| v.ashift);
Some((guid, txg, ashift))
}
fn check_label_divergence(out: &mut Vec<Anomaly>, image: &[u8], l0: &VdevLabel) {
let Some(base_guid) = l0.config.get_u64("pool_guid") else {
return; };
let base_ashift = l0.config.vdev_tree().map_or(0, |v| v.ashift);
let image_len = image.len() as u64;
if image_len < 4 * LABEL_SIZE as u64 {
return;
}
let l3_off = image_len - LABEL_SIZE as u64;
if label_identity(image, l3_off).is_none() {
return;
}
let candidates: [(usize, u64); 3] = [
(1, LABEL_SIZE as u64),
(2, image_len - 2 * LABEL_SIZE as u64),
(3, l3_off),
];
for (idx, off) in candidates {
match label_identity(image, off) {
None => out.push(Anomaly::new(AnomalyKind::LabelDivergence {
field: "config",
label: idx,
reason: "vdev label config could not be parsed while the other labels did — \
consistent with a torn label in an otherwise well-formed vdev"
.to_string(),
})),
Some((guid, _txg, ashift)) => {
if guid != base_guid {
out.push(Anomaly::new(AnomalyKind::LabelDivergence {
field: "pool_guid",
label: idx,
reason: format!("pool_guid {guid} differs from L0 baseline {base_guid}"),
}));
}
if ashift != base_ashift {
out.push(Anomaly::new(AnomalyKind::LabelDivergence {
field: "ashift",
label: idx,
reason: format!("ashift {ashift} differs from L0 baseline {base_ashift}"),
}));
}
}
}
}
}
fn sweep_reachable_blkptrs(out: &mut Vec<Anomaly>, image: &[u8], l0: &VdevLabel) {
let ub = &l0.active_uberblock;
let rootbp = ub.rootbp_full();
let Ok(mos_block) = read_block(image, &rootbp) else {
return;
};
let Ok(mos) = ObjsetPhys::parse(&mos_block.data, ub.endian) else {
return; };
let mut budget: usize = 4096;
for bp in &mos.meta_dnode.blkptrs {
if budget == 0 {
break; }
budget -= 1;
if bp.embedded || bp.is_hole() {
continue;
}
if let ChecksumVerdict::Mismatch = blkptr_checksum_verdict(image, bp) {
let dva_offset = bp
.dvas
.iter()
.find(|d| !d.is_empty())
.map_or(0, |d| d.physical_byte_offset());
out.push(Anomaly::new(AnomalyKind::BlkptrChecksumMismatch {
dva_offset,
object_type: bp.object_type,
}));
}
}
}
#[must_use]
pub fn audit_findings(image: &[u8], scope: &str) -> Vec<Finding> {
let source = Source {
analyzer: "zfs-forensic".to_string(),
scope: scope.to_string(),
version: None,
};
audit_image(image)
.iter()
.map(|a| a.to_finding(source.clone()))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecoveredFile {
pub path: String,
pub source: String,
pub inode: u64,
pub size: u64,
pub content: Vec<u8>,
pub content_sha256: String,
}
#[must_use]
pub fn recover_deleted(image: &[u8]) -> Vec<RecoveredFile> {
let mut out = Vec::new();
let Some((mos, endian)) = open_mos(image) else {
return out;
};
let Some(objdir) = mos_dnode(image, &mos, 1) else {
return out; };
let Ok(objdir_data) = read_zap_object(image, &objdir) else {
return out; };
let Some(root_dataset) = zap_lookup(&objdir_data, "root_dataset") else {
return out; };
let Some(dsl_dir) = mos_dnode(image, &mos, root_dataset) else {
return out; };
let head = dsl_dir_head_dataset(&dsl_dir);
if head == 0 {
return out; }
let Some(head_ds) = mos_dnode(image, &mos, head) else {
return out; };
let live_names = dataset_root_names(image, &head_ds, endian);
let mut snap_obj = dsl_dataset_prev_snap(&head_ds);
let mut budget: usize = 4096; let mut seen: Vec<u64> = Vec::new();
while snap_obj != 0 && budget > 0 {
budget -= 1;
if seen.contains(&snap_obj) {
break; }
seen.push(snap_obj);
let Some(snap_ds) = mos_dnode(image, &mos, snap_obj) else {
break; };
recover_from_snapshot(
image,
&mos,
&snap_ds,
snap_obj,
endian,
&live_names,
&mut out,
);
snap_obj = dsl_dataset_prev_snap(&snap_ds);
}
out
}
fn open_mos(image: &[u8]) -> Option<(ObjsetPhys, Endian)> {
let l0_bytes = image.get(0..LABEL_SIZE)?;
let l0 = VdevLabel::parse(l0_bytes).ok()?;
let endian = l0.active_uberblock.endian;
let rootbp = l0.active_uberblock.rootbp_full();
let block = read_block(image, &rootbp).ok()?;
let mos = ObjsetPhys::parse(&block.data, endian).ok()?;
Some((mos, endian))
}
fn dataset_root_names(image: &[u8], dataset: &Dnode, endian: Endian) -> Vec<(String, u64)> {
let Some(zpl) = dataset_zpl_objset(image, dataset, endian) else {
return Vec::new(); };
let Some(root) = zpl_master_root(image, &zpl) else {
return Vec::new(); };
zpl_list_dir(image, &zpl, root)
}
fn dataset_zpl_objset(image: &[u8], dataset: &Dnode, endian: Endian) -> Option<ObjsetPhys> {
let ds_bp: Blkptr = dsl_dataset_bp(dataset);
let block = read_block(image, &ds_bp).ok()?;
ObjsetPhys::parse(&block.data, endian).ok()
}
fn recover_from_snapshot(
image: &[u8],
_mos: &ObjsetPhys,
snap_ds: &Dnode,
snap_obj: u64,
endian: Endian,
live_names: &[(String, u64)],
out: &mut Vec<RecoveredFile>,
) {
let Some(zpl) = dataset_zpl_objset(image, snap_ds, endian) else {
return; };
let Some(root) = zpl_master_root(image, &zpl) else {
return; };
let source = format!("snapshot obj {snap_obj}");
for (name, obj) in zpl_list_dir(image, &zpl, root) {
if live_names.iter().any(|(n, _)| *n == name) {
continue;
}
if out.iter().any(|r| r.path == name) {
continue; }
let Ok(content) = zpl_read_file(image, &zpl, obj) else {
continue; };
let content_sha256 = sha256_hex(&content);
out.push(RecoveredFile {
path: name,
source: source.clone(),
inode: obj,
size: content.len() as u64,
content,
content_sha256,
});
}
}
fn sha256_hex(data: &[u8]) -> String {
let words = checksum::sha256(data);
let mut hex = String::with_capacity(64);
use std::fmt::Write as _;
for w in words {
let _ = write!(hex, "{w:016x}");
}
hex
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod unit {
use super::{sha256_hex, Anomaly, AnomalyKind, Severity};
use forensicnomicon::report::{Location, Observation, Source};
#[test]
fn sha256_of_empty_and_known_input() {
assert_eq!(
sha256_hex(&[]),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn every_anomaly_kind_derives_code_severity_note_and_evidence() {
let kinds = [
AnomalyKind::UberblockChecksumMismatch { txg: 22, slot: 22 },
AnomalyKind::LabelDivergence {
field: "pool_guid",
label: 2,
reason: "pool_guid 7 differs from L0 baseline 9".to_string(),
},
AnomalyKind::BlkptrChecksumMismatch {
dva_offset: 0x0040_0000,
object_type: 10,
},
AnomalyKind::ImpossibleGeometry {
field: "ub_rootbp LSIZE",
value: u64::MAX,
limit: 32 * 1024 * 1024,
},
];
for kind in kinds {
let a = Anomaly::new(kind.clone());
assert_eq!(a.severity, Severity::High);
assert!(a.code.starts_with("ZFS-"));
assert_eq!(a.code, kind.code());
assert_eq!(a.note, kind.note());
assert!(
a.note.to_lowercase().contains("consistent with"),
"note must be an observation: {}",
a.note
);
assert!(!a.kind.evidence().is_empty());
assert_eq!(a.severity(), Some(Severity::High));
assert_eq!(Observation::code(&a), a.code);
assert_eq!(Observation::note(&a), a.note);
assert!(!Observation::evidence(&a).is_empty());
}
}
#[test]
fn to_finding_tags_analyzer_scope_for_every_kind() {
let source = Source {
analyzer: "zfs-forensic".to_string(),
scope: "vdev0".to_string(),
version: None,
};
for kind in [
AnomalyKind::LabelDivergence {
field: "ashift",
label: 3,
reason: "ashift 9 differs from L0 baseline 12".to_string(),
},
AnomalyKind::BlkptrChecksumMismatch {
dva_offset: 4096,
object_type: 11,
},
AnomalyKind::ImpossibleGeometry {
field: "x",
value: 1,
limit: 0,
},
] {
let a = Anomaly::new(kind);
let f = a.to_finding(source.clone());
assert_eq!(f.source.analyzer, "zfs-forensic");
assert_eq!(f.source.scope, "vdev0");
assert_eq!(f.code, a.code);
}
}
use super::{blkptr_checksum_verdict, ChecksumVerdict, MAX_BLOCK_SIZE};
use zfs_core::{Blkptr, ChecksumType, CompressType, Endian};
#[test]
fn verdict_embedded_and_hole_are_unverified() {
let mut emb = Blkptr::default();
emb.embedded = true;
emb.embedded_lsize = 8;
assert!(matches!(
blkptr_checksum_verdict(&[], &emb),
ChecksumVerdict::Unverified
));
let hole = Blkptr::default();
assert!(matches!(
blkptr_checksum_verdict(&[], &hole),
ChecksumVerdict::Unverified
));
}
#[test]
fn verdict_over_cap_lsize_is_allocation_bomb() {
let mut bp = Blkptr::default();
bp.dvas[0].asize_sectors = 1;
bp.dvas[0].offset_sectors = 1; bp.lsize_raw = u32::MAX; let verdict = blkptr_checksum_verdict(&[0u8; 16], &bp);
let ChecksumVerdict::AllocationBomb { value, cap } = verdict else {
panic!("expected AllocationBomb for an over-cap LSIZE"); };
assert_eq!(cap, MAX_BLOCK_SIZE);
assert!(value > MAX_BLOCK_SIZE || value == 0);
let mut out = Vec::new();
super::push_impossible_rootbp(&mut out, value, cap);
assert_eq!(out.len(), 1);
assert_eq!(out[0].code, "ZFS-IMPOSSIBLE-GEOMETRY");
}
#[test]
fn verdict_unsupported_checksum_function_is_unverified() {
let mut bp = Blkptr::default();
bp.dvas[0].asize_sectors = 1;
bp.dvas[0].offset_sectors = 0;
bp.lsize_raw = 0;
bp.psize_raw = 0;
bp.checksum = ChecksumType::Other(30).raw(); let mut img = vec![0u8; 0x0040_0000 + 4096];
img[0x0040_0000 + 10] = 0xAB; assert!(matches!(
blkptr_checksum_verdict(&img, &bp),
ChecksumVerdict::Unverified
));
}
#[test]
fn verdict_off_checksum_is_unverified() {
let mut bp = Blkptr::default();
bp.dvas[0].asize_sectors = 1;
bp.dvas[0].offset_sectors = 1;
bp.lsize_raw = 0; bp.psize_raw = 0;
bp.checksum = ChecksumType::Off.raw();
let mut img = vec![0u8; 0x0040_0000 + 4096];
img[0x0040_0000 + 512] = 1; assert!(matches!(
blkptr_checksum_verdict(&img, &bp),
ChecksumVerdict::Unverified
));
}
#[test]
fn verdict_all_zero_target_is_unreadable() {
let mut bp = Blkptr::default();
bp.dvas[0].asize_sectors = 1;
bp.dvas[0].offset_sectors = 0; bp.lsize_raw = 0;
bp.psize_raw = 0;
bp.checksum = ChecksumType::Fletcher4.raw();
bp.compression = CompressType::Off.raw();
bp.byteorder = Endian::Little;
let img = vec![0u8; 0x0040_0000 + 4096]; assert!(matches!(
blkptr_checksum_verdict(&img, &bp),
ChecksumVerdict::Unreadable
));
}
#[test]
fn verdict_out_of_image_dva_is_unreadable() {
let mut bp = Blkptr::default();
bp.dvas[0].asize_sectors = 1;
bp.dvas[0].offset_sectors = 0xffff_ffff; bp.lsize_raw = 0;
bp.psize_raw = 0;
bp.checksum = ChecksumType::Fletcher4.raw();
assert!(matches!(
blkptr_checksum_verdict(&[0u8; 4096], &bp),
ChecksumVerdict::Unreadable
));
}
#[test]
fn verdict_real_checksum_ok_and_mismatch() {
let payload: Vec<u8> = (0..512u32).map(|i| i as u8).collect();
let mut img = vec![0u8; 0x0040_0000 + 4096];
img[0x0040_0000..0x0040_0000 + 512].copy_from_slice(&payload);
let cksum = zfs_core::checksum::fletcher4(&payload, Endian::Little);
let mut bp = Blkptr::default();
bp.dvas[0].asize_sectors = 1;
bp.dvas[0].offset_sectors = 0;
bp.lsize_raw = 0;
bp.psize_raw = 0;
bp.checksum = ChecksumType::Fletcher4.raw();
bp.compression = CompressType::Off.raw();
bp.byteorder = Endian::Little;
bp.checksum_words = cksum;
assert!(matches!(
blkptr_checksum_verdict(&img, &bp),
ChecksumVerdict::Ok
));
bp.checksum_words = [9, 9, 9, 9];
assert!(matches!(
blkptr_checksum_verdict(&img, &bp),
ChecksumVerdict::Mismatch
));
}
#[test]
fn evidence_locations_are_kind_specific() {
let bomb = AnomalyKind::ImpossibleGeometry {
field: "f",
value: 2,
limit: 1,
};
assert!(bomb.evidence()[0].location.is_none());
let blk = AnomalyKind::BlkptrChecksumMismatch {
dva_offset: 0x1234,
object_type: 10,
};
assert!(matches!(
blk.evidence()[0].location,
Some(Location::ByteOffset(0x1234))
));
}
}