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
This commit is contained in:
NK
2026-02-10 07:17:09 +01:00
parent dfc36becd5
commit f23b0b71bb
6 changed files with 345 additions and 15 deletions
+51 -4
View File
@@ -25,20 +25,30 @@ pub type PagefileRef<'a> = Option<&'a crate::paging::pagefile::PagefileReader>;
#[cfg(not(feature = "sam"))] #[cfg(not(feature = "sam"))]
pub type PagefileRef<'a> = (); 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. /// Find LSASS and extract all credentials.
/// When a pagefile reader is provided, paged-out memory is resolved from disk. /// 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<P: PhysicalMemory>( pub fn extract_all_credentials<P: PhysicalMemory>(
phys: &P, phys: &P,
lsass: &Process, lsass: &Process,
_kernel_dtb: u64, _kernel_dtb: u64,
pagefile: PagefileRef<'_>, pagefile: PagefileRef<'_>,
disk_path: DiskPathRef<'_>,
) -> Result<Vec<Credential>> { ) -> Result<Vec<Credential>> {
// Create virtual memory reader for LSASS (with optional pagefile resolution) // Create initial virtual memory reader for module enumeration
#[cfg(feature = "sam")] #[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"))] #[cfg(not(feature = "sam"))]
let lsass_vmem = { let lsass_vmem_init = {
let _ = pagefile; let _ = pagefile;
let _ = disk_path;
ProcessMemory::new(phys, lsass.dtb) ProcessMemory::new(phys, lsass.dtb)
}; };
@@ -50,7 +60,7 @@ pub fn extract_all_credentials<P: PhysicalMemory>(
); );
// Enumerate DLLs in LSASS // 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:"); log::debug!("LSASS modules:");
for m in &modules { for m in &modules {
@@ -62,6 +72,36 @@ pub fn extract_all_credentials<P: PhysicalMemory>(
); );
} }
// 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 { let dlls = LsassDlls {
lsasrv: find_module(&modules, "lsasrv.dll"), lsasrv: find_module(&modules, "lsasrv.dll"),
msv1_0: find_module(&modules, "msv1_0.dll"), msv1_0: find_module(&modules, "msv1_0.dll"),
@@ -295,6 +335,13 @@ pub fn extract_all_credentials<P: PhysicalMemory>(
msv_status, wdigest_status, kerberos_status, tspkg_status, dpapi_status, msv_status, wdigest_status, kerberos_status, tspkg_status, dpapi_status,
ssp_status, livessp_status, credman_status, cloudap_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 // Merge MSV credentials with unknown LUID (0) into matching credentials by username+domain
if let Some(orphan) = all_creds.remove(&0) { if let Some(orphan) = all_creds.remove(&0) {
+21 -9
View File
@@ -110,7 +110,8 @@ fn main() -> anyhow::Result<()> {
// LSASS credential extraction mode // LSASS credential extraction mode
#[cfg(feature = "sam")] #[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)) { match vmkatz::paging::pagefile::PagefileReader::open(Path::new(d)) {
Ok(pf) => { Ok(pf) => {
println!( println!(
@@ -120,15 +121,16 @@ fn main() -> anyhow::Result<()> {
Some(pf) Some(pf)
} }
Err(e) => { Err(e) => {
eprintln!("[!] Failed to open pagefile from {}: {}", d, e); log::info!("No pagefile from {}: {}", d, e);
None 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"))] #[cfg(not(feature = "sam"))]
run_lsass(input_path, &args, Default::default()) run_lsass(input_path, &args, Default::default(), Default::default())
} }
#[cfg(feature = "sam")] #[cfg(feature = "sam")]
@@ -246,11 +248,18 @@ fn run_directory(dir: &Path, args: &Args) -> anyhow::Result<()> {
#[cfg(not(feature = "sam"))] #[cfg(not(feature = "sam"))]
let pagefile: PagefileRef<'_> = Default::default(); 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"))] #[cfg(any(feature = "vmware", feature = "vbox"))]
for file in &discovery.lsass_files { for file in &discovery.lsass_files {
let name = file.file_name().unwrap_or_default().to_string_lossy(); let name = file.file_name().unwrap_or_default().to_string_lossy();
println!("\n[*] LSASS: {}", name); 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); eprintln!("[!] {}: {}", name, e);
} }
} }
@@ -272,7 +281,7 @@ fn run_directory(dir: &Path, args: &Args) -> anyhow::Result<()> {
Ok(()) 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 verbose = args.verbose || args.list_processes;
let ext = input_path.extension().and_then(|e| e.to_str()).unwrap_or(""); 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, args,
verbose, verbose,
pagefile, pagefile,
disk_path,
) )
} }
#[cfg(not(feature = "vbox"))] #[cfg(not(feature = "vbox"))]
{ {
let _ = pagefile; let _ = (pagefile, disk_path);
anyhow::bail!("VirtualBox .sav support not enabled (compile with --features vbox)") anyhow::bail!("VirtualBox .sav support not enabled (compile with --features vbox)")
} }
} else { } else {
@@ -330,11 +340,12 @@ fn run_lsass(input_path: &Path, args: &Args, pagefile: PagefileRef<'_>) -> anyho
args, args,
verbose, verbose,
pagefile, pagefile,
disk_path,
) )
} }
#[cfg(not(feature = "vmware"))] #[cfg(not(feature = "vmware"))]
{ {
let _ = pagefile; let _ = (pagefile, disk_path);
anyhow::bail!("VMware .vmem/.vmsn support not enabled (compile with --features vmware)") anyhow::bail!("VMware .vmem/.vmsn support not enabled (compile with --features vmware)")
} }
} }
@@ -346,6 +357,7 @@ fn run_with_layer<L: PhysicalMemory, F: FnOnce() -> anyhow::Result<L>>(
args: &Args, args: &Args,
verbose: bool, verbose: bool,
pagefile: PagefileRef<'_>, pagefile: PagefileRef<'_>,
disk_path: vmkatz::lsass::finder::DiskPathRef<'_>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let layer = make_layer()?; let layer = make_layer()?;
@@ -386,7 +398,7 @@ fn run_with_layer<L: PhysicalMemory, F: FnOnce() -> anyhow::Result<L>>(
// Extract credentials // Extract credentials
let 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")?; .context("Credential extraction failed")?;
// Report pagefile resolution stats // Report pagefile resolution stats
+251
View File
@@ -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<u8>, // page-aligned size
}
/// Resolves demand-paged DLL pages by serving data from on-disk PE files.
pub struct FileBackedResolver {
sections: Vec<BackedSection>, // sorted by va_start
total_bytes: usize,
pages_resolved: Cell<u64>,
}
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<Self> {
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<dyn disk::DiskImage>,
partition_offset: u64,
modules: &[LoadedModule],
sections: &mut Vec<BackedSection>,
) -> 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<R: Read + Seek>(
file: &ntfs::NtfsFile,
reader: &mut R,
module_base: u64,
) -> Result<Vec<BackedSection>> {
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<Vec<BackedSection>> {
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(&section.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()
}
}
+2
View File
@@ -1,4 +1,6 @@
pub mod entry; pub mod entry;
#[cfg(feature = "sam")] #[cfg(feature = "sam")]
pub mod filebacked;
#[cfg(feature = "sam")]
pub mod pagefile; pub mod pagefile;
pub mod translate; pub mod translate;
+19 -1
View File
@@ -367,12 +367,15 @@ impl<'a, P: PhysicalMemory> PageTableWalker<'a, P> {
/// Process virtual memory: combines a DTB (CR3) with physical memory for address translation. /// 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 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> { pub struct ProcessMemory<'a, P: PhysicalMemory> {
phys: &'a P, phys: &'a P,
walker: PageTableWalker<'a, P>, walker: PageTableWalker<'a, P>,
dtb: u64, dtb: u64,
#[cfg(feature = "sam")] #[cfg(feature = "sam")]
pagefile: Option<&'a crate::paging::pagefile::PagefileReader>, pagefile: Option<&'a crate::paging::pagefile::PagefileReader>,
#[cfg(feature = "sam")]
filebacked: Option<&'a crate::paging::filebacked::FileBackedResolver>,
} }
impl<'a, P: PhysicalMemory> ProcessMemory<'a, P> { impl<'a, P: PhysicalMemory> ProcessMemory<'a, P> {
@@ -383,20 +386,24 @@ impl<'a, P: PhysicalMemory> ProcessMemory<'a, P> {
dtb, dtb,
#[cfg(feature = "sam")] #[cfg(feature = "sam")]
pagefile: None, pagefile: None,
#[cfg(feature = "sam")]
filebacked: None,
} }
} }
#[cfg(feature = "sam")] #[cfg(feature = "sam")]
pub fn with_pagefile( pub fn with_resolvers(
phys: &'a P, phys: &'a P,
dtb: u64, dtb: u64,
pagefile: Option<&'a crate::paging::pagefile::PagefileReader>, pagefile: Option<&'a crate::paging::pagefile::PagefileReader>,
filebacked: Option<&'a crate::paging::filebacked::FileBackedResolver>,
) -> Self { ) -> Self {
Self { Self {
phys, phys,
walker: PageTableWalker::new(phys), walker: PageTableWalker::new(phys),
dtb, dtb,
pagefile, pagefile,
filebacked,
} }
} }
@@ -455,6 +462,17 @@ impl<'a, P: PhysicalMemory> VirtualMemory for ProcessMemory<'a, P> {
} }
} }
Err(ref e) => { 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); log::trace!("Page fault: {} at VA 0x{:x}", e, current_vaddr);
buf[offset..offset + chunk].fill(0); buf[offset..offset + chunk].fill(0);
} }
+1 -1
View File
@@ -356,7 +356,7 @@ pub(crate) fn find_entry<'n, R: Read + Seek>(
} }
/// Read file data ($DATA attribute) into a Vec<u8>. /// Read file data ($DATA attribute) into a Vec<u8>.
fn read_file_data<R: Read + Seek>( pub(crate) fn read_file_data<R: Read + Seek>(
file: &ntfs::NtfsFile, file: &ntfs::NtfsFile,
reader: &mut R, reader: &mut R,
) -> Result<Vec<u8>> { ) -> Result<Vec<u8>> {