diff --git a/src/main.rs b/src/main.rs index a85fb4f..52f9df0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ use crate::stream::FscryptDecryptor; mod bootid; mod crypto; mod stream; +mod vhd; /// Info collected from each input file for sorting and post-extraction merge. struct InputFile { @@ -234,98 +235,6 @@ fn strip_extended_path_prefix(path: PathBuf) -> PathBuf { } } -/// Mount a VHD, extract all contents to a folder with the same name, dismount, and delete the VHD. -/// Requires elevation (triggers UAC prompt). -#[cfg(windows)] -fn extract_vhd_contents(vhd_path: &Path) -> Result<()> { - let vhd_abs = strip_extended_path_prefix(std::fs::canonicalize(vhd_path)?); - let output_dir = vhd_path.with_extension(""); - let output_dir_abs = strip_extended_path_prefix(std::path::absolute(&output_dir)?); - - println!("Extracting VHD contents..."); - println!(" VHD: {}", vhd_abs.display()); - println!(" Output: {}", output_dir_abs.display()); - - // Write a PowerShell script to a temp file to avoid nested quoting hell. - // Mount VHD read-only, copy contents, dismount. - let script_path = vhd_path.with_extension("extract.ps1"); - let script_abs = strip_extended_path_prefix(std::path::absolute(&script_path)?); - let error_log = vhd_path.with_extension("extract_error.txt"); - let error_log_abs = strip_extended_path_prefix(std::path::absolute(&error_log)?); - - let script = format!( - r#"try {{ - $vhd = Mount-VHD -Path '{vhd}' -ReadOnly -Passthru - $disk = $vhd | Get-Disk - $part = $disk | Get-Partition | Where-Object {{ $_.Type -ne 'Reserved' }} | Select-Object -First 1 - - # Try to get existing drive letter - $dl = ($part | Get-Volume).DriveLetter - - # If no drive letter, assign one temporarily - $assignedLetter = $false - if (-not $dl) {{ - $usedLetters = (Get-Volume).DriveLetter - $dl = [char[]](90..68) | Where-Object {{ $_ -notin $usedLetters }} | Select-Object -First 1 - if (-not $dl) {{ - Dismount-VHD -Path '{vhd}' - throw 'No available drive letter' - }} - $part | Set-Partition -NewDriveLetter $dl - $assignedLetter = $true - }} - - $src = "$($dl):\" - New-Item -ItemType Directory -Force -Path '{out}' | Out-Null - Get-ChildItem -Path $src -Force | Where-Object {{ $_.Name -ne 'System Volume Information' -and $_.Name -ne '$Recycle.Bin' }} | Copy-Item -Destination '{out}' -Recurse -Force - - # Remove assigned letter before dismounting - if ($assignedLetter) {{ - $part | Remove-PartitionAccessPath -AccessPath "$($dl):\" -ErrorAction SilentlyContinue - }} - Dismount-VHD -Path '{vhd}' -}} catch {{ - $_ | Out-File '{err}' -Encoding UTF8 - try {{ Dismount-VHD -Path '{vhd}' -ErrorAction SilentlyContinue }} catch {{}} - throw -}}"#, - vhd = vhd_abs.display(), - out = output_dir_abs.display(), - err = error_log_abs.display(), - ); - - std::fs::write(&script_path, &script)?; - - let status = std::process::Command::new("powershell") - .args([ - "-Command", - &format!( - "Start-Process powershell -Verb RunAs -Wait -ArgumentList '-ExecutionPolicy', 'Bypass', '-File', '{}'", - script_abs.display() - ), - ]) - .status()?; - - // Clean up the script - std::fs::remove_file(&script_path).ok(); - - if error_log.exists() { - let error_text = std::fs::read_to_string(&error_log).unwrap_or_default(); - std::fs::remove_file(&error_log).ok(); - println!("WARNING: VHD extraction failed: {}", error_text.trim()); - } else if status.success() && output_dir.exists() { - println!("Extracted to: {}", output_dir_abs.display()); - // Delete the VHD now that contents are extracted - if let Err(e) = std::fs::remove_file(vhd_path) { - println!("WARNING: Could not delete VHD: {e}"); - } - } else { - println!("WARNING: VHD extraction may have failed. Check UAC was accepted."); - } - - Ok(()) -} - /// Merge a differencing VHD into its parent using Hyper-V PowerShell cmdlets. /// /// Steps: @@ -611,7 +520,7 @@ fn main() -> Result<()> { // Extract all VHD contents to folders, then delete the VHDs for vhd_path in &vhds_to_extract { if vhd_path.exists() { - if let Err(e) = extract_vhd_contents(vhd_path) { + if let Err(e) = vhd::extract_vhd(vhd_path) { println!("WARNING: Failed to extract VHD contents: {e:#?}"); } } diff --git a/src/vhd.rs b/src/vhd.rs new file mode 100644 index 0000000..704aed4 --- /dev/null +++ b/src/vhd.rs @@ -0,0 +1,519 @@ +use std::{ + fs::{create_dir_all, File, FileTimes, OpenOptions}, + io::{self, BufRead, BufReader, Read, Seek, SeekFrom, Write}, + path::Path, + time::{Duration, SystemTime}, +}; + +use anyhow::{anyhow, Result}; +use indicatif::{ProgressBar, ProgressStyle}; +use ntfs::{structured_values::NtfsStandardInformation, Ntfs, NtfsAttributeType, NtfsTime}; + +// --------------------------------------------------------------------------- +// VHD constants +// --------------------------------------------------------------------------- + +const VHD_FOOTER_SIZE: u64 = 512; +const VHD_COOKIE: &[u8; 8] = b"conectix"; +const VHD_TYPE_FIXED: u32 = 2; +const VHD_TYPE_DYNAMIC: u32 = 3; + +const DYNAMIC_HEADER_COOKIE: &[u8; 8] = b"cxsparse"; +const BAT_ENTRY_UNUSED: u32 = 0xFFFFFFFF; + +/// Sectors per bitmap: each data block is preceded by a sector-aligned bitmap. +const BITMAP_SECTORS: u64 = 1; + +const MBR_SIGNATURE: [u8; 2] = [0x55, 0xAA]; +const MBR_PARTITION_TABLE_OFFSET: usize = 0x1BE; +const MBR_PARTITION_ENTRY_SIZE: usize = 16; +const NTFS_PARTITION_TYPE: u8 = 0x07; + +const NTFS_PROBE_OFFSETS: &[u64] = &[0, 32_256, 1_048_576, 512]; +const NTFS_MAGIC: [u8; 4] = [0xEB, 0x52, 0x90, 0x4E]; + +// --------------------------------------------------------------------------- +// VHD error type +// --------------------------------------------------------------------------- + +#[derive(Debug, thiserror::Error)] +pub enum VhdError { + #[error(transparent)] + Io(#[from] io::Error), + + #[error("Not a valid VHD file (bad cookie)")] + InvalidCookie, + + #[error("Unsupported VHD type {0} (only fixed=2 and dynamic=3 are supported)")] + UnsupportedType(u32), + + #[error("Invalid dynamic VHD header")] + InvalidDynamicHeader, + + #[error("No NTFS partition found in VHD")] + NoNtfsPartition, +} + +// --------------------------------------------------------------------------- +// VHD reader +// --------------------------------------------------------------------------- + +enum VhdLayout { + /// Fixed VHD: data is contiguous from offset 0 to (file_size - 512). + Fixed, + /// Dynamic VHD: data is in blocks addressed via a Block Allocation Table. + Dynamic { + bat: Vec, + block_size: u64, + }, +} + +/// A reader that transparently presents the NTFS partition within a VHD file. +pub struct VhdReader { + inner: R, + layout: VhdLayout, + /// Byte offset where the NTFS partition starts within the virtual disk. + ntfs_offset: u64, + /// Total virtual disk size. + virtual_disk_size: u64, + /// Current virtual position (relative to NTFS start). + pos: u64, +} + +impl VhdReader { + pub fn new(mut inner: R) -> Result { + let file_size = inner.seek(SeekFrom::End(0))?; + if file_size < VHD_FOOTER_SIZE { + return Err(VhdError::InvalidCookie); + } + + // Read footer (last 512 bytes) + inner.seek(SeekFrom::Start(file_size - VHD_FOOTER_SIZE))?; + let mut footer = [0u8; VHD_FOOTER_SIZE as usize]; + inner.read_exact(&mut footer)?; + + if &footer[0..8] != VHD_COOKIE { + return Err(VhdError::InvalidCookie); + } + + let disk_type = + u32::from_be_bytes([footer[0x3C], footer[0x3D], footer[0x3E], footer[0x3F]]); + + let (layout, virtual_disk_size) = match disk_type { + VHD_TYPE_FIXED => { + let vds = file_size - VHD_FOOTER_SIZE; + (VhdLayout::Fixed, vds) + } + VHD_TYPE_DYNAMIC => { + let (layout, vds) = Self::parse_dynamic(&mut inner, &footer)?; + (layout, vds) + } + other => return Err(VhdError::UnsupportedType(other)), + }; + + // Detect NTFS partition offset within the virtual disk + let ntfs_offset = + Self::detect_ntfs_offset_virtual(&mut inner, &layout, virtual_disk_size)?; + + Ok(Self { + inner, + layout, + ntfs_offset, + virtual_disk_size, + pos: 0, + }) + } + + fn parse_dynamic(inner: &mut R, footer: &[u8]) -> Result<(VhdLayout, u64), VhdError> { + // data_offset in footer (big-endian u64 at 0x10) points to dynamic header + let data_offset = u64::from_be_bytes([ + footer[0x10], + footer[0x11], + footer[0x12], + footer[0x13], + footer[0x14], + footer[0x15], + footer[0x16], + footer[0x17], + ]); + + // Read dynamic disk header (1024 bytes) + inner.seek(SeekFrom::Start(data_offset))?; + let mut hdr = [0u8; 1024]; + inner.read_exact(&mut hdr)?; + + if &hdr[0..8] != DYNAMIC_HEADER_COOKIE { + return Err(VhdError::InvalidDynamicHeader); + } + + // BAT offset (big-endian u64 at 0x10 in header) + let bat_offset = u64::from_be_bytes([ + hdr[0x10], hdr[0x11], hdr[0x12], hdr[0x13], hdr[0x14], hdr[0x15], hdr[0x16], + hdr[0x17], + ]); + + // Max table entries (big-endian u32 at 0x18) + let max_entries = + u32::from_be_bytes([hdr[0x18], hdr[0x19], hdr[0x1A], hdr[0x1B]]) as usize; + + // Block size (big-endian u32 at 0x20) + let block_size = u32::from_be_bytes([hdr[0x20], hdr[0x21], hdr[0x22], hdr[0x23]]) as u64; + + // Read BAT + inner.seek(SeekFrom::Start(bat_offset))?; + let mut bat_bytes = vec![0u8; max_entries * 4]; + inner.read_exact(&mut bat_bytes)?; + + let bat: Vec = (0..max_entries) + .map(|i| { + u32::from_be_bytes([ + bat_bytes[i * 4], + bat_bytes[i * 4 + 1], + bat_bytes[i * 4 + 2], + bat_bytes[i * 4 + 3], + ]) + }) + .collect(); + + let virtual_disk_size = max_entries as u64 * block_size; + + Ok((VhdLayout::Dynamic { bat, block_size }, virtual_disk_size)) + } + + /// Read from a virtual disk offset, handling both fixed and dynamic layouts. + fn read_virtual(&mut self, virtual_offset: u64, buf: &mut [u8]) -> io::Result { + if virtual_offset >= self.virtual_disk_size { + return Ok(0); + } + + let remaining = self.virtual_disk_size - virtual_offset; + let to_read = std::cmp::min(buf.len() as u64, remaining) as usize; + + match &self.layout { + VhdLayout::Fixed => { + self.inner.seek(SeekFrom::Start(virtual_offset))?; + self.inner.read(&mut buf[..to_read]) + } + VhdLayout::Dynamic { bat, block_size } => { + let block_index = (virtual_offset / block_size) as usize; + let offset_in_block = virtual_offset % block_size; + let max_in_block = (*block_size - offset_in_block) as usize; + let to_read = std::cmp::min(to_read, max_in_block); + + if block_index >= bat.len() || bat[block_index] == BAT_ENTRY_UNUSED { + // Unallocated block: return zeros + buf[..to_read].fill(0); + Ok(to_read) + } else { + // Block starts at sector bat[block_index], skip bitmap sector(s) + let block_file_offset = bat[block_index] as u64 * 512 + + BITMAP_SECTORS * 512 + + offset_in_block; + self.inner.seek(SeekFrom::Start(block_file_offset))?; + self.inner.read(&mut buf[..to_read]) + } + } + } + } + + /// Detect NTFS partition offset by reading virtual disk data. + fn detect_ntfs_offset_virtual( + inner: &mut R, + layout: &VhdLayout, + virtual_disk_size: u64, + ) -> Result { + // Helper to read 4 bytes from a virtual offset + let read_magic = |inner: &mut R, layout: &VhdLayout, offset: u64| -> io::Result<[u8; 4]> { + let mut magic = [0u8; 4]; + match layout { + VhdLayout::Fixed => { + inner.seek(SeekFrom::Start(offset))?; + inner.read_exact(&mut magic)?; + } + VhdLayout::Dynamic { bat, block_size } => { + let bi = (offset / block_size) as usize; + let bo = offset % block_size; + if bi < bat.len() && bat[bi] != BAT_ENTRY_UNUSED { + let file_off = bat[bi] as u64 * 512 + BITMAP_SECTORS * 512 + bo; + inner.seek(SeekFrom::Start(file_off))?; + inner.read_exact(&mut magic)?; + } + } + } + Ok(magic) + }; + + // Stage 1: Try MBR + if virtual_disk_size >= 512 { + let mut mbr = [0u8; 512]; + match layout { + VhdLayout::Fixed => { + inner.seek(SeekFrom::Start(0))?; + inner.read_exact(&mut mbr)?; + } + VhdLayout::Dynamic { bat, block_size: _ } => { + if !bat.is_empty() && bat[0] != BAT_ENTRY_UNUSED { + let file_off = bat[0] as u64 * 512 + BITMAP_SECTORS * 512; + inner.seek(SeekFrom::Start(file_off))?; + inner.read_exact(&mut mbr)?; + } + } + } + + if mbr[510..512] == MBR_SIGNATURE { + for i in 0..4 { + let eo = MBR_PARTITION_TABLE_OFFSET + i * MBR_PARTITION_ENTRY_SIZE; + if mbr[eo + 4] == NTFS_PARTITION_TYPE { + let lba = u32::from_le_bytes([ + mbr[eo + 8], + mbr[eo + 9], + mbr[eo + 10], + mbr[eo + 11], + ]); + let offset = lba as u64 * 512; + if offset + 4 <= virtual_disk_size { + if read_magic(inner, layout, offset)? == NTFS_MAGIC { + return Ok(offset); + } + } + } + } + } + } + + // Stage 2: Probe known offsets + for &offset in NTFS_PROBE_OFFSETS { + if offset + 4 > virtual_disk_size { + continue; + } + if read_magic(inner, layout, offset)? == NTFS_MAGIC { + return Ok(offset); + } + } + + Err(VhdError::NoNtfsPartition) + } + + pub fn ntfs_size(&self) -> u64 { + self.virtual_disk_size - self.ntfs_offset + } +} + +impl Read for VhdReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let remaining = self.ntfs_size() - self.pos; + if remaining == 0 { + return Ok(0); + } + let virtual_offset = self.ntfs_offset + self.pos; + let max_read = std::cmp::min(buf.len() as u64, remaining) as usize; + let n = self.read_virtual(virtual_offset, &mut buf[..max_read])?; + self.pos += n as u64; + Ok(n) + } +} + +impl Seek for VhdReader { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + let new_pos = match pos { + SeekFrom::Start(offset) => offset, + SeekFrom::Current(offset) => { + let target = self.pos as i64 + offset; + if target < 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "cannot seek before start", + )); + } + target as u64 + } + SeekFrom::End(offset) => { + let target = self.ntfs_size() as i64 + offset; + if target < 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "cannot seek before start", + )); + } + target as u64 + } + }; + self.pos = new_pos; + Ok(new_pos) + } +} + +// --------------------------------------------------------------------------- +// NTFS extraction from VHD +// --------------------------------------------------------------------------- + +fn ntfs_time_to_system_time(ntfs_time: NtfsTime) -> SystemTime { + let intervals_since_windows_epoch = ntfs_time.nt_timestamp(); + let intervals_since_unix_epoch = intervals_since_windows_epoch - 116_444_736_000_000_000; + let nanos_since_unix_epoch = intervals_since_unix_epoch * 100; + SystemTime::UNIX_EPOCH + Duration::from_nanos(nanos_since_unix_epoch) +} + +fn set_ntfs_timestamps( + fs: &mut T, + file: &ntfs::NtfsFile, + path: &Path, +) -> Result<()> { + let mut attrs = file.attributes(); + while let Some(attr) = attrs.next(fs) { + let attr = attr?; + let attr = attr.to_attribute()?; + if let Ok(NtfsAttributeType::StandardInformation) = attr.ty() { + let info = attr.resident_structured_value::()?; + let handle = OpenOptions::new().write(true).open(path)?; + handle.set_times( + FileTimes::new() + .set_accessed(ntfs_time_to_system_time(info.access_time())) + .set_modified(ntfs_time_to_system_time(info.modification_time())), + )?; + break; + } + } + Ok(()) +} + +fn extract_ntfs_dir( + ntfs: &Ntfs, + fs: &mut T, + dir: &ntfs::NtfsFile, + output_dir: &Path, + pb: &ProgressBar, +) -> Result<()> { + let index = dir.directory_index(fs)?; + let mut iter = index.entries(); + + while let Some(entry) = iter.next(fs) { + let entry = entry?; + let file_name = entry + .key() + .ok_or_else(|| anyhow!("missing index entry key"))?; + let file_name = file_name?; + let name = file_name.name().to_string_lossy(); + + if name.starts_with('$') + || name == "." + || name == ".." + || name == "System Volume Information" + { + continue; + } + + let file = entry.to_file(ntfs, fs)?; + let dest_path = output_dir.join(&*name); + + if file_name.is_directory() { + create_dir_all(&dest_path)?; + extract_ntfs_dir(ntfs, fs, &file, &dest_path, pb)?; + set_ntfs_timestamps(fs, &file, &dest_path).ok(); + } else if let Some(data_item) = file.data(fs, "") { + let data_item = data_item?; + let data_attribute = data_item.to_attribute()?; + let mut data_value = + BufReader::with_capacity(256 * 1024, data_attribute.value(fs)?.attach(fs)); + + let mut output_file = File::create(&dest_path)?; + + loop { + let buffer = data_value.fill_buf()?; + let length = buffer.len(); + if length == 0 { + break; + } + output_file.write_all(buffer)?; + data_value.consume(length); + pb.inc(length as u64); + } + output_file.flush()?; + drop(data_value); + + set_ntfs_timestamps(fs, &file, &dest_path).ok(); + } + } + + Ok(()) +} + +fn calculate_ntfs_size( + ntfs: &Ntfs, + fs: &mut T, + dir: &ntfs::NtfsFile, +) -> Result { + let mut total: u64 = 0; + let index = dir.directory_index(fs)?; + let mut iter = index.entries(); + + while let Some(entry) = iter.next(fs) { + let entry = entry?; + let file_name = entry + .key() + .ok_or_else(|| anyhow!("missing index entry key"))?; + let file_name = file_name?; + let name = file_name.name().to_string_lossy(); + + if name.starts_with('$') + || name == "." + || name == ".." + || name == "System Volume Information" + { + continue; + } + + let file = entry.to_file(ntfs, fs)?; + if file_name.is_directory() { + total += calculate_ntfs_size(ntfs, fs, &file)?; + } else if let Some(data_item) = file.data(fs, "") { + let data_item = data_item?; + let attr = data_item.to_attribute()?; + total += attr.value_length(); + } + } + + Ok(total) +} + +/// Extract all files from a VHD's NTFS filesystem into a folder, then delete the VHD. +pub fn extract_vhd(vhd_path: &Path) -> Result<()> { + let output_dir = vhd_path.with_extension(""); + + println!("Extracting VHD contents..."); + println!(" VHD: {}", vhd_path.display()); + println!(" Output: {}", output_dir.display()); + + let file = File::open(vhd_path)?; + let mut vhd = VhdReader::new(file).map_err(|e| anyhow!(e))?; + + let mut ntfs = Ntfs::new(&mut vhd)?; + ntfs.read_upcase_table(&mut vhd)?; + + let root = ntfs.root_directory(&mut vhd)?; + let total_size = calculate_ntfs_size(&ntfs, &mut vhd, &root)?; + + let pb = ProgressBar::new(total_size).with_style( + ProgressStyle::default_bar().template( + "{prefix} [{bar:20!.bright.yellow/dim.white}] {bytes:>8} [{elapsed}<{eta}, {bytes_per_sec}]", + )?, + ); + pb.set_prefix(format!( + "Extracting {}", + vhd_path.file_name().unwrap_or_default().to_string_lossy() + )); + + create_dir_all(&output_dir)?; + let root = ntfs.root_directory(&mut vhd)?; + extract_ntfs_dir(&ntfs, &mut vhd, &root, &output_dir, &pb)?; + + pb.finish(); + println!("Extracted to: {}", output_dir.display()); + + drop(vhd); + if let Err(e) = std::fs::remove_file(vhd_path) { + println!("WARNING: Could not delete VHD: {e}"); + } + + Ok(()) +}