ufs-forensic 0.1.1

Forensic anomaly auditor for UFS/FFS — deleted-inode recovery, directory-slack residue, orphaned inodes, geometry integrity as graded report::Finding, built on ufs-core
Documentation

ufs-forensic

ufs-core ufs-forensic Docs.rs Rust 1.75+ License: Apache-2.0 Sponsor

CI Coverage unsafe forbidden Security audit Docs

A from-scratch UFS/FFS reader and a graded anomaly auditor — walk the superblock, cylinder groups, inodes, directories, and file block-maps of a UFS1 or UFS2 image over any byte source, then turn its residue into evidence: diverged backup superblocks, bad cylinder-group magics, orphaned inodes, and deleted files still carvable from freed-but-intact dinodes.

UFS is the Unix File System, a.k.a. the Berkeley Fast File System (FFS) — the native filesystem of FreeBSD, the historic BSDs, and Solaris. This workspace reads both on-disk generations: UFS1 (128-byte inodes, 32-bit block pointers, superblock at byte 8192, magic 0x00011954) and UFS2 (256-byte inodes, 64-bit block pointers, superblock at byte 65536, magic 0x19540119), in either byte order (the on-disk order is the creating host's; the magic disambiguates it).

Two crates, one workspace:

  • ufs-core — the reader: superblock + geometry, cylinder-group headers and allocation bitmaps, UFS1/UFS2 dinode decode, struct direct directory walking, path resolution, and block-map → file content (12 direct + single/double/triple indirect chains), over any byte slice. No unsafe, no C bindings.
  • ufs-forensic — the auditor: turns parsed UFS structures into severity-graded forensicnomicon::report::Findings, and recovers deleted files and directory entries, so a UFS volume's anomalies aggregate uniformly with the partition and container layers.

Audit a UFS image in 30 seconds

[dependencies]
ufs-forensic = "0.1"   # pulls in ufs-core
use ufs_forensic::audit_findings;

// Feed it the raw filesystem-partition bytes; get back graded findings.
for finding in audit_findings(&partition_bytes, "ufs") {
    println!("[{:?}] {}{}", finding.severity, finding.code, finding.note);
    // e.g. [Some(High)] UFS-BACKUP-SUPERBLOCK-DIVERGENCE — cylinder group 2 backup superblock: fs_ipg = 64 differs from the primary 128 …
}

audit_findings parses the superblock, cylinder groups, and inode/directory tree in place and grades what it finds. A structurally invalid image yields no findings (corruption is surfaced as its own finding, never a panic). For the typed form, audit_image(&partition) returns Vec<Anomaly> — each anomaly.to_finding(source) converts to a report::Finding.

The anomaly codes

Each finding is an observation ("consistent with …"); the examiner draws the conclusions. Codes are a stable, published contract.

Code Severity What it observes
UFS-SUPERBLOCK-MAGIC-INVALID High fs_magic matches neither UFS1 nor UFS2 in either byte order — consistent with corruption or an overwritten superblock
UFS-BACKUP-SUPERBLOCK-DIVERGENCE High A per-cylinder-group backup superblock field differs from the primary — consistent with a spliced or edited image
UFS-CG-MAGIC-INVALID High A cylinder-group header's cg_magic is not 0x00090255 — consistent with corruption or a tampered allocation map
UFS-IMPOSSIBLE-GEOMETRY High A geometry field beyond what the image can hold — a corruption / allocation-bomb guard
UFS-ORPHANED-INODE Medium An allocated inode (di_nlink > 0) reachable by no directory entry from root — an inode unlinked while open, or a corruption lead

Deleted-item recovery is separate: recover_deleted(&partition) sweeps every cylinder group's inode table for inodes that are free in the cg bitmap yet still carry an intact di_mode/di_size/di_db, re-assembles their content, and walks the directory tree for d_ino == 0 slots whose residual d_name survives. It returns each carved RecoveredItem — a DeletedFile (name, inode, size, content, and the content's sha256 — a provenance stamp validated against an independent pre-delete hash, not a runtime gate; conceptually UFS-DELETED-FILE-CARVED) or a DeletedDirent (residual name + inode; conceptually UFS-DELETED-DIRENT). Recovery is state-dependent — it succeeds only while the freed dinode and data blocks are un-reallocated, and returns nothing rather than fabricate once the residue is gone.

The reader: navigate an image

ufs-core (imported as ufs) reads a UFS1/UFS2 filesystem partition over any byte slice:

use ufs::{Superblock, read_path_content, list_dir};

// The primary superblock lives at byte 65536 on UFS2 (8192 on UFS1); parse it,
// then resolve a slash-separated path from the root inode to its file bytes,
// walking the block map (direct + indirect chains) transparently.
let sb = Superblock::parse(&partition[65536..])?;
let entries = list_dir(&partition, &sb, ufs::UFS_ROOTINO)?;      // root directory
let bytes = read_path_content(&partition, &sb, "etc/passwd")?;   // file content
# Ok::<(), ufs::UfsError>(())

The bare crate name ufs on crates.io is an unrelated, obscure file-embedding utility, so this on-disk reader publishes as ufs-core and imports as ufs (via [lib] name = "ufs") — consumers write use ufs::….

What makes this different from a general-purpose UFS reader

Most UFS readers answer one question: "what files are on this volume?" This workspace answers the questions a digital forensics examiner actually needs:

Capability General-purpose UFS reader this workspace
UFS1 + UFS2 superblock / geometry
Endianness auto-detect (LE + BE images) partial
Cylinder-group headers + allocation bitmaps
UFS1 (128-byte) + UFS2 (256-byte) dinode decode
Directory walking + path resolution
Block-map → file content (direct + single/double/triple indirect)
Fast (inline) + slow symlink targets
Backup-superblock divergence detection (splice tell)
Cylinder-group magic verification
Orphaned-inode enumeration
Deleted-file recovery from freed-but-intact dinodes
Deleted-dirent (d_ino == 0 slack) recovery
Impossible-geometry / allocation-bomb guards
Severity-graded report::Finding output
#![forbid(unsafe_code)]

Trust but verify

  • #![forbid(unsafe_code)] in both crates — no unsafe, no C bindings.
  • Panic-free — every integer / length / offset / block-pointer field is read through bounds-checked helpers; a malformed image degrades to an empty or typed result, never a panic.
  • Fuzzed — one cargo-fuzz target per parsed structure (superblock, cg, inode, dir, file) plus a fuzz_forensic target driving the full audit_image / recover_deleted pipeline. fuzz.yml builds every target on each push and deep-fuzzes each for 10 minutes weekly.
  • Tier-1 validated — the UFS2 reader is checked against a real third-party image, test_data/ufs2.raw from log2timeline/dfvfs (Apache-2.0), whose ground truth comes from The Sleuth Kit (fsstat / fls / istat / icat), a wholly separate implementation, down to per-file icat | sha256 content. The single / double / triple indirect block chains — which no publicly available real UFS image exercises — are validated by an independent block-map walker cross-check over a crafted image (two decoders agreeing on the artifact), and the UFS1 path is spec-derived (FreeBSD sys/ufs/ffs/fs.h), lifted to Tier-1 in a follow-on against a real FreeBSD image. See docs/validation.md.

Reader API (ufs-core)

Item Purpose
Superblock::parse UFS1/UFS2 superblock geometry + addressing, with endian auto-detect from the magic
CylinderGroup::parse Cylinder-group header + inode/block allocation-bitmap offsets
read_inode / Inode::parse Locate + decode a UFS1 (128-byte) or UFS2 (256-byte) dinode
list_dir / list_dir_all / read_by_path struct direct entries (live, or the deleted-slot superset), path resolution
read_file / read_path_content Block-map → file bytes (12 direct + single/double/triple indirect), truncated to di_size
read_symlink_target Fast (inline) or slow (data-block) symlink target

Privacy Policy · Terms of Service · © 2026 Security Ronin Ltd