From f23b0b71bb74d4c2d89220f7bb68dd731dd03b48 Mon Sep 17 00:00:00 2001 From: NK Date: Tue, 10 Feb 2026 07:17:09 +0100 Subject: [PATCH] Add file-backed DLL page resolution from disk images When Windows drops DLL .text pages from the working set, it zeros the PTE knowing the data can be re-read from the DLL file. This commit reads DLL files from the disk image via NTFS and serves those pages when a zero-PTE fault occurs in a known non-writable DLL section. Architecture: - FileBackedResolver reads PE files from disk, extracts non-writable sections (.text, .rdata), maps them to module_base + VirtualAddress - Binary search resolves VA to on-disk section data - Integrated into ProcessMemory::read_virt() as fallback on PageFault - Works synergistically with pagefile resolution (DLL .text enables pattern scans that discover structures whose data pages are in pagefile) Results on VMware test snapshots: - 472 sections loaded from 93 DLLs (~40 MB) - 12,020 DLL pages resolved from disk per snapshot - 2,235 pagefile pages resolved (up from 0 without file-backed) - New --disk flag for single-file mode, auto-discovered in folder mode --- src/lsass/finder.rs | 55 ++++++++- src/main.rs | 30 +++-- src/paging/filebacked.rs | 251 +++++++++++++++++++++++++++++++++++++++ src/paging/mod.rs | 2 + src/paging/translate.rs | 20 +++- src/sam/mod.rs | 2 +- 6 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 src/paging/filebacked.rs diff --git a/src/lsass/finder.rs b/src/lsass/finder.rs index ba86519..a8262c2 100644 --- a/src/lsass/finder.rs +++ b/src/lsass/finder.rs @@ -25,20 +25,30 @@ pub type PagefileRef<'a> = Option<&'a crate::paging::pagefile::PagefileReader>; #[cfg(not(feature = "sam"))] pub type PagefileRef<'a> = (); +/// Disk path reference type: wraps Option<&Path> when sam feature is enabled, +/// or () when not. Allows a unified function signature across feature configurations. +#[cfg(feature = "sam")] +pub type DiskPathRef<'a> = Option<&'a std::path::Path>; +#[cfg(not(feature = "sam"))] +pub type DiskPathRef<'a> = (); + /// Find LSASS and extract all credentials. /// When a pagefile reader is provided, paged-out memory is resolved from disk. +/// When a disk path is provided, demand-paged DLL sections are resolved from DLL files. pub fn extract_all_credentials( phys: &P, lsass: &Process, _kernel_dtb: u64, pagefile: PagefileRef<'_>, + disk_path: DiskPathRef<'_>, ) -> Result> { - // Create virtual memory reader for LSASS (with optional pagefile resolution) + // Create initial virtual memory reader for module enumeration #[cfg(feature = "sam")] - let lsass_vmem = ProcessMemory::with_pagefile(phys, lsass.dtb, pagefile); + let lsass_vmem_init = ProcessMemory::with_resolvers(phys, lsass.dtb, pagefile, None); #[cfg(not(feature = "sam"))] - let lsass_vmem = { + let lsass_vmem_init = { let _ = pagefile; + let _ = disk_path; ProcessMemory::new(phys, lsass.dtb) }; @@ -50,7 +60,7 @@ pub fn extract_all_credentials( ); // Enumerate DLLs in LSASS - let modules = peb::enumerate_modules(&lsass_vmem, lsass.peb_vaddr, &X64_LDR)?; + let modules = peb::enumerate_modules(&lsass_vmem_init, lsass.peb_vaddr, &X64_LDR)?; log::debug!("LSASS modules:"); for m in &modules { @@ -62,6 +72,36 @@ pub fn extract_all_credentials( ); } + // Build file-backed resolver from disk to serve demand-paged DLL sections + #[cfg(feature = "sam")] + let filebacked = disk_path.and_then(|p| { + match crate::paging::filebacked::FileBackedResolver::from_disk_and_modules(p, &modules) { + Ok(fb) if fb.section_count() > 0 => { + log::info!( + "File-backed: {} sections, {:.1} MB from {} DLLs", + fb.section_count(), + fb.total_bytes() as f64 / (1024.0 * 1024.0), + modules.len() + ); + Some(fb) + } + Ok(_) => { + log::info!("File-backed: no DLL sections loaded from disk"); + None + } + Err(e) => { + log::info!("File-backed resolver failed: {}", e); + None + } + } + }); + + // Create enhanced vmem with file-backed resolution for DLL sections + #[cfg(feature = "sam")] + let lsass_vmem = ProcessMemory::with_resolvers(phys, lsass.dtb, pagefile, filebacked.as_ref()); + #[cfg(not(feature = "sam"))] + let lsass_vmem = lsass_vmem_init; + let dlls = LsassDlls { lsasrv: find_module(&modules, "lsasrv.dll"), msv1_0: find_module(&modules, "msv1_0.dll"), @@ -295,6 +335,13 @@ pub fn extract_all_credentials( msv_status, wdigest_status, kerberos_status, tspkg_status, dpapi_status, ssp_status, livessp_status, credman_status, cloudap_status, ); + #[cfg(feature = "sam")] + if let Some(fb) = &filebacked { + let resolved = fb.pages_resolved(); + if resolved > 0 { + println!("[+] File-backed: {} DLL pages resolved from disk", resolved); + } + } // Merge MSV credentials with unknown LUID (0) into matching credentials by username+domain if let Some(orphan) = all_creds.remove(&0) { diff --git a/src/main.rs b/src/main.rs index cb7213f..94b7a1e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -110,7 +110,8 @@ fn main() -> anyhow::Result<()> { // LSASS credential extraction mode #[cfg(feature = "sam")] { - let pagefile_reader = args.disk.as_ref().and_then(|d| { + let disk_path_str = args.disk.clone(); + let pagefile_reader = disk_path_str.as_ref().and_then(|d| { match vmkatz::paging::pagefile::PagefileReader::open(Path::new(d)) { Ok(pf) => { println!( @@ -120,15 +121,16 @@ fn main() -> anyhow::Result<()> { Some(pf) } Err(e) => { - eprintln!("[!] Failed to open pagefile from {}: {}", d, e); + log::info!("No pagefile from {}: {}", d, e); None } } }); - return run_lsass(input_path, &args, pagefile_reader.as_ref()); + let disk_ref = disk_path_str.as_ref().map(|d| Path::new(d.as_str())); + return run_lsass(input_path, &args, pagefile_reader.as_ref(), disk_ref); } #[cfg(not(feature = "sam"))] - run_lsass(input_path, &args, Default::default()) + run_lsass(input_path, &args, Default::default(), Default::default()) } #[cfg(feature = "sam")] @@ -246,11 +248,18 @@ fn run_directory(dir: &Path, args: &Args) -> anyhow::Result<()> { #[cfg(not(feature = "sam"))] let pagefile: PagefileRef<'_> = Default::default(); + // Disk path for file-backed DLL resolution + #[cfg(feature = "sam")] + let disk_path: vmkatz::lsass::finder::DiskPathRef<'_> = + discovery.disk_files.first().map(|p| p.as_path()); + #[cfg(not(feature = "sam"))] + let disk_path: vmkatz::lsass::finder::DiskPathRef<'_> = Default::default(); + #[cfg(any(feature = "vmware", feature = "vbox"))] for file in &discovery.lsass_files { let name = file.file_name().unwrap_or_default().to_string_lossy(); println!("\n[*] LSASS: {}", name); - if let Err(e) = run_lsass(file, args, pagefile) { + if let Err(e) = run_lsass(file, args, pagefile, disk_path) { eprintln!("[!] {}: {}", name, e); } } @@ -272,7 +281,7 @@ fn run_directory(dir: &Path, args: &Args) -> anyhow::Result<()> { Ok(()) } -fn run_lsass(input_path: &Path, args: &Args, pagefile: PagefileRef<'_>) -> anyhow::Result<()> { +fn run_lsass(input_path: &Path, args: &Args, pagefile: PagefileRef<'_>, disk_path: vmkatz::lsass::finder::DiskPathRef<'_>) -> anyhow::Result<()> { let verbose = args.verbose || args.list_processes; let ext = input_path.extension().and_then(|e| e.to_str()).unwrap_or(""); @@ -294,11 +303,12 @@ fn run_lsass(input_path: &Path, args: &Args, pagefile: PagefileRef<'_>) -> anyho args, verbose, pagefile, + disk_path, ) } #[cfg(not(feature = "vbox"))] { - let _ = pagefile; + let _ = (pagefile, disk_path); anyhow::bail!("VirtualBox .sav support not enabled (compile with --features vbox)") } } else { @@ -330,11 +340,12 @@ fn run_lsass(input_path: &Path, args: &Args, pagefile: PagefileRef<'_>) -> anyho args, verbose, pagefile, + disk_path, ) } #[cfg(not(feature = "vmware"))] { - let _ = pagefile; + let _ = (pagefile, disk_path); anyhow::bail!("VMware .vmem/.vmsn support not enabled (compile with --features vmware)") } } @@ -346,6 +357,7 @@ fn run_with_layer anyhow::Result>( args: &Args, verbose: bool, pagefile: PagefileRef<'_>, + disk_path: vmkatz::lsass::finder::DiskPathRef<'_>, ) -> anyhow::Result<()> { let layer = make_layer()?; @@ -386,7 +398,7 @@ fn run_with_layer anyhow::Result>( // Extract credentials let credentials = - lsass::finder::extract_all_credentials(&layer, lsass_proc, system.dtb, pagefile) + lsass::finder::extract_all_credentials(&layer, lsass_proc, system.dtb, pagefile, disk_path) .context("Credential extraction failed")?; // Report pagefile resolution stats diff --git a/src/paging/filebacked.rs b/src/paging/filebacked.rs new file mode 100644 index 0000000..2abdbbd --- /dev/null +++ b/src/paging/filebacked.rs @@ -0,0 +1,251 @@ +//! File-backed page resolution for demand-paged DLL sections. +//! +//! When Windows removes DLL .text pages from the working set, it zeros the PTE +//! knowing the data can be re-read from the DLL file on disk. This module reads +//! DLL files from a disk image via NTFS and serves those pages on zero-PTE faults. + +use std::cell::Cell; +use std::io::{Read, Seek}; +use std::path::Path; + +use crate::disk; +use crate::error::{GovmemError, Result}; +use crate::windows::peb::LoadedModule; + +/// IMAGE_SCN_MEM_WRITE — skip writable sections (in-memory content differs from disk). +const SCN_MEM_WRITE: u32 = 0x8000_0000; + +/// Pre-read DLL section mapped to a virtual address range. +struct BackedSection { + va_start: u64, + data: Vec, // page-aligned size +} + +/// Resolves demand-paged DLL pages by serving data from on-disk PE files. +pub struct FileBackedResolver { + sections: Vec, // sorted by va_start + total_bytes: usize, + pages_resolved: Cell, +} + +impl FileBackedResolver { + /// Build a resolver by reading DLL files from a disk image's NTFS System32. + pub fn from_disk_and_modules(disk_path: &Path, modules: &[LoadedModule]) -> Result { + let mut disk = disk::open_disk(disk_path)?; + let partitions = crate::sam::find_ntfs_partitions(&mut disk)?; + + let mut sections = Vec::new(); + + for &partition_offset in &partitions { + match Self::try_load_from_partition(&mut disk, partition_offset, modules, &mut sections) + { + Ok(()) => break, + Err(e) => { + log::debug!( + "File-backed: partition 0x{:x}: {}", + partition_offset, + e + ); + } + } + } + + let total_bytes: usize = sections.iter().map(|s| s.data.len()).sum(); + sections.sort_by_key(|s| s.va_start); + + Ok(Self { + sections, + total_bytes, + pages_resolved: Cell::new(0), + }) + } + + fn try_load_from_partition( + disk: &mut Box, + partition_offset: u64, + modules: &[LoadedModule], + sections: &mut Vec, + ) -> Result<()> { + let mut part_reader = crate::sam::PartitionReader::new(disk, partition_offset); + + let ntfs = ntfs::Ntfs::new(&mut part_reader) + .map_err(|e| GovmemError::DecryptionError(format!("NTFS: {}", e)))?; + let root = ntfs + .root_directory(&mut part_reader) + .map_err(|e| GovmemError::DecryptionError(format!("NTFS root: {}", e)))?; + + // Navigate to Windows\System32 + let windows = crate::sam::find_entry(&ntfs, &root, &mut part_reader, "Windows")?; + let sys32 = crate::sam::find_entry(&ntfs, &windows, &mut part_reader, "System32")?; + + let mut loaded_count = 0usize; + for module in modules { + let dll_name = &module.base_name; + if dll_name.is_empty() { + continue; + } + + match crate::sam::find_entry(&ntfs, &sys32, &mut part_reader, dll_name) { + Ok(file) => { + match Self::read_pe_sections(&file, &mut part_reader, module.base) { + Ok(secs) => { + let bytes: usize = secs.iter().map(|s| s.data.len()).sum(); + log::debug!( + "File-backed: {} @ 0x{:x}: {} sections, {} KB", + dll_name, + module.base, + secs.len(), + bytes / 1024 + ); + loaded_count += 1; + sections.extend(secs); + } + Err(e) => { + log::debug!("File-backed: {} PE parse: {}", dll_name, e); + } + } + } + Err(_) => { + log::debug!("File-backed: {} not in System32", dll_name); + } + } + } + + if loaded_count > 0 { + Ok(()) + } else { + Err(GovmemError::DecryptionError( + "No DLLs found on disk".to_string(), + )) + } + } + + /// Read PE file from NTFS and extract non-writable section data. + fn read_pe_sections( + file: &ntfs::NtfsFile, + reader: &mut R, + module_base: u64, + ) -> Result> { + let pe_data = crate::sam::read_file_data(file, reader)?; + Self::parse_pe_sections(&pe_data, module_base) + } + + /// Parse PE headers, return non-writable sections mapped to module_base. + fn parse_pe_sections(pe_data: &[u8], module_base: u64) -> Result> { + if pe_data.len() < 0x40 { + return Err(GovmemError::DecryptionError("PE too small".to_string())); + } + + // DOS header + if u16::from_le_bytes([pe_data[0], pe_data[1]]) != 0x5A4D { + return Err(GovmemError::DecryptionError("Not a PE (no MZ)".to_string())); + } + let e_lfanew = u32::from_le_bytes(pe_data[0x3C..0x40].try_into().unwrap()) as usize; + if e_lfanew + 24 > pe_data.len() { + return Err(GovmemError::DecryptionError("Invalid e_lfanew".to_string())); + } + + // PE signature + if u32::from_le_bytes(pe_data[e_lfanew..e_lfanew + 4].try_into().unwrap()) != 0x0000_4550 + { + return Err(GovmemError::DecryptionError( + "Invalid PE signature".to_string(), + )); + } + + // COFF header + let num_sections = + u16::from_le_bytes(pe_data[e_lfanew + 6..e_lfanew + 8].try_into().unwrap()) as usize; + let opt_hdr_size = u16::from_le_bytes( + pe_data[e_lfanew + 20..e_lfanew + 22].try_into().unwrap(), + ) as usize; + + let section_table = e_lfanew + 24 + opt_hdr_size; + let mut sections = Vec::new(); + + for i in 0..num_sections { + let off = section_table + i * 40; + if off + 40 > pe_data.len() { + break; + } + + let virt_size = + u32::from_le_bytes(pe_data[off + 8..off + 12].try_into().unwrap()) as usize; + let virt_addr = + u32::from_le_bytes(pe_data[off + 12..off + 16].try_into().unwrap()) as u64; + let raw_size = + u32::from_le_bytes(pe_data[off + 16..off + 20].try_into().unwrap()) as usize; + let raw_offset = + u32::from_le_bytes(pe_data[off + 20..off + 24].try_into().unwrap()) as usize; + let characteristics = + u32::from_le_bytes(pe_data[off + 36..off + 40].try_into().unwrap()); + + // Skip writable sections (in-memory content modified by process) + if characteristics & SCN_MEM_WRITE != 0 { + continue; + } + if raw_size == 0 || raw_offset == 0 || virt_size == 0 { + continue; + } + if raw_offset.saturating_add(raw_size) > pe_data.len() { + continue; + } + + // Copy raw data, page-aligned to VirtualSize + let copy_size = std::cmp::min(raw_size, virt_size); + let padded_size = (virt_size + 0xFFF) & !0xFFF; + let mut data = vec![0u8; padded_size]; + data[..copy_size].copy_from_slice(&pe_data[raw_offset..raw_offset + copy_size]); + + sections.push(BackedSection { + va_start: module_base + virt_addr, + data, + }); + } + + Ok(sections) + } + + /// Resolve a page from file-backed DLL data. Returns page contents if the + /// virtual address falls within a known non-writable DLL section. + pub fn resolve_page(&self, vaddr: u64) -> Option<[u8; 4096]> { + let page_base = vaddr & !0xFFF; + + let idx = match self.sections.binary_search_by(|s| { + let s_end = s.va_start + s.data.len() as u64; + if page_base < s.va_start { + std::cmp::Ordering::Greater + } else if page_base >= s_end { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }) { + Ok(i) => i, + Err(_) => return None, + }; + + let section = &self.sections[idx]; + let offset = (page_base - section.va_start) as usize; + if offset + 4096 > section.data.len() { + return None; + } + + let mut page = [0u8; 4096]; + page.copy_from_slice(§ion.data[offset..offset + 4096]); + self.pages_resolved.set(self.pages_resolved.get() + 1); + Some(page) + } + + pub fn pages_resolved(&self) -> u64 { + self.pages_resolved.get() + } + + pub fn total_bytes(&self) -> usize { + self.total_bytes + } + + pub fn section_count(&self) -> usize { + self.sections.len() + } +} diff --git a/src/paging/mod.rs b/src/paging/mod.rs index d1262cd..74971b4 100644 --- a/src/paging/mod.rs +++ b/src/paging/mod.rs @@ -1,4 +1,6 @@ pub mod entry; #[cfg(feature = "sam")] +pub mod filebacked; +#[cfg(feature = "sam")] pub mod pagefile; pub mod translate; diff --git a/src/paging/translate.rs b/src/paging/translate.rs index e894573..04ab8c4 100644 --- a/src/paging/translate.rs +++ b/src/paging/translate.rs @@ -367,12 +367,15 @@ impl<'a, P: PhysicalMemory> PageTableWalker<'a, P> { /// Process virtual memory: combines a DTB (CR3) with physical memory for address translation. /// Optional pagefile reader resolves pages swapped to pagefile.sys on disk. +/// Optional file-backed resolver serves demand-paged DLL sections from disk. pub struct ProcessMemory<'a, P: PhysicalMemory> { phys: &'a P, walker: PageTableWalker<'a, P>, dtb: u64, #[cfg(feature = "sam")] pagefile: Option<&'a crate::paging::pagefile::PagefileReader>, + #[cfg(feature = "sam")] + filebacked: Option<&'a crate::paging::filebacked::FileBackedResolver>, } impl<'a, P: PhysicalMemory> ProcessMemory<'a, P> { @@ -383,20 +386,24 @@ impl<'a, P: PhysicalMemory> ProcessMemory<'a, P> { dtb, #[cfg(feature = "sam")] pagefile: None, + #[cfg(feature = "sam")] + filebacked: None, } } #[cfg(feature = "sam")] - pub fn with_pagefile( + pub fn with_resolvers( phys: &'a P, dtb: u64, pagefile: Option<&'a crate::paging::pagefile::PagefileReader>, + filebacked: Option<&'a crate::paging::filebacked::FileBackedResolver>, ) -> Self { Self { phys, walker: PageTableWalker::new(phys), dtb, pagefile, + filebacked, } } @@ -455,6 +462,17 @@ impl<'a, P: PhysicalMemory> VirtualMemory for ProcessMemory<'a, P> { } } Err(ref e) => { + // Try file-backed resolution for demand-paged DLL sections + #[cfg(feature = "sam")] + if let Some(fb) = self.filebacked { + if let Some(page_data) = fb.resolve_page(current_vaddr) { + let page_off = (current_vaddr & 0xFFF) as usize; + buf[offset..offset + chunk] + .copy_from_slice(&page_data[page_off..page_off + chunk]); + offset += chunk; + continue; + } + } log::trace!("Page fault: {} at VA 0x{:x}", e, current_vaddr); buf[offset..offset + chunk].fill(0); } diff --git a/src/sam/mod.rs b/src/sam/mod.rs index 40f1726..ae2fa0a 100644 --- a/src/sam/mod.rs +++ b/src/sam/mod.rs @@ -356,7 +356,7 @@ pub(crate) fn find_entry<'n, R: Read + Seek>( } /// Read file data ($DATA attribute) into a Vec. -fn read_file_data( +pub(crate) fn read_file_data( file: &ntfs::NtfsFile, reader: &mut R, ) -> Result> {