mirror of
https://github.com/nikaiw/VMkatz
synced 2026-09-25 07:51:06 +00:00
LSASS credential extraction: - Minidump (.dmp) support: full MSV/Kerberos/DPAPI/WDigest/TsPkg/SSP/ LiveSSP/CredMan/CloudAP extraction from LSASS minidumps - Pre-Vista (WinXP/Win2003): 32-bit EPROCESS, PAE paging, DES-X-CBC/RC4 - Win11 24H2 (26100+): correct EPROCESS offsets, MSV LIST_64/LIST_65, Kerberos variant with shifted offsets - All MSV/SSP/LiveSSP/CloudAP/CredMan/WDigest offsets verified against mimikatz C structs and pypykatz templates - EPROCESS offsets verified against Vergilius Project (13 variants) - AES-CFB-128 cipher for non-8-aligned LSASS blobs - DPAPI extraction from dpapisrv.dll (Win10 19041+ moved g_MasterKeyCacheList) - Adaptive MSV offset discovery with build-number-aware variant ordering - MSV NT hash fix: validated variant tracking + DPAPI cross-check for human accounts (SHA1 validation only works for machine accounts) - Kerberos: AES/DES/RC4 key extraction, kirbi/ccache export, ticket quality validation, false positive filtering - CloudAP: PRT blob extraction, 7 patterns covering Win10 1507–Win11 24H2 - CredMan: correct 2-level navigation (SET_LIST→STARTER→entry) - Garbage filtering: repeating pattern detection, structural score validation, unknown etype rejection Architecture: - Carve mode: two-level degraded extraction for truncated memory files - sam/mod.rs split into 4 submodules (partition, ntfs_reader, disk_fallbacks, vmdk_scan) - ProviderStatus enum replacing string-based status tracking - Safe read helpers (utils.rs) replacing 86 try_into().unwrap() calls - GovmemError renamed to VmkatzError across all 35 source files - Named paging constants (PAGE_PHYS_MASK, LARGE_*_MASK) Performance: - TLB cache for page table translation (256-entry direct-mapped) - QCOW2 L2 table caching (64 tables, amortized I/O) - VMware region binary search (partition_point) - Stack-allocated ASN.1 length encoding, IV entropy histogram - Single-pass System process + EPT scanning - memchr::memmem for pattern matching Robustness: - Minidump parser hardening (bounds checks, overflow protection) - PE32 validation (machine type, section count, optional header size) - Multiple pagefile support (PTE pagefile_number routing) - VMware embedded memory support (.vmss/.vmsn without .vmem) - EPT false positive prevention (reserved bits, PDPT validation) - VMEM truncation detection with user warning Testing: - 8 automated tests (4 unit + 4 integration) - Non-regression framework: compare.py + esxi_test.sh - All credentials verified against pypykatz on 10+ minidumps CLI: - --all/-a: show empty sessions (hidden by default) - --no-ept: skip EPT scanning - --kirbi/--ccache: Kerberos ticket export - Silent output modes: ntlm, hashcat, text summary - Hex display for non-printable machine account passwords
171 lines
6.1 KiB
Rust
171 lines
6.1 KiB
Rust
use std::path::Path;
|
||
use vmkatz::minidump::Minidump;
|
||
use vmkatz::memory::VirtualMemory;
|
||
|
||
/// Synthetic minidump: header + 1 SystemInfoStream + 1 Memory64ListStream with 1 region.
|
||
fn make_test_minidump() -> Vec<u8> {
|
||
let mut data = Vec::new();
|
||
|
||
// Header (32 bytes)
|
||
data.extend_from_slice(&0x504D_444Du32.to_le_bytes()); // Signature "MDMP"
|
||
data.extend_from_slice(&0x0000_A793u32.to_le_bytes()); // Version
|
||
data.extend_from_slice(&2u32.to_le_bytes()); // NumberOfStreams
|
||
data.extend_from_slice(&32u32.to_le_bytes()); // StreamDirectoryRva
|
||
data.extend_from_slice(&0u32.to_le_bytes()); // CheckSum
|
||
data.extend_from_slice(&0u32.to_le_bytes()); // TimeDateStamp
|
||
data.extend_from_slice(&2u64.to_le_bytes()); // Flags
|
||
|
||
// Stream directory (2 entries × 12 bytes = 24 bytes, starts at offset 32)
|
||
let sysinfo_rva = 32 + 24; // 56
|
||
let sysinfo_size = 56u32;
|
||
data.extend_from_slice(&7u32.to_le_bytes()); // STREAM_TYPE_SYSTEM_INFO
|
||
data.extend_from_slice(&sysinfo_size.to_le_bytes());
|
||
data.extend_from_slice(&(sysinfo_rva as u32).to_le_bytes());
|
||
|
||
let mem64_rva = sysinfo_rva + sysinfo_size as usize; // 112
|
||
let mem64_size = 16 + 16; // header(16) + 1 descriptor(16) = 32
|
||
data.extend_from_slice(&9u32.to_le_bytes()); // STREAM_TYPE_MEMORY64_LIST
|
||
data.extend_from_slice(&(mem64_size as u32).to_le_bytes());
|
||
data.extend_from_slice(&(mem64_rva as u32).to_le_bytes());
|
||
|
||
// SystemInfoStream (56 bytes at offset 56)
|
||
data.extend_from_slice(&9u16.to_le_bytes()); // ProcessorArchitecture = AMD64
|
||
data.extend_from_slice(&0u16.to_le_bytes()); // ProcessorLevel
|
||
data.extend_from_slice(&0u16.to_le_bytes()); // ProcessorRevision
|
||
data.extend_from_slice(&[1u8]); // NumberOfProcessors
|
||
data.extend_from_slice(&[1u8]); // ProductType
|
||
data.extend_from_slice(&10u32.to_le_bytes()); // MajorVersion
|
||
data.extend_from_slice(&0u32.to_le_bytes()); // MinorVersion
|
||
data.extend_from_slice(&19045u32.to_le_bytes()); // BuildNumber
|
||
data.extend_from_slice(&2u32.to_le_bytes()); // PlatformId
|
||
data.extend_from_slice(&0u32.to_le_bytes()); // CSDVersionRva
|
||
data.extend_from_slice(&0u16.to_le_bytes()); // SuiteMask
|
||
data.extend_from_slice(&0u16.to_le_bytes()); // Reserved2
|
||
data.extend_from_slice(&[0u8; 24]); // CPU_INFORMATION
|
||
|
||
// Memory64ListStream at offset 112
|
||
let memory_data_start = mem64_rva + mem64_size; // 144
|
||
data.extend_from_slice(&1u64.to_le_bytes()); // NumberOfMemoryRanges
|
||
data.extend_from_slice(&(memory_data_start as u64).to_le_bytes()); // BaseRva
|
||
|
||
data.extend_from_slice(&0x1000u64.to_le_bytes()); // StartOfMemoryRange
|
||
data.extend_from_slice(&8u64.to_le_bytes()); // DataSize
|
||
|
||
// Memory data: 8 bytes at offset 144
|
||
data.extend_from_slice(&0xDEAD_BEEF_CAFE_BABEu64.to_le_bytes());
|
||
|
||
data
|
||
}
|
||
|
||
#[test]
|
||
fn parse_synthetic_minidump() {
|
||
let data = make_test_minidump();
|
||
let mdmp = Minidump::parse(data).expect("parse failed");
|
||
|
||
assert_eq!(mdmp.build_number, 19045);
|
||
assert_eq!(mdmp.major_version, 10);
|
||
assert_eq!(mdmp.minor_version, 0);
|
||
assert_eq!(mdmp.modules.len(), 0);
|
||
|
||
// Read back the memory region
|
||
let val = mdmp.read_virt_u64(0x1000).expect("read failed");
|
||
assert_eq!(val, 0xDEAD_BEEF_CAFE_BABE);
|
||
}
|
||
|
||
#[test]
|
||
fn read_outside_region_fails() {
|
||
let data = make_test_minidump();
|
||
let mdmp = Minidump::parse(data).expect("parse failed");
|
||
|
||
// Address outside any region should fail
|
||
assert!(mdmp.read_virt_u64(0x2000).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn parse_real_minidump() {
|
||
let path = Path::new("lsass.dmp");
|
||
if !path.exists() {
|
||
return;
|
||
}
|
||
|
||
let mdmp = Minidump::open(path).expect("failed to parse lsass.dmp");
|
||
|
||
assert!(mdmp.build_number > 0, "build number should be > 0");
|
||
assert!(!mdmp.modules.is_empty(), "should have modules");
|
||
assert!(
|
||
mdmp.modules.iter().any(|m| !m.full_name.is_empty()),
|
||
"at least some modules should have names"
|
||
);
|
||
|
||
let has_lsasrv = mdmp.modules.iter().any(|m| m.base_name == "lsasrv.dll");
|
||
assert!(has_lsasrv, "lsasrv.dll should be in module list");
|
||
|
||
let lsasrv = mdmp.modules.iter().find(|m| m.base_name == "lsasrv.dll").unwrap();
|
||
let mz = mdmp.read_virt_u16(lsasrv.base).expect("should read MZ header");
|
||
assert_eq!(mz, 0x5A4D, "lsasrv.dll should start with MZ");
|
||
}
|
||
|
||
/// End-to-end credential extraction from lsass.dmp.
|
||
/// Validates that vmkatz extracts the same NT hash as pypykatz.
|
||
#[test]
|
||
fn extract_credentials_from_real_minidump() {
|
||
let path = Path::new("lsass.dmp");
|
||
if !path.exists() {
|
||
return;
|
||
}
|
||
|
||
let mdmp = Minidump::open(path).expect("failed to parse lsass.dmp");
|
||
let region_ranges = mdmp.region_ranges();
|
||
let credentials = vmkatz::lsass::finder::extract_credentials_from_minidump(
|
||
&mdmp,
|
||
&mdmp.modules,
|
||
mdmp.build_number,
|
||
®ion_ranges,
|
||
mdmp.arch,
|
||
)
|
||
.expect("credential extraction should succeed");
|
||
|
||
// Should find at least one credential with MSV data
|
||
let msv_creds: Vec<_> = credentials.iter().filter(|c| c.msv.is_some()).collect();
|
||
assert!(
|
||
!msv_creds.is_empty(),
|
||
"should extract at least one MSV credential from lsass.dmp"
|
||
);
|
||
|
||
// Validate known-good NT hash for user@SECLAB (Win10 19045)
|
||
let expected_nt = "bbf7d1528afa8b0fdd40a5b2531bbb6d";
|
||
let expected_sha1 = "abdcba254aec226bb7762f709753084d3985b7d3";
|
||
|
||
let found_nt = msv_creds.iter().any(|c| {
|
||
if let Some(msv) = &c.msv {
|
||
hex::encode(msv.nt_hash) == expected_nt
|
||
} else {
|
||
false
|
||
}
|
||
});
|
||
assert!(
|
||
found_nt,
|
||
"should find NT hash {} (user@SECLAB)",
|
||
expected_nt
|
||
);
|
||
|
||
let found_sha1 = msv_creds.iter().any(|c| {
|
||
if let Some(msv) = &c.msv {
|
||
hex::encode(msv.sha1_hash) == expected_sha1
|
||
} else {
|
||
false
|
||
}
|
||
});
|
||
assert!(
|
||
found_sha1,
|
||
"should find SHA1 hash {} (matches pypykatz)",
|
||
expected_sha1
|
||
);
|
||
|
||
// Verify the user's session metadata
|
||
let user_cred = credentials
|
||
.iter()
|
||
.find(|c| c.username == "user" && c.domain == "SECLAB" && c.msv.is_some());
|
||
assert!(user_cred.is_some(), "should find user@SECLAB session with MSV data");
|
||
}
|