Add process memory dump as minidump (.dmp) for pypykatz comparison

New --dump <process> flag exports a process's virtual memory as a Windows
minidump file compatible with pypykatz (lsa minidump command).

Writes 3 streams: SystemInfoStream, ModuleListStream, Memory64ListStream.
Captures present+transition+pagefile PTEs plus module VA ranges.
Supports pagefile and file-backed DLL resolution via --disk flag.

Verified: pypykatz extracts identical NT hash from our dump as our direct
extraction (bbf7d1528afa8b0fdd40a5b2531bbb6d on VMware Win10 snapshot).
This commit is contained in:
NK
2026-02-10 12:26:06 +01:00
parent cef6c57678
commit 56f827e9ee
3 changed files with 442 additions and 0 deletions
+383
View File
@@ -0,0 +1,383 @@
//! Process memory dump in Windows minidump format.
//!
//! Produces minidump files compatible with pypykatz and other analysis tools.
//! Writes 3 streams: SystemInfoStream, ModuleListStream, Memory64ListStream.
use std::collections::BTreeSet;
use std::io::{BufWriter, Write};
use std::path::Path;
use crate::error::{GovmemError, Result};
use crate::lsass::finder::{DiskPathRef, PagefileRef};
use crate::memory::{PhysicalMemory, VirtualMemory};
use crate::paging::entry::PageTableEntry;
use crate::paging::translate::ProcessMemory;
use crate::windows::offsets::X64_LDR;
use crate::windows::peb::{self, LoadedModule};
use crate::windows::process::Process;
// Minidump constants
const MINIDUMP_SIGNATURE: u32 = 0x504D_444D; // "MDMP"
const MINIDUMP_VERSION: u32 = 0x0000_A793;
const STREAM_TYPE_SYSTEM_INFO: u32 = 7;
const STREAM_TYPE_MODULE_LIST: u32 = 4;
const STREAM_TYPE_MEMORY64_LIST: u32 = 9;
const PROCESSOR_ARCHITECTURE_AMD64: u16 = 9;
const VER_PLATFORM_WIN32_NT: u32 = 2;
/// A contiguous virtual memory region.
struct MemoryRegion {
start_va: u64,
size: u64,
}
/// Dump a process's virtual memory as a Windows minidump (.dmp) file.
///
/// Compatible with `pypykatz lsa minidump <file>` for LSASS credential extraction.
pub fn dump_process<P: PhysicalMemory>(
phys: &P,
process: &Process,
build_number: u32,
output_path: &Path,
pagefile: PagefileRef<'_>,
disk_path: DiskPathRef<'_>,
) -> Result<()> {
// Create initial virtual memory reader for module enumeration
#[cfg(feature = "sam")]
let vmem_init = ProcessMemory::with_resolvers(phys, process.dtb, pagefile, None);
#[cfg(not(feature = "sam"))]
let vmem_init = {
let _ = (pagefile, disk_path);
ProcessMemory::new(phys, process.dtb)
};
// Enumerate loaded modules from PEB
let modules = if process.peb_vaddr != 0 {
match peb::enumerate_modules(&vmem_init, process.peb_vaddr, &X64_LDR) {
Ok(m) => {
log::info!("Dump: enumerated {} modules for {}", m.len(), process.name);
m
}
Err(e) => {
log::warn!("Module enumeration failed: {}", e);
Vec::new()
}
}
} else {
Vec::new()
};
// Build file-backed resolver from disk for 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!(
"Dump file-backed: {} sections, {:.1} MB",
fb.section_count(),
fb.total_bytes() as f64 / (1024.0 * 1024.0),
);
Some(fb)
}
Ok(_) => None,
Err(e) => {
log::info!("Dump file-backed unavailable: {}", e);
None
}
}
});
// Full ProcessMemory with all resolvers
#[cfg(feature = "sam")]
let vmem = ProcessMemory::with_resolvers(phys, process.dtb, pagefile, filebacked.as_ref());
#[cfg(not(feature = "sam"))]
let vmem = ProcessMemory::new(phys, process.dtb);
// Collect all user-mode page VAs (present + transition + pagefile PTEs)
let mut page_vas: BTreeSet<u64> = BTreeSet::new();
collect_all_user_pages(phys, process.dtb, &mut page_vas);
let pt_count = page_vas.len();
// Add module VA ranges for file-backed DLL page resolution
for m in &modules {
let page_count = (m.size as u64).div_ceil(0x1000);
for i in 0..page_count {
page_vas.insert(m.base + i * 0x1000);
}
}
log::info!(
"Dump pages: {} from page tables, {} total with modules",
pt_count,
page_vas.len()
);
// Coalesce into contiguous regions
let sorted_vas: Vec<u64> = page_vas.into_iter().collect();
let regions = coalesce_pages(&sorted_vas);
log::info!("Dump: {} contiguous memory regions", regions.len());
// Write minidump
write_minidump(output_path, &vmem, &modules, &regions, build_number)?;
// Report pagefile stats
#[cfg(feature = "sam")]
if let Some(pf) = pagefile {
let resolved = pf.pages_resolved();
if resolved > 0 {
log::info!("Dump: {} pagefile pages resolved", resolved);
}
}
Ok(())
}
/// Walk page tables collecting all user-mode VAs with valid PTEs.
/// Includes present, transition, and pagefile PTEs (not just present+transition).
fn collect_all_user_pages<P: PhysicalMemory>(
phys: &P,
cr3: u64,
pages: &mut BTreeSet<u64>,
) {
let pml4_base = cr3 & 0x000F_FFFF_FFFF_F000;
for pml4_idx in 0..256u64 {
let pml4e = match phys.read_phys_u64(pml4_base + pml4_idx * 8) {
Ok(v) => PageTableEntry(v),
Err(_) => continue,
};
if !pml4e.is_present() {
continue;
}
let pdpt_base = pml4e.frame_addr();
for pdpt_idx in 0..512u64 {
let pdpte = match phys.read_phys_u64(pdpt_base + pdpt_idx * 8) {
Ok(v) => PageTableEntry(v),
Err(_) => continue,
};
if !pdpte.is_present() {
continue;
}
if pdpte.is_large_page() {
let base_va = (pml4_idx << 39) | (pdpt_idx << 30);
for i in 0..(0x4000_0000u64 / 0x1000) {
pages.insert(base_va + i * 0x1000);
}
continue;
}
let pd_base = pdpte.frame_addr();
for pd_idx in 0..512u64 {
let pde = match phys.read_phys_u64(pd_base + pd_idx * 8) {
Ok(v) => PageTableEntry(v),
Err(_) => continue,
};
if !pde.is_present() {
continue;
}
if pde.is_large_page() {
let base_va =
(pml4_idx << 39) | (pdpt_idx << 30) | (pd_idx << 21);
for i in 0..512u64 {
pages.insert(base_va + i * 0x1000);
}
continue;
}
let pt_base = pde.frame_addr();
for pt_idx in 0..512u64 {
let pte = match phys.read_phys_u64(pt_base + pt_idx * 8) {
Ok(v) => PageTableEntry(v),
Err(_) => continue,
};
// Include present, transition, and pagefile PTEs
if pte.is_present() || pte.is_transition() || pte.is_pagefile() {
let va = (pml4_idx << 39)
| (pdpt_idx << 30)
| (pd_idx << 21)
| (pt_idx << 12);
pages.insert(va);
}
}
}
}
}
}
/// Coalesce sorted page VAs into contiguous memory regions.
fn coalesce_pages(sorted_vas: &[u64]) -> Vec<MemoryRegion> {
let mut regions = Vec::new();
if sorted_vas.is_empty() {
return regions;
}
let mut start = sorted_vas[0];
let mut end = start + 0x1000;
for &va in &sorted_vas[1..] {
if va == end {
end += 0x1000;
} else {
regions.push(MemoryRegion {
start_va: start,
size: end - start,
});
start = va;
end = va + 0x1000;
}
}
regions.push(MemoryRegion {
start_va: start,
size: end - start,
});
regions
}
/// Write the minidump file with 3 streams.
fn write_minidump(
output_path: &Path,
vmem: &impl VirtualMemory,
modules: &[LoadedModule],
regions: &[MemoryRegion],
build_number: u32,
) -> Result<()> {
let file = std::fs::File::create(output_path).map_err(GovmemError::Io)?;
let mut w = BufWriter::new(file);
// === Layout computation ===
let header_size = 32u32;
let dir_size = 3u32 * 12;
let sysinfo_rva = header_size + dir_size; // 0x44
let sysinfo_size = 56u32;
// CSD version string (empty MINIDUMP_STRING: Length=0 + null terminator)
let csd_rva = sysinfo_rva + sysinfo_size;
let csd_size = 4u32 + 2;
// ModuleListStream: 4-byte count + N * 108-byte entries
let modlist_rva = csd_rva + csd_size;
let modlist_data_size = 4 + modules.len() as u32 * 108;
// Module name strings placed after module entries
let names_base = modlist_rva + modlist_data_size;
let mut name_entries: Vec<(u32, Vec<u16>)> = Vec::new();
let mut names_offset = 0u32;
for m in modules {
let rva = names_base + names_offset;
let utf16: Vec<u16> = m.full_name.encode_utf16().collect();
// MINIDUMP_STRING: Length(4) + UTF-16LE data + null(2)
names_offset += 4 + utf16.len() as u32 * 2 + 2;
name_entries.push((rva, utf16));
}
// Memory64ListStream
let mem64_rva = names_base + names_offset;
let mem64_header = 16u64; // NumberOfMemoryRanges(8) + BaseRva(8)
let mem64_descs = regions.len() as u64 * 16;
let mem64_list_size = mem64_header + mem64_descs;
let memory_data_rva = mem64_rva as u64 + mem64_list_size;
// === MINIDUMP_HEADER (32 bytes) ===
w.write_all(&MINIDUMP_SIGNATURE.to_le_bytes())?;
w.write_all(&MINIDUMP_VERSION.to_le_bytes())?;
w.write_all(&3u32.to_le_bytes())?; // NumberOfStreams
w.write_all(&header_size.to_le_bytes())?; // StreamDirectoryRva (dir follows header)
w.write_all(&0u32.to_le_bytes())?; // CheckSum
w.write_all(&0u32.to_le_bytes())?; // TimeDateStamp
w.write_all(&2u64.to_le_bytes())?; // Flags = MiniDumpWithFullMemory
// === MINIDUMP_DIRECTORY[3] (36 bytes) ===
w.write_all(&STREAM_TYPE_SYSTEM_INFO.to_le_bytes())?;
w.write_all(&sysinfo_size.to_le_bytes())?;
w.write_all(&sysinfo_rva.to_le_bytes())?;
w.write_all(&STREAM_TYPE_MODULE_LIST.to_le_bytes())?;
w.write_all(&modlist_data_size.to_le_bytes())?;
w.write_all(&modlist_rva.to_le_bytes())?;
w.write_all(&STREAM_TYPE_MEMORY64_LIST.to_le_bytes())?;
w.write_all(&(mem64_list_size as u32).to_le_bytes())?;
w.write_all(&mem64_rva.to_le_bytes())?;
// === SystemInfoStream (56 bytes) ===
w.write_all(&PROCESSOR_ARCHITECTURE_AMD64.to_le_bytes())?;
w.write_all(&0u16.to_le_bytes())?; // ProcessorLevel
w.write_all(&0u16.to_le_bytes())?; // ProcessorRevision
w.write_all(&[1u8])?; // NumberOfProcessors
w.write_all(&[1u8])?; // ProductType = VER_NT_WORKSTATION
w.write_all(&10u32.to_le_bytes())?; // MajorVersion (Windows 10)
w.write_all(&0u32.to_le_bytes())?; // MinorVersion
w.write_all(&build_number.to_le_bytes())?;
w.write_all(&VER_PLATFORM_WIN32_NT.to_le_bytes())?;
w.write_all(&csd_rva.to_le_bytes())?; // CSDVersionRva
w.write_all(&0u16.to_le_bytes())?; // SuiteMask
w.write_all(&0u16.to_le_bytes())?; // Reserved2
w.write_all(&[0u8; 24])?; // CPU_INFORMATION (zeroed)
// === CSD version string (6 bytes) ===
w.write_all(&0u32.to_le_bytes())?; // Length = 0
w.write_all(&0u16.to_le_bytes())?; // Null terminator
// === ModuleListStream ===
w.write_all(&(modules.len() as u32).to_le_bytes())?;
for (i, m) in modules.iter().enumerate() {
// MINIDUMP_MODULE: 108 bytes total
w.write_all(&m.base.to_le_bytes())?; // BaseOfImage (8)
w.write_all(&m.size.to_le_bytes())?; // SizeOfImage (4)
w.write_all(&0u32.to_le_bytes())?; // CheckSum (4)
w.write_all(&0u32.to_le_bytes())?; // TimeDateStamp (4)
w.write_all(&name_entries[i].0.to_le_bytes())?; // ModuleNameRva (4)
w.write_all(&[0u8; 52])?; // VS_FIXEDFILEINFO (52)
w.write_all(&[0u8; 8])?; // CvRecord (8)
w.write_all(&[0u8; 8])?; // MiscRecord (8)
w.write_all(&[0u8; 16])?; // Reserved0 + Reserved1 (16)
}
// Module name strings
for (_, utf16) in &name_entries {
let byte_len = utf16.len() as u32 * 2;
w.write_all(&byte_len.to_le_bytes())?; // Length (bytes, excludes null)
for &ch in utf16 {
w.write_all(&ch.to_le_bytes())?;
}
w.write_all(&0u16.to_le_bytes())?; // Null terminator
}
// === Memory64ListStream ===
w.write_all(&(regions.len() as u64).to_le_bytes())?;
w.write_all(&memory_data_rva.to_le_bytes())?;
for region in regions {
w.write_all(&region.start_va.to_le_bytes())?;
w.write_all(&region.size.to_le_bytes())?;
}
// === Memory data (sequential pages) ===
let mut page_buf = [0u8; 4096];
let mut total_pages = 0u64;
for region in regions {
let page_count = region.size / 0x1000;
for i in 0..page_count {
let va = region.start_va + i * 0x1000;
if vmem.read_virt(va, &mut page_buf).is_err() {
page_buf.fill(0);
}
w.write_all(&page_buf)?;
total_pages += 1;
}
}
w.flush()?;
let file_size = memory_data_rva + total_pages * 0x1000;
log::info!(
"Minidump written: {} regions, {} pages, {:.1} MB → {}",
regions.len(),
total_pages,
file_size as f64 / (1024.0 * 1024.0),
output_path.display(),
);
Ok(())
}
+1
View File
@@ -13,3 +13,4 @@ pub mod disk;
#[cfg(feature = "sam")]
pub mod sam;
pub mod discover;
pub mod dump;
+58
View File
@@ -41,6 +41,8 @@ use vmkatz::windows::process;
vmkatz disk.vdi Extract SAM hashes + LSA secrets\n \
vmkatz /path/to/vm/directory/ Auto-discover and process all files\n \
vmkatz --list-processes snapshot.vmsn List running processes only\n \
vmkatz --dump lsass snapshot.vmsn Dump LSASS as minidump for pypykatz\n \
vmkatz --dump lsass -o out.dmp snap.vmsn Dump with custom output filename\n \
vmkatz -v snapshot.vmsn Verbose output with process list",
)]
struct Args {
@@ -62,6 +64,18 @@ struct Args {
#[arg(long, value_name = "DISK_IMAGE")]
disk: Option<String>,
/// Dump a process's virtual memory as minidump (.dmp) file
#[arg(long, value_name = "PROCESS_NAME")]
dump: Option<String>,
/// Output file for --dump (default: <process>.dmp)
#[arg(short, long, value_name = "FILE")]
output: Option<String>,
/// Windows build number for minidump header (default: 19045)
#[arg(long, default_value_t = 19045, value_name = "NUMBER")]
build: u32,
/// Output format
#[arg(long, default_value = "text", value_name = "FORMAT", value_parser = ["text", "csv", "ntlm"])]
format: String,
@@ -397,6 +411,32 @@ fn run_with_layer<L: PhysicalMemory, F: FnOnce() -> anyhow::Result<L>>(
return Ok(());
}
// Process dump mode
if let Some(ref dump_name) = args.dump {
let target = find_process_by_name(&processes, dump_name)
.ok_or_else(|| anyhow::anyhow!("Process '{}' not found in process list", dump_name))?;
let default_output = format!("{}.dmp", dump_name.to_lowercase().trim_end_matches(".exe"));
let output = args.output.as_deref().unwrap_or(&default_output);
let output_path = std::path::Path::new(output);
println!(
"[*] Dumping {} (PID={}, DTB=0x{:x})...",
target.name, target.pid, target.dtb
);
vmkatz::dump::dump_process(&layer, target, args.build, output_path, pagefile, disk_path)?;
let file_size = std::fs::metadata(output_path).map(|m| m.len()).unwrap_or(0);
println!(
"[+] Dumped {} → {} ({:.1} MB)",
target.name,
output,
file_size as f64 / (1024.0 * 1024.0)
);
return Ok(());
}
// Find LSASS
let lsass_proc = processes
.iter()
@@ -433,6 +473,24 @@ fn run_with_layer<L: PhysicalMemory, F: FnOnce() -> anyhow::Result<L>>(
Ok(())
}
#[cfg(any(feature = "vmware", feature = "vbox"))]
fn find_process_by_name<'a>(
processes: &'a [vmkatz::windows::process::Process],
name: &str,
) -> Option<&'a vmkatz::windows::process::Process> {
// Try exact match (case-insensitive)
processes
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
.or_else(|| {
// Try with .exe appended
let with_exe = format!("{}.exe", name);
processes
.iter()
.find(|p| p.name.eq_ignore_ascii_case(&with_exe))
})
}
#[cfg(any(feature = "vmware", feature = "vbox"))]
fn print_text(credentials: &[Credential]) {
let with_creds = credentials.iter().filter(|c| c.has_credentials()).count();