#![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 ufs::{
list_dir_all, read_file, read_inode, CylinderGroup, DirEntry, DirEntryType, Superblock,
UfsError, CG_MAGIC, SBLOCK_UFS1, SBLOCK_UFS2, UFS_ROOTINO,
};
pub use ufs::{CylinderGroup as ReaderCylinderGroup, Superblock as ReaderSuperblock, UfsVersion};
const FS_MAGIC_OFF: usize = 1372;
const CG_MAGIC_OFF: usize = 4;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AnomalyKind {
SuperblockMagicInvalid {
offset: u64,
bytes: [u8; 4],
},
BackupSuperblockDivergence {
cg: u32,
field: &'static str,
primary: u64,
backup: u64,
offset: u64,
},
CgMagicInvalid {
cg: u32,
found: u32,
offset: u64,
},
OrphanedInode {
inode: u64,
nlink: u16,
},
ImpossibleGeometry {
field: &'static str,
value: u64,
limit: u64,
},
}
impl AnomalyKind {
#[must_use]
pub fn severity(&self) -> Severity {
match self {
AnomalyKind::SuperblockMagicInvalid { .. }
| AnomalyKind::BackupSuperblockDivergence { .. }
| AnomalyKind::CgMagicInvalid { .. }
| AnomalyKind::ImpossibleGeometry { .. } => Severity::High,
AnomalyKind::OrphanedInode { .. } => Severity::Medium,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
match self {
AnomalyKind::SuperblockMagicInvalid { .. } => "UFS-SUPERBLOCK-MAGIC-INVALID",
AnomalyKind::BackupSuperblockDivergence { .. } => "UFS-BACKUP-SUPERBLOCK-DIVERGENCE",
AnomalyKind::CgMagicInvalid { .. } => "UFS-CG-MAGIC-INVALID",
AnomalyKind::OrphanedInode { .. } => "UFS-ORPHANED-INODE",
AnomalyKind::ImpossibleGeometry { .. } => "UFS-IMPOSSIBLE-GEOMETRY",
}
}
#[must_use]
pub fn note(&self) -> String {
match self {
AnomalyKind::SuperblockMagicInvalid { offset, bytes } => format!(
"superblock at byte {offset}: fs_magic bytes {bytes:02x?} match neither UFS1 (0x00011954) nor UFS2 (0x19540119) in either byte order — consistent with corruption or an overwritten superblock"
),
AnomalyKind::BackupSuperblockDivergence {
cg,
field,
primary,
backup,
..
} => format!(
"cylinder group {cg} backup superblock: {field} = {backup} differs from the primary {primary} — consistent with a spliced or edited image"
),
AnomalyKind::CgMagicInvalid { cg, found, .. } => format!(
"cylinder group {cg} header: cg_magic = {found:#010x} is not 0x00090255 — consistent with corruption or a tampered allocation map"
),
AnomalyKind::OrphanedInode { inode, nlink } => format!(
"inode {inode} is allocated (di_nlink {nlink}) yet reachable by no directory entry from root — an inode unlinked while still open, or a corruption lead"
),
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::SuperblockMagicInvalid { offset, bytes } => vec![Evidence {
field: "fs_magic".to_string(),
value: format!("{bytes:02x?}"),
location: Some(Location::ByteOffset(*offset)),
}],
AnomalyKind::BackupSuperblockDivergence {
cg,
field,
primary,
backup,
offset,
} => vec![Evidence {
field: (*field).to_string(),
value: format!("cg{cg} backup={backup} vs primary={primary}"),
location: Some(Location::ByteOffset(*offset)),
}],
AnomalyKind::CgMagicInvalid { cg, found, offset } => vec![Evidence {
field: "cg_magic".to_string(),
value: format!("cg{cg}: {found:#010x}"),
location: Some(Location::ByteOffset(*offset)),
}],
AnomalyKind::OrphanedInode { inode, nlink } => vec![Evidence {
field: "di_nlink".to_string(),
value: format!("inode {inode} nlink {nlink}, unreferenced"),
location: Some(Location::Other {
space: "ufs:inode".to_string(),
value: *inode,
}),
}],
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(partition: &[u8]) -> Vec<Anomaly> {
let mut out = Vec::new();
let sb = match parse_primary_sb(partition) {
Ok(sb) => sb,
Err(SbError::MagicInvalid { offset, bytes }) => {
out.push(Anomaly::new(AnomalyKind::SuperblockMagicInvalid {
offset,
bytes,
}));
return out;
}
Err(SbError::NotUfs) => return out,
};
let fsize = if sb.fsize > 0 { sb.fsize as u64 } else { 0 };
let fpg = if sb.fpg > 0 { sb.fpg as u64 } else { 0 };
let sblkno = if sb.sblkno >= 0 { sb.sblkno as u64 } else { 0 };
let cblkno = if sb.cblkno >= 0 { sb.cblkno as u64 } else { 0 };
let ncg = u64::from(sb.ncg);
let part_len = partition.len() as u64;
if check_impossible_geometry(&mut out, fsize, fpg, ncg, part_len) {
return out;
}
check_all_cgs(&mut out, partition, &sb, fsize, fpg, sblkno, cblkno, ncg);
check_orphaned_inodes(&mut out, partition, &sb);
out
}
fn check_impossible_geometry(
out: &mut Vec<Anomaly>,
fsize: u64,
fpg: u64,
ncg: u64,
part_len: u64,
) -> bool {
if fsize == 0 || fpg == 0 || ncg == 0 {
return false;
}
let cg_bytes = fpg.saturating_mul(fsize);
let last_base = ncg.saturating_sub(1).saturating_mul(cg_bytes);
if last_base < part_len {
return false;
}
out.push(Anomaly::new(AnomalyKind::ImpossibleGeometry {
field: "fs_ncg",
value: ncg,
limit: part_len.checked_div(cg_bytes).map_or(1, |q| q + 1),
}));
true
}
#[allow(clippy::too_many_arguments)]
fn check_all_cgs(
out: &mut Vec<Anomaly>,
partition: &[u8],
sb: &Superblock,
fsize: u64,
fpg: u64,
sblkno: u64,
cblkno: u64,
ncg: u64,
) {
if fsize == 0 || fpg == 0 {
return;
}
for cg in 0..ncg {
let cg_base_frag = cg.saturating_mul(fpg);
if cg >= 1 && sblkno > 0 {
let bsb_off = cg_base_frag.saturating_add(sblkno).saturating_mul(fsize);
check_backup_sb(out, partition, sb, cg as u32, bsb_off);
}
if cblkno > 0 {
let cg_off = cg_base_frag.saturating_add(cblkno).saturating_mul(fsize);
check_cg_magic(out, partition, cg as u32, cg_off);
}
}
}
enum SbError {
MagicInvalid { offset: u64, bytes: [u8; 4] },
NotUfs,
}
fn parse_primary_sb(partition: &[u8]) -> Result<Superblock, SbError> {
for off in [SBLOCK_UFS2, SBLOCK_UFS1] {
let Some(slice) = partition.get(off..) else {
continue;
};
match Superblock::parse(slice) {
Ok(sb) => return Ok(sb),
Err(UfsError::BadMagic { bytes, .. }) => {
return Err(SbError::MagicInvalid {
offset: (off + FS_MAGIC_OFF) as u64,
bytes,
});
}
Err(_) => {}
}
}
Err(SbError::NotUfs)
}
fn check_backup_sb(
out: &mut Vec<Anomaly>,
partition: &[u8],
primary: &Superblock,
cg: u32,
offset: u64,
) {
let start = usize::try_from(offset).unwrap_or(usize::MAX);
let Some(slice) = partition.get(start..) else {
return;
};
let Ok(backup) = Superblock::parse(slice) else {
return;
};
let checks: [(&'static str, u64, u64); 4] = [
("fs_ipg", primary.ipg as u64, backup.ipg as u64),
("fs_fpg", primary.fpg as u64, backup.fpg as u64),
("fs_bsize", primary.bsize as u64, backup.bsize as u64),
("fs_ncg", u64::from(primary.ncg), u64::from(backup.ncg)),
];
for (field, p, b) in checks {
if p != b {
out.push(Anomaly::new(AnomalyKind::BackupSuperblockDivergence {
cg,
field,
primary: p,
backup: b,
offset,
}));
}
}
}
fn check_cg_magic(out: &mut Vec<Anomaly>, partition: &[u8], cg: u32, offset: u64) {
let start = usize::try_from(offset).unwrap_or(usize::MAX);
let Some(slice) = partition.get(start..) else {
return;
};
if slice.len() < CG_MAGIC_OFF + 4 {
return;
}
let le = read_u32_le(slice, CG_MAGIC_OFF);
let be = read_u32_be(slice, CG_MAGIC_OFF);
if le != CG_MAGIC && be != CG_MAGIC {
out.push(Anomaly::new(AnomalyKind::CgMagicInvalid {
cg,
found: le,
offset,
}));
}
}
fn check_orphaned_inodes(out: &mut Vec<Anomaly>, partition: &[u8], sb: &Superblock) {
let reachable = reachable_inodes(partition, sb);
let ipg = if sb.ipg > 0 { sb.ipg as u64 } else { return };
let ncg = u64::from(sb.ncg);
let total = ipg.saturating_mul(ncg);
for cg in 0..ncg {
let Some(used) = cg_inode_bitmap(partition, sb, cg) else {
continue;
};
for within in 0..ipg {
let ino = cg.saturating_mul(ipg).saturating_add(within);
if ino < UFS_ROOTINO + 1 || ino >= total {
continue;
}
if !bitmap_bit(&used, within as usize) {
continue; }
if reachable.contains(&ino) {
continue;
}
let Ok(inode) = read_inode(partition, sb, ino) else {
continue;
};
if inode.nlink == 0 {
continue;
}
out.push(Anomaly::new(AnomalyKind::OrphanedInode {
inode: ino,
nlink: inode.nlink,
}));
}
}
}
#[must_use]
pub fn audit_findings(partition: &[u8], scope: &str) -> Vec<Finding> {
let source = Source {
analyzer: "ufs-forensic".to_string(),
scope: scope.to_string(),
version: None,
};
audit_image(partition)
.iter()
.map(|a| a.to_finding(source.clone()))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RecoveredItem {
DeletedFile {
name: Option<String>,
inode: u64,
size: u64,
content: Vec<u8>,
content_sha256: String,
},
DeletedDirent {
name: String,
inode: u64,
},
}
#[must_use]
pub fn recover_deleted(partition: &[u8]) -> Vec<RecoveredItem> {
let mut out = Vec::new();
let Ok(sb) = parse_primary_sb(partition) else {
return out;
};
let deleted_names = collect_deleted_dirents(partition, &sb);
for (name, ino) in &deleted_names {
out.push(RecoveredItem::DeletedDirent {
name: name.clone(),
inode: *ino,
});
}
carve_deleted_inodes(partition, &sb, &deleted_names, &mut out);
out
}
fn collect_deleted_dirents(partition: &[u8], sb: &Superblock) -> Vec<(String, u64)> {
let mut deleted = Vec::new();
let mut visited: Vec<u64> = Vec::new();
let mut queue: Vec<u64> = vec![UFS_ROOTINO];
let mut budget: usize = 1 << 20;
while let Some(dir_ino) = queue.pop() {
if budget == 0 {
break; }
budget -= 1;
if visited.contains(&dir_ino) {
continue;
}
visited.push(dir_ino);
let Ok(entries) = list_dir_all(partition, sb, dir_ino) else {
continue;
};
for e in &entries {
if e.deleted {
if !e.name.is_empty() && e.name != b"." && e.name != b".." {
deleted.push((decode_name(&e.name), e.ino));
}
} else if is_dir_entry(e) && e.name != b"." && e.name != b".." {
queue.push(e.ino);
}
}
}
deleted
}
fn carve_deleted_inodes(
partition: &[u8],
sb: &Superblock,
deleted_names: &[(String, u64)],
out: &mut Vec<RecoveredItem>,
) {
let ipg = if sb.ipg > 0 { sb.ipg as u64 } else { return };
let ncg = u64::from(sb.ncg);
let total = ipg.saturating_mul(ncg);
for cg in 0..ncg {
let Some(used) = cg_inode_bitmap(partition, sb, cg) else {
continue;
};
for within in 0..ipg {
let ino = cg.saturating_mul(ipg).saturating_add(within);
if ino < UFS_ROOTINO + 1 || ino >= total {
continue;
}
if bitmap_bit(&used, within as usize) {
continue;
}
let Ok(inode) = read_inode(partition, sb, ino) else {
continue;
};
if !inode.is_regular() || inode.size == 0 || inode.direct[0] == 0 {
continue;
}
let Ok(content) = read_file(partition, sb, ino) else {
continue;
};
if content.is_empty() {
continue; }
let content_sha256 = sha256_hex(&content);
let name = deleted_names
.iter()
.find(|(_, dino)| *dino == ino)
.map(|(n, _)| n.clone());
out.push(RecoveredItem::DeletedFile {
name,
inode: ino,
size: inode.size,
content,
content_sha256,
});
}
}
}
fn cg_inode_bitmap(partition: &[u8], sb: &Superblock, cg: u64) -> Option<Vec<u8>> {
if sb.fsize <= 0 || sb.fpg <= 0 || sb.cblkno < 0 || sb.ipg <= 0 {
return None; }
let fsize = sb.fsize as u64;
let fpg = sb.fpg as u64;
let cblkno = sb.cblkno as u64;
let cg_off = cg
.saturating_mul(fpg)
.saturating_add(cblkno)
.saturating_mul(fsize);
let start = usize::try_from(cg_off).ok()?;
let header = partition.get(start..)?;
let cgh = CylinderGroup::parse(header, sb.endian).ok()?;
let bmp_start = cgh.inosused_off();
let bytes = (sb.ipg as usize).div_ceil(8);
let slice = header.get(bmp_start..bmp_start.saturating_add(bytes))?;
Some(slice.to_vec())
}
fn bitmap_bit(bitmap: &[u8], idx: usize) -> bool {
let byte = idx / 8;
let bit = idx % 8;
bitmap.get(byte).is_some_and(|b| (b >> bit) & 1 == 1)
}
fn reachable_inodes(partition: &[u8], sb: &Superblock) -> Vec<u64> {
let mut reachable: Vec<u64> = vec![UFS_ROOTINO];
let mut queue: Vec<u64> = vec![UFS_ROOTINO];
let mut budget: usize = 1 << 20;
while let Some(dir_ino) = queue.pop() {
if budget == 0 {
break; }
budget -= 1;
let Ok(entries) = list_dir_all(partition, sb, dir_ino) else {
continue;
};
for e in &entries {
if e.deleted || e.name == b"." || e.name == b".." {
continue;
}
if !reachable.contains(&e.ino) {
reachable.push(e.ino);
if is_dir_entry(e) {
queue.push(e.ino);
}
}
}
}
reachable
}
fn is_dir_entry(e: &DirEntry) -> bool {
matches!(e.file_type, DirEntryType::Directory)
}
fn decode_name(name: &[u8]) -> String {
String::from_utf8_lossy(name).into_owned()
}
fn sha256_hex(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(data);
let digest = h.finalize();
let mut hex = String::with_capacity(64);
use std::fmt::Write as _;
for b in digest {
let _ = write!(hex, "{b:02x}");
}
hex
}
fn read_u32_le(d: &[u8], o: usize) -> u32 {
d.get(o..o.saturating_add(4))
.and_then(|b| <[u8; 4]>::try_from(b).ok())
.map_or(0, u32::from_le_bytes)
}
fn read_u32_be(d: &[u8], o: usize) -> u32 {
d.get(o..o.saturating_add(4))
.and_then(|b| <[u8; 4]>::try_from(b).ok())
.map_or(0, u32::from_be_bytes)
}
#[cfg(test)]
mod unit {
use super::{
bitmap_bit, decode_name, read_u32_be, read_u32_le, sha256_hex, Anomaly, AnomalyKind,
Severity,
};
use forensicnomicon::report::{Location, Observation, Source};
#[test]
fn readers_yield_zero_out_of_range() {
assert_eq!(read_u32_le(&[0, 0, 0], 0), 0);
assert_eq!(read_u32_le(&[1, 0, 0, 0], 0), 1);
assert_eq!(read_u32_be(&[0, 0, 0], 0), 0);
assert_eq!(read_u32_be(&[0, 0, 0, 1], 0), 1);
}
#[test]
fn bitmap_bit_reads_lsb_first() {
let bmp = [0b0000_0101u8, 0b1000_0000u8];
assert!(bitmap_bit(&bmp, 0));
assert!(!bitmap_bit(&bmp, 1));
assert!(bitmap_bit(&bmp, 2));
assert!(bitmap_bit(&bmp, 15)); assert!(!bitmap_bit(&bmp, 16)); }
#[test]
fn decode_name_is_lossy() {
assert_eq!(decode_name(b"secret.txt"), "secret.txt");
let _ = decode_name(&[0xff, 0xfe, b'a']);
}
#[test]
fn sha256_of_known_input() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(
sha256_hex(&[]),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn every_anomaly_kind_derives_code_severity_note_and_evidence() {
let kinds = [
AnomalyKind::SuperblockMagicInvalid {
offset: 66908,
bytes: [0xde, 0xad, 0xbe, 0xef],
},
AnomalyKind::BackupSuperblockDivergence {
cg: 1,
field: "fs_ipg",
primary: 128,
backup: 999,
offset: 1_146_880,
},
AnomalyKind::CgMagicInvalid {
cg: 2,
found: 0x1234_5678,
offset: 2_228_224,
},
AnomalyKind::OrphanedInode { inode: 6, nlink: 1 },
AnomalyKind::ImpossibleGeometry {
field: "fs_ncg",
value: 100_000,
limit: 5,
},
];
for kind in kinds {
let a = Anomaly::new(kind.clone());
assert!(a.code.starts_with("UFS-"));
assert_eq!(a.code, kind.code());
assert_eq!(a.note, kind.note());
assert_eq!(a.severity, kind.severity());
assert!(
a.note.to_lowercase().contains("consistent with")
|| a.note.to_lowercase().contains("unlinked while still open"),
"note must be an observation: {}",
a.note
);
assert!(!a.kind.evidence().is_empty());
assert_eq!(a.severity(), Some(a.severity));
assert_eq!(Observation::code(&a), a.code);
assert_eq!(Observation::note(&a), a.note);
assert!(!Observation::evidence(&a).is_empty());
}
}
#[test]
fn severity_grading_matches_spec() {
assert_eq!(
AnomalyKind::OrphanedInode { inode: 6, nlink: 1 }.severity(),
Severity::Medium
);
assert_eq!(
AnomalyKind::CgMagicInvalid {
cg: 0,
found: 0,
offset: 0
}
.severity(),
Severity::High
);
}
#[test]
fn to_finding_tags_analyzer_scope() {
let source = Source {
analyzer: "ufs-forensic".to_string(),
scope: "part0".to_string(),
version: None,
};
for kind in [
AnomalyKind::OrphanedInode { inode: 9, nlink: 2 },
AnomalyKind::ImpossibleGeometry {
field: "x",
value: 2,
limit: 1,
},
] {
let a = Anomaly::new(kind);
let f = a.to_finding(source.clone());
assert_eq!(f.source.analyzer, "ufs-forensic");
assert_eq!(f.source.scope, "part0");
assert_eq!(f.code, a.code);
}
}
#[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 mag = AnomalyKind::SuperblockMagicInvalid {
offset: 0x1234,
bytes: [1, 2, 3, 4],
};
assert!(matches!(
mag.evidence()[0].location,
Some(Location::ByteOffset(0x1234))
));
}
}