From 9df9c2fca242241df1cb0a1863576066780f01ca Mon Sep 17 00:00:00 2001 From: NK Date: Thu, 19 Feb 2026 16:21:49 +0100 Subject: [PATCH] Add NTDS feature pipeline and release workflow --- .github/workflows/release.yml | 37 +++ Cargo.toml | 1 + src/lsass/msv.rs | 405 ++++++++++++++++++++++-------- src/main.rs | 416 ++++++++++++++++++++++++++----- src/paging/ept.rs | 457 +++++++++++++++++++++++++--------- src/sam/mod.rs | 272 +++++++++++--------- src/sam/ntds.rs | 220 ++++++++++++++++ 7 files changed, 1413 insertions(+), 395 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 src/sam/ntds.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a56371a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-and-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build release binary + run: cargo build --release --features "ntds.dit" + + - name: Strip binary + run: strip target/release/vmkatz + + - name: Archive binary + run: | + mkdir -p dist + cp target/release/vmkatz dist/vmkatz + tar -C dist -czf vmkatz-${{ github.ref_name }}-linux-x86_64.tar.gz vmkatz + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: vmkatz-${{ github.ref_name }}-linux-x86_64.tar.gz diff --git a/Cargo.toml b/Cargo.toml index 68fdecf..2f3182b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ vbox = [] qemu = ["dep:memmap2"] hyperv = ["dep:memmap2"] sam = ["dep:ntfs", "dep:md-5", "dep:sha2"] +"ntds.dit" = ["sam"] [dependencies] memmap2 = { version = "0.9", optional = true } diff --git a/src/lsass/msv.rs b/src/lsass/msv.rs index eb6d6ac..93ce5fd 100644 --- a/src/lsass/msv.rs +++ b/src/lsass/msv.rs @@ -32,15 +32,45 @@ pub struct MsvSessionInfo { // credentials_ptr = 0 means "auto-detect by scanning for Primary signature". const MSV_OFFSET_VARIANTS: &[MsvOffsets] = &[ // Variant 0: Empirical NlpActiveLogonTable (Win10 19041+/22H2) - MsvOffsets { flink: 0x00, luid: 0x2C, username: 0x48, domain: 0x58, credentials_ptr: 0 }, + MsvOffsets { + flink: 0x00, + luid: 0x2C, + username: 0x48, + domain: 0x58, + credentials_ptr: 0, + }, // Variant 1: MSV1_0_LIST_63 base (Win10 1507-1511) - MsvOffsets { flink: 0x00, luid: 0x70, username: 0x80, domain: 0x90, credentials_ptr: 0xE8 }, + MsvOffsets { + flink: 0x00, + luid: 0x70, + username: 0x80, + domain: 0x90, + credentials_ptr: 0xE8, + }, // Variant 2: MSV1_0_LIST_63 extended (Win10 1607+) - MsvOffsets { flink: 0x00, luid: 0x90, username: 0xA8, domain: 0xB8, credentials_ptr: 0x108 }, + MsvOffsets { + flink: 0x00, + luid: 0x90, + username: 0xA8, + domain: 0xB8, + credentials_ptr: 0x108, + }, // Variant 3: MSV1_0_LIST_62 (Win8/8.1 / Server 2012/2012R2) - MsvOffsets { flink: 0x00, luid: 0x70, username: 0x90, domain: 0xA0, credentials_ptr: 0xF8 }, + MsvOffsets { + flink: 0x00, + luid: 0x70, + username: 0x90, + domain: 0xA0, + credentials_ptr: 0xF8, + }, // Variant 4: MSV1_0_LIST_61 (Win7 / Server 2008 R2) - MsvOffsets { flink: 0x00, luid: 0x30, username: 0x40, domain: 0x50, credentials_ptr: 0xA0 }, + MsvOffsets { + flink: 0x00, + luid: 0x30, + username: 0x40, + domain: 0x50, + credentials_ptr: 0xA0, + }, ]; /// Primary credential offsets within MSV1_0_PRIMARY_CREDENTIAL. @@ -64,20 +94,44 @@ struct PrimaryCredOffsets { const PRIMARY_CRED_OFFSET_VARIANTS: &[PrimaryCredOffsets] = &[ // Variant 0: Win10 1607+ / Win11 (KIWI_MSV1_0_PRIMARY_CREDENTIAL_10_1607) // Canonical mimikatz layout: unk0(4)+unk1(2) before hashes - PrimaryCredOffsets { nt_hash: 0x36, lm_hash: 0x46, sha1_hash: 0x56 }, + PrimaryCredOffsets { + nt_hash: 0x36, + lm_hash: 0x46, + sha1_hash: 0x56, + }, // Variant 1: Win10 1507/1511 (KIWI_MSV1_0_PRIMARY_CREDENTIAL_10_OLD) // isIso(1)+isNtOwf(1)+isLmOwf(1)+isSha(1)+align(4) = 8 bytes at +0x20 → hashes at +0x28 - PrimaryCredOffsets { nt_hash: 0x28, lm_hash: 0x38, sha1_hash: 0x48 }, + PrimaryCredOffsets { + nt_hash: 0x28, + lm_hash: 0x38, + sha1_hash: 0x48, + }, // Variant 2: Win7 SP1 / Win8 / Win8.1 / Server 2008R2-2012R2 // No isIso, no DPAPIProtected. Hashes directly after UserName. - PrimaryCredOffsets { nt_hash: 0x20, lm_hash: 0x30, sha1_hash: 0x40 }, + PrimaryCredOffsets { + nt_hash: 0x20, + lm_hash: 0x30, + sha1_hash: 0x40, + }, // Variant 3: Win10 1607+ without unk0/unk1 (some builds or Credential Guard configs) - PrimaryCredOffsets { nt_hash: 0x30, lm_hash: 0x40, sha1_hash: 0x50 }, + PrimaryCredOffsets { + nt_hash: 0x30, + lm_hash: 0x40, + sha1_hash: 0x50, + }, // Variant 4: Empirical — observed on ESXi Win10 NAS and some Server 2016 VMs. // Structure may have extra fields or different alignment. - PrimaryCredOffsets { nt_hash: 0x4A, lm_hash: 0x5A, sha1_hash: 0x6A }, + PrimaryCredOffsets { + nt_hash: 0x4A, + lm_hash: 0x5A, + sha1_hash: 0x6A, + }, // Variant 5: Empirical — slight alignment variation of variant 4. - PrimaryCredOffsets { nt_hash: 0x4C, lm_hash: 0x5C, sha1_hash: 0x6C }, + PrimaryCredOffsets { + nt_hash: 0x4C, + lm_hash: 0x5C, + sha1_hash: 0x6C, + }, ]; /// Extract MSV1_0 sessions (always) and credentials (when available) from msv1_0.dll. @@ -101,8 +155,11 @@ pub fn extract_msv_sessions( // Find LogonSessionList (hash table) via pattern or data scan. // The pattern resolves both the list base address and the bucket count. let (list_base, bucket_count) = match patterns::find_pattern( - vmem, text_base, text.virtual_size, - patterns::MSV_LOGON_SESSION_PATTERNS, "msv_LogonSessionList_sessions", + vmem, + text_base, + text.virtual_size, + patterns::MSV_LOGON_SESSION_PATTERNS, + "msv_LogonSessionList_sessions", ) { Ok((pattern_addr, _)) => match find_list_addr_and_count(vmem, pattern_addr) { Ok((addr, count)) => (Some(addr), count), @@ -115,17 +172,26 @@ pub fn extract_msv_sessions( // Use HashMap to allow metadata enrichment when a session is re-discovered // by a variant with richer metadata (e.g. variant 2 has logon_time, variant 0 doesn't). - let mut session_map: std::collections::HashMap = std::collections::HashMap::new(); + let mut session_map: std::collections::HashMap = + std::collections::HashMap::new(); // Walk all buckets of the pattern-resolved hash table if let Some(base) = list_base { - log::info!("MSV session discovery: list=0x{:x} buckets={}", base, bucket_count); + log::info!( + "MSV session discovery: list=0x{:x} buckets={}", + base, + bucket_count + ); for offsets in MSV_OFFSET_VARIANTS { let pre = session_map.len(); walk_session_buckets(vmem, base, bucket_count, offsets, &mut session_map); if session_map.len() > pre { - log::info!("MSV sessions: variant luid=0x{:x} found {} sessions across {} buckets", - offsets.luid, session_map.len() - pre, bucket_count); + log::info!( + "MSV sessions: variant luid=0x{:x} found {} sessions across {} buckets", + offsets.luid, + session_map.len() - pre, + bucket_count + ); break; } } @@ -133,7 +199,8 @@ pub fn extract_msv_sessions( // Also try .data scan candidates (single list heads) if pattern didn't find enough if session_map.len() < 3 { - let list_addrs = find_all_logon_session_list_candidates(vmem, &pe, msv_base).unwrap_or_default(); + let list_addrs = + find_all_logon_session_list_candidates(vmem, &pe, msv_base).unwrap_or_default(); for list_addr in &list_addrs { for offsets in MSV_OFFSET_VARIANTS { @@ -154,12 +221,21 @@ pub fn extract_msv_sessions( if let Ok(tables) = find_inline_hash_table(vmem, &pe, msv_base) { for (table_addr, bucket_count) in &tables { for offsets in MSV_OFFSET_VARIANTS { - walk_session_buckets(vmem, *table_addr, *bucket_count, offsets, &mut session_map); + walk_session_buckets( + vmem, + *table_addr, + *bucket_count, + offsets, + &mut session_map, + ); } } } if session_map.len() > pre_count { - log::info!("Hash table walk found {} additional sessions", session_map.len() - pre_count); + log::info!( + "Hash table walk found {} additional sessions", + session_map.len() - pre_count + ); } } @@ -194,15 +270,25 @@ fn walk_session_buckets( visited.insert(current); let luid = vmem.read_virt_u64(current + offsets.luid).unwrap_or(0); - let username = vmem.read_win_unicode_string(current + offsets.username).unwrap_or_default(); - let domain = vmem.read_win_unicode_string(current + offsets.domain).unwrap_or_default(); + let username = vmem + .read_win_unicode_string(current + offsets.username) + .unwrap_or_default(); + let domain = vmem + .read_win_unicode_string(current + offsets.domain) + .unwrap_or_default(); if !username.is_empty() && luid != 0 { let (logon_type, session_id, logon_time, logon_server, sid) = extract_session_metadata(vmem, current, offsets); let info = MsvSessionInfo { - luid, username, domain, logon_type, session_id, - logon_time, logon_server, sid, + luid, + username, + domain, + logon_type, + session_id, + logon_time, + logon_server, + sid, }; merge_session(session_map, info); } @@ -240,15 +326,25 @@ fn walk_session_list( visited.insert(current); let luid = vmem.read_virt_u64(current + offsets.luid).unwrap_or(0); - let username = vmem.read_win_unicode_string(current + offsets.username).unwrap_or_default(); - let domain = vmem.read_win_unicode_string(current + offsets.domain).unwrap_or_default(); + let username = vmem + .read_win_unicode_string(current + offsets.username) + .unwrap_or_default(); + let domain = vmem + .read_win_unicode_string(current + offsets.domain) + .unwrap_or_default(); if !username.is_empty() && luid != 0 { let (logon_type, session_id, logon_time, logon_server, sid) = extract_session_metadata(vmem, current, offsets); let info = MsvSessionInfo { - luid, username, domain, logon_type, session_id, - logon_time, logon_server, sid, + luid, + username, + domain, + logon_type, + session_id, + logon_time, + logon_server, + sid, }; merge_session(session_map, info); } @@ -262,12 +358,11 @@ fn walk_session_list( /// Insert or merge a session into the map. When re-discovering a LUID, /// enrich with richer metadata (prefer non-zero logon_time, non-empty SID, etc.). -fn merge_session( - map: &mut std::collections::HashMap, - new: MsvSessionInfo, -) { +fn merge_session(map: &mut std::collections::HashMap, new: MsvSessionInfo) { match map.entry(new.luid) { - std::collections::hash_map::Entry::Vacant(e) => { e.insert(new); }, + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(new); + } std::collections::hash_map::Entry::Occupied(mut e) => { let existing = e.get_mut(); // Enrich: prefer non-zero/non-empty values from the new variant @@ -312,21 +407,27 @@ fn extract_session_metadata( logon_type = vmem.read_virt_u32(entry_addr + 0x34).unwrap_or(0); session_id = vmem.read_virt_u32(entry_addr + 0x38).unwrap_or(0); logon_time = 0; // Not stored in NlpActiveLogon - logon_server = vmem.read_win_unicode_string(entry_addr + 0x68).unwrap_or_default(); + logon_server = vmem + .read_win_unicode_string(entry_addr + 0x68) + .unwrap_or_default(); sid = read_sid_embedded(vmem, entry_addr + 0x88); } else if offsets.luid == 0x90 { // MSV1_0_LIST_63 extended (Win10 1607+) logon_type = vmem.read_virt_u32(entry_addr + 0x80).unwrap_or(0); session_id = vmem.read_virt_u32(entry_addr + 0x84).unwrap_or(0); logon_time = vmem.read_virt_u64(entry_addr + 0x88).unwrap_or(0); - logon_server = vmem.read_win_unicode_string(entry_addr + 0xC8).unwrap_or_default(); + logon_server = vmem + .read_win_unicode_string(entry_addr + 0xC8) + .unwrap_or_default(); sid = read_sid_string(vmem, entry_addr + 0x98); } else if offsets.luid == 0x70 { // MSV1_0_LIST_63 base / MSV1_0_LIST_62 logon_type = vmem.read_virt_u32(entry_addr + 0x60).unwrap_or(0); session_id = vmem.read_virt_u32(entry_addr + 0x64).unwrap_or(0); logon_time = vmem.read_virt_u64(entry_addr + 0x68).unwrap_or(0); - logon_server = vmem.read_win_unicode_string(entry_addr + 0xA8).unwrap_or_default(); + logon_server = vmem + .read_win_unicode_string(entry_addr + 0xA8) + .unwrap_or_default(); sid = read_sid_string(vmem, entry_addr + 0x78); } else { // Win7 or unknown - minimal metadata @@ -356,14 +457,21 @@ fn read_sid_string(vmem: &impl VirtualMemory, ptr_addr: u64) -> String { if revision != 1 || sub_count == 0 || sub_count > 15 { return String::new(); } - let authority = u64::from_be_bytes([0, 0, header[2], header[3], header[4], header[5], header[6], header[7]]); + let authority = u64::from_be_bytes([ + 0, 0, header[2], header[3], header[4], header[5], header[6], header[7], + ]); let sub_data = match vmem.read_virt_bytes(sid_ptr + 8, sub_count * 4) { Ok(d) => d, Err(_) => return String::new(), }; let mut s = format!("S-{}-{}", revision, authority); for i in 0..sub_count { - let sub = u32::from_le_bytes([sub_data[i*4], sub_data[i*4+1], sub_data[i*4+2], sub_data[i*4+3]]); + let sub = u32::from_le_bytes([ + sub_data[i * 4], + sub_data[i * 4 + 1], + sub_data[i * 4 + 2], + sub_data[i * 4 + 3], + ]); s.push_str(&format!("-{}", sub)); } s @@ -380,14 +488,21 @@ fn read_sid_embedded(vmem: &impl VirtualMemory, sid_addr: u64) -> String { if revision != 1 || sub_count == 0 || sub_count > 15 { return String::new(); } - let authority = u64::from_be_bytes([0, 0, header[2], header[3], header[4], header[5], header[6], header[7]]); + let authority = u64::from_be_bytes([ + 0, 0, header[2], header[3], header[4], header[5], header[6], header[7], + ]); let sub_data = match vmem.read_virt_bytes(sid_addr + 8, sub_count * 4) { Ok(d) => d, Err(_) => return String::new(), }; let mut s = format!("S-{}-{}", revision, authority); for i in 0..sub_count { - let sub = u32::from_le_bytes([sub_data[i*4], sub_data[i*4+1], sub_data[i*4+2], sub_data[i*4+3]]); + let sub = u32::from_le_bytes([ + sub_data[i * 4], + sub_data[i * 4 + 1], + sub_data[i * 4 + 2], + sub_data[i * 4 + 3], + ]); s.push_str(&format!("-{}", sub)); } s @@ -415,7 +530,9 @@ pub fn extract_msv_credentials( let text_base = msv_base + text.virtual_address as u64; log::info!( "MSV PE: base=0x{:x}, .text VA=0x{:x}, size=0x{:x}", - msv_base, text.virtual_address, text.virtual_size + msv_base, + text.virtual_address, + text.virtual_size ); // Pattern scan for LogonSessionList @@ -572,13 +689,19 @@ fn walk_msv_list( // Get credentials pointer: either from known offset or auto-detect let cred_ptr = if offsets.credentials_ptr > 0 { - let ptr = vmem.read_virt_u64(current + offsets.credentials_ptr).unwrap_or(0); + let ptr = vmem + .read_virt_u64(current + offsets.credentials_ptr) + .unwrap_or(0); if ptr != 0 && is_heap_ptr(ptr) { // Verify it's actually a KIWI_MSV1_0_PRIMARY_CREDENTIALS if is_primary_credentials_struct(vmem, ptr) { Some(ptr) } else { - log::debug!(" cred_ptr at +0x{:x} = 0x{:x} is not Primary credentials, trying scan", offsets.credentials_ptr, ptr); + log::debug!( + " cred_ptr at +0x{:x} = 0x{:x} is not Primary credentials, trying scan", + offsets.credentials_ptr, + ptr + ); find_credentials_ptr_in_entry(vmem, current) } } else { @@ -594,7 +717,10 @@ fn walk_msv_list( if let Ok(cred) = extract_primary_credential(vmem, cred_ptr, keys) { log::info!( "MSV credential: LUID=0x{:x} user={} domain={} NT={}", - luid, username, domain, hex::encode(cred.nt_hash) + luid, + username, + domain, + hex::encode(cred.nt_hash) ); results.push(( luid, @@ -611,7 +737,9 @@ fn walk_msv_list( } else if !username.is_empty() { log::info!( "MSV entry (credentials paged out): LUID=0x{:x} user={} domain={}", - luid, username, domain + luid, + username, + domain ); } @@ -626,10 +754,7 @@ fn walk_msv_list( /// Scan an entry's memory for a pointer to KIWI_MSV1_0_PRIMARY_CREDENTIALS. /// Identified by the "Primary" ANSI_STRING at offset +0x08 in the target structure. -fn find_credentials_ptr_in_entry( - vmem: &impl VirtualMemory, - entry_addr: u64, -) -> Option { +fn find_credentials_ptr_in_entry(vmem: &impl VirtualMemory, entry_addr: u64) -> Option { // Scan 8-byte aligned offsets for heap pointers // Start at 0x80 (past known UNICODE_STRING fields) up to 0x220 let mut heap_ptrs_found = 0; @@ -646,7 +771,8 @@ fn find_credentials_ptr_in_entry( if is_primary_credentials_struct(vmem, ptr) { log::info!( " Auto-detected pCredentials at entry+0x{:x} -> 0x{:x}", - off, ptr + off, + ptr ); return Some(ptr); } @@ -680,7 +806,8 @@ fn find_credentials_ptr_in_entry( } log::debug!( " No Primary credentials found in entry 0x{:x} ({} heap ptrs scanned)", - entry_addr, heap_ptrs_found + entry_addr, + heap_ptrs_found ); None } @@ -759,11 +886,9 @@ fn find_all_logon_session_list_candidates( pe: &PeHeaders, msv_base: u64, ) -> Result> { - let data_sec = pe - .find_section(".data") - .ok_or_else(|| crate::error::GovmemError::PatternNotFound( - ".data section in msv1_0.dll".to_string(), - ))?; + let data_sec = pe.find_section(".data").ok_or_else(|| { + crate::error::GovmemError::PatternNotFound(".data section in msv1_0.dll".to_string()) + })?; let data_base = msv_base + data_sec.virtual_address as u64; let data_size = std::cmp::min(data_sec.virtual_size as usize, 0x10000); @@ -771,7 +896,8 @@ fn find_all_logon_session_list_candidates( log::info!( "Scanning msv1_0.dll .data for LIST_ENTRY heads: base=0x{:x} size=0x{:x}", - data_base, data_size + data_base, + data_size ); let mut candidates = Vec::new(); @@ -812,7 +938,10 @@ fn find_all_logon_session_list_candidates( candidates.push(list_addr); } - log::info!("MSV data scan: {} topology-valid candidates", candidates.len()); + log::info!( + "MSV data scan: {} topology-valid candidates", + candidates.len() + ); Ok(candidates) } @@ -827,11 +956,9 @@ fn find_inline_hash_table( msv_base: u64, ) -> Result> { let msv_end = msv_base + 0x100000; // Upper bound of DLL image - let data_sec = pe - .find_section(".data") - .ok_or_else(|| crate::error::GovmemError::PatternNotFound( - ".data section in msv1_0.dll".to_string(), - ))?; + let data_sec = pe.find_section(".data").ok_or_else(|| { + crate::error::GovmemError::PatternNotFound(".data section in msv1_0.dll".to_string()) + })?; let data_base = msv_base + data_sec.virtual_address as u64; let data_size = std::cmp::min(data_sec.virtual_size as usize, 0x10000); @@ -868,7 +995,9 @@ fn find_inline_hash_table( let table_addr = data_base + start as u64; log::info!( "Found inline hash table at 0x{:x} (data+0x{:x}): {} buckets", - table_addr, start, run_count + table_addr, + start, + run_count ); tables.push((table_addr, run_count)); } @@ -881,7 +1010,9 @@ fn find_inline_hash_table( let table_addr = data_base + start as u64; log::info!( "Found inline hash table at 0x{:x} (data+0x{:x}): {} buckets", - table_addr, start, run_count + table_addr, + start, + run_count ); tables.push((table_addr, run_count)); } @@ -914,7 +1045,9 @@ fn walk_hash_table( non_empty += 1; log::debug!( "Hash table 0x{:x} bucket {}: flink=0x{:x} (non-empty)", - table_addr, bucket_idx, flink + table_addr, + bucket_idx, + flink ); // Walk the chain for this bucket @@ -937,7 +1070,9 @@ fn walk_hash_table( // Get credentials pointer (known offset or auto-detect) let cred_ptr = if offsets.credentials_ptr > 0 { - let ptr = vmem.read_virt_u64(current + offsets.credentials_ptr).unwrap_or(0); + let ptr = vmem + .read_virt_u64(current + offsets.credentials_ptr) + .unwrap_or(0); if ptr != 0 && is_heap_ptr(ptr) && is_primary_credentials_struct(vmem, ptr) { Some(ptr) } else { @@ -969,7 +1104,10 @@ fn walk_hash_table( } else if !username.is_empty() { log::info!( "MSV entry (credentials paged out): bucket={} LUID=0x{:x} user={} domain={}", - bucket_idx, luid, username, domain + bucket_idx, + luid, + username, + domain ); } @@ -1002,7 +1140,9 @@ fn find_list_addr_and_count(vmem: &impl VirtualMemory, pattern_addr: u64) -> Res let mut i = 0; while i < data.len().saturating_sub(6) { - let is_lea = (data[i] == 0x48 && data[i + 1] == 0x8D && (data[i + 2] == 0x0D || data[i + 2] == 0x15)) + let is_lea = (data[i] == 0x48 + && data[i + 1] == 0x8D + && (data[i + 2] == 0x0D || data[i + 2] == 0x15)) || (data[i] == 0x4C && data[i + 1] == 0x8D && data[i + 2] == 0x05) || (data[i] == 0x4C && data[i + 1] == 0x8D && data[i + 2] == 0x0D); if is_lea { @@ -1089,9 +1229,10 @@ fn extract_primary_credential( if enc_size == 0 || enc_size > 0x200 { log::info!(" Invalid enc_size {}, trying direct read", enc_size); - return Err(crate::error::GovmemError::DecryptionError( - format!("Invalid encrypted credential size: {}", enc_size), - )); + return Err(crate::error::GovmemError::DecryptionError(format!( + "Invalid encrypted credential size: {}", + enc_size + ))); } let enc_data_ptr = vmem.read_virt_u64(cred_ptr + 0x20)?; @@ -1103,12 +1244,32 @@ fn extract_primary_credential( } let enc_data = vmem.read_virt_bytes(enc_data_ptr, enc_size)?; - log::debug!(" Encrypted data ({} bytes): {}...", enc_size, hex::encode(&enc_data[..std::cmp::min(32, enc_data.len())])); + log::debug!( + " Encrypted data ({} bytes): {}...", + enc_size, + hex::encode(&enc_data[..std::cmp::min(32, enc_data.len())]) + ); let decrypted = crate::lsass::crypto::decrypt_credential(keys, &enc_data)?; log::debug!(" Decrypted data ({} bytes):", decrypted.len()); - for (i, chunk) in decrypted[..std::cmp::min(0xA0, decrypted.len())].chunks(16).enumerate() { - let hex_str: String = chunk.iter().map(|b| format!("{:02x}", b)).collect::>().join(" "); - let ascii: String = chunk.iter().map(|&b| if (0x20..0x7f).contains(&b) { b as char } else { '.' }).collect(); + for (i, chunk) in decrypted[..std::cmp::min(0xA0, decrypted.len())] + .chunks(16) + .enumerate() + { + let hex_str: String = chunk + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(" "); + let ascii: String = chunk + .iter() + .map(|&b| { + if (0x20..0x7f).contains(&b) { + b as char + } else { + '.' + } + }) + .collect(); log::debug!(" {:04x}: {} {}", i * 16, hex_str, ascii); } @@ -1149,7 +1310,11 @@ fn extract_primary_credential( " Using primary cred offset variant {} (nt=0x{:x}, lm=0x{:x}, sha1=0x{:x}) [SHA1 validated]", vi, offsets.nt_hash, offsets.lm_hash, offsets.sha1_hash ); - best_result = Some(RawPrimaryCred { lm_hash, nt_hash, sha1_hash }); + best_result = Some(RawPrimaryCred { + lm_hash, + nt_hash, + sha1_hash, + }); break; } @@ -1162,7 +1327,15 @@ fn extract_primary_credential( vi, offsets.nt_hash, struct_score ); let computed_sha1 = sha1_digest(&nt_hash); - entropy_candidates.push((vi, struct_score, RawPrimaryCred { lm_hash, nt_hash, sha1_hash: computed_sha1 })); + entropy_candidates.push(( + vi, + struct_score, + RawPrimaryCred { + lm_hash, + nt_hash, + sha1_hash: computed_sha1, + }, + )); } } @@ -1181,7 +1354,8 @@ fn extract_primary_credential( best_result.ok_or_else(|| { crate::error::GovmemError::DecryptionError( - "No offset variant matched (SHA1 cross-validation and entropy check both failed)".to_string(), + "No offset variant matched (SHA1 cross-validation and entropy check both failed)" + .to_string(), ) }) } @@ -1206,10 +1380,10 @@ fn structural_score(blob: &[u8], offsets: &PrimaryCredOffsets) -> u32 { let dpapi_shifted = if blob.len() >= 0x7E { let at_36 = &blob[0x36..0x4A]; // 20 bytes let at_6a = &blob[0x6A..0x7E]; // 20 bytes - // DPAPI layout: data at 0x36 matches data at 0x6A (both non-zero), - // OR 0x36 is all zeros AND 0x6A is non-zero (isDPAPIProtected=0 variant) - let both_match = at_36 == at_6a && at_36 != &[0u8; 20]; - let zeros_at_36 = at_36 == &[0u8; 20] && at_6a != &[0u8; 20]; + // DPAPI layout: data at 0x36 matches data at 0x6A (both non-zero), + // OR 0x36 is all zeros AND 0x6A is non-zero (isDPAPIProtected=0 variant) + let both_match = at_36 == at_6a && at_36 != [0u8; 20]; + let zeros_at_36 = at_36 == [0u8; 20] && at_6a != [0u8; 20]; both_match || zeros_at_36 } else { false @@ -1226,7 +1400,9 @@ fn structural_score(blob: &[u8], offsets: &PrimaryCredOffsets) -> u32 { let all_bool = flags.iter().all(|&b| b <= 1); if all_bool { score += 10; - if blob[0x29] == 1 { score += 5; } + if blob[0x29] == 1 { + score += 5; + } } } // Win10 1507/1511 @@ -1235,7 +1411,9 @@ fn structural_score(blob: &[u8], offsets: &PrimaryCredOffsets) -> u32 { let all_bool = flags.iter().all(|&b| b <= 1); if all_bool { score += 10; - if blob[0x21] == 1 { score += 5; } + if blob[0x21] == 1 { + score += 5; + } } } // Win7/Win8: no flags before hashes @@ -1244,12 +1422,16 @@ fn structural_score(blob: &[u8], offsets: &PrimaryCredOffsets) -> u32 { } // Win10 1607+ without unk0/unk1 0x30 if blob.len() >= 0x30 => { - if dpapi_shifted { return 0; } // Same DPAPI issue + if dpapi_shifted { + return 0; + } // Same DPAPI issue let flags = &blob[0x28..0x2D]; let all_bool = flags.iter().all(|&b| b <= 1); if all_bool { score += 8; - if blob[0x29] == 1 { score += 3; } + if blob[0x29] == 1 { + score += 3; + } } } // Win10 1607+ DPAPI-shifted layout (NT at 0x4A) @@ -1261,14 +1443,18 @@ fn structural_score(blob: &[u8], offsets: &PrimaryCredOffsets) -> u32 { let all_bool = flags.iter().all(|&b| b <= 1); if all_bool { score += 15; // Strong structural match - if blob[0x29] == 1 { score += 5; } + if blob[0x29] == 1 { + score += 5; + } } } } // Also check: LM at 0x5A should be all zeros on modern Windows if blob.len() >= lm_off + 16 { let lm = &blob[lm_off..lm_off + 16]; - if lm == &[0u8; 16] { score += 3; } + if lm == [0u8; 16] { + score += 3; + } } } _ => {} @@ -1287,7 +1473,10 @@ fn looks_like_hash(data: &[u8; 16]) -> bool { return false; // Too many zeros for a hash — likely UTF-16 text } // Check for alternating zero pattern (UTF-16LE): xx 00 xx 00 - let utf16_pattern = data.chunks(2).filter(|c| c.len() == 2 && c[1] == 0 && c[0] != 0).count(); + let utf16_pattern = data + .chunks(2) + .filter(|c| c.len() == 2 && c[1] == 0 && c[0] != 0) + .count(); if utf16_pattern >= 5 { return false; // Strongly resembles UTF-16LE text } @@ -1297,8 +1486,13 @@ fn looks_like_hash(data: &[u8; 16]) -> bool { /// Minimal inline SHA-1 for cross-validating NT hash against SHA1 field. /// Avoids external crate dependency. fn sha1_digest(data: &[u8]) -> [u8; 20] { - let (mut h0, mut h1, mut h2, mut h3, mut h4) = - (0x67452301u32, 0xEFCDAB89u32, 0x98BADCFEu32, 0x10325476u32, 0xC3D2E1F0u32); + let (mut h0, mut h1, mut h2, mut h3, mut h4) = ( + 0x67452301u32, + 0xEFCDAB89u32, + 0x98BADCFEu32, + 0x10325476u32, + 0xC3D2E1F0u32, + ); let bit_len = (data.len() as u64) * 8; let mut msg = data.to_vec(); msg.push(0x80); @@ -1309,7 +1503,12 @@ fn sha1_digest(data: &[u8]) -> [u8; 20] { for block in msg.chunks(64) { let mut w = [0u32; 80]; for i in 0..16 { - w[i] = u32::from_be_bytes([block[i * 4], block[i * 4 + 1], block[i * 4 + 2], block[i * 4 + 3]]); + w[i] = u32::from_be_bytes([ + block[i * 4], + block[i * 4 + 1], + block[i * 4 + 2], + block[i * 4 + 3], + ]); } for i in 16..80 { w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); @@ -1322,11 +1521,23 @@ fn sha1_digest(data: &[u8]) -> [u8; 20] { 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDCu32), _ => (b ^ c ^ d, 0xCA62C1D6u32), }; - let temp = a.rotate_left(5).wrapping_add(f).wrapping_add(e).wrapping_add(k).wrapping_add(wi); - e = d; d = c; c = b.rotate_left(30); b = a; a = temp; + let temp = a + .rotate_left(5) + .wrapping_add(f) + .wrapping_add(e) + .wrapping_add(k) + .wrapping_add(wi); + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = temp; } - h0 = h0.wrapping_add(a); h1 = h1.wrapping_add(b); h2 = h2.wrapping_add(c); - h3 = h3.wrapping_add(d); h4 = h4.wrapping_add(e); + h0 = h0.wrapping_add(a); + h1 = h1.wrapping_add(b); + h2 = h2.wrapping_add(c); + h3 = h3.wrapping_add(d); + h4 = h4.wrapping_add(e); } let mut r = [0u8; 20]; r[0..4].copy_from_slice(&h0.to_be_bytes()); diff --git a/src/main.rs b/src/main.rs index 77cd7d1..e41ca0c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,28 +1,56 @@ -#[cfg(not(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv", feature = "sam")))] -compile_error!("At least one backend must be enabled: --features vmware, vbox, qemu, hyperv, and/or sam"); +#[cfg(not(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv", + feature = "sam" +)))] +compile_error!( + "At least one backend must be enabled: --features vmware, vbox, qemu, hyperv, and/or sam" +); use std::path::Path; use anyhow::Context; use clap::Parser; -#[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] +#[cfg(feature = "hyperv")] +use vmkatz::hyperv::HypervLayer; +#[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" +))] use vmkatz::lsass; use vmkatz::lsass::finder::PagefileRef; -#[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] +#[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" +))] use vmkatz::lsass::types::Credential; -#[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] +#[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" +))] use vmkatz::memory::PhysicalMemory; +#[cfg(feature = "qemu")] +use vmkatz::qemu::QemuElfLayer; #[cfg(feature = "vbox")] use vmkatz::vbox::VBoxLayer; #[cfg(feature = "vmware")] use vmkatz::vmware::VmwareLayer; -#[cfg(feature = "qemu")] -use vmkatz::qemu::QemuElfLayer; -#[cfg(feature = "hyperv")] -use vmkatz::hyperv::HypervLayer; // EPROCESS offsets auto-detected at runtime from ALL_EPROCESS_OFFSETS -#[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] +#[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" +))] use vmkatz::windows::process; #[derive(Parser, Debug)] @@ -48,7 +76,7 @@ use vmkatz::windows::process; 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", + vmkatz -v snapshot.vmsn Verbose output with process list" )] struct Args { /// Path to a snapshot, disk image, or VM directory @@ -64,6 +92,16 @@ struct Args { #[arg(long, default_value_t = false)] sam: bool, + /// Try NTDS.dit extraction workflow (Windows/NTDS/ntds.dit + SYSTEM bootkey) + #[cfg(feature = "ntds.dit")] + #[arg(long, default_value_t = false)] + ntds: bool, + + /// Include NTDS password history hashes (when available) + #[cfg(feature = "ntds.dit")] + #[arg(long, default_value_t = false)] + ntds_history: bool, + /// Disk image for pagefile.sys resolution (resolves paged-out memory from disk) #[cfg(feature = "sam")] #[arg(long, value_name = "DISK_IMAGE")] @@ -115,7 +153,20 @@ fn main() -> anyhow::Result<()> { // Auto-detect SAM mode for disk images, or explicit --sam flag #[cfg(feature = "sam")] { - 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(""); + #[cfg(feature = "ntds.dit")] + let sam_mode = args.sam + || args.ntds + || ext.eq_ignore_ascii_case("vdi") + || ext.eq_ignore_ascii_case("vmdk") + || ext.eq_ignore_ascii_case("qcow2") + || ext.eq_ignore_ascii_case("qcow") + || ext.eq_ignore_ascii_case("vhdx") + || ext.eq_ignore_ascii_case("vhd"); + #[cfg(not(feature = "ntds.dit"))] let sam_mode = args.sam || ext.eq_ignore_ascii_case("vdi") || ext.eq_ignore_ascii_case("vmdk") @@ -132,21 +183,22 @@ fn main() -> anyhow::Result<()> { #[cfg(feature = "sam")] { 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!( - "[+] Pagefile: {:.1} MB", - pf.pagefile_size() as f64 / (1024.0 * 1024.0), - ); - Some(pf) - } - Err(e) => { - log::info!("No pagefile from {}: {}", d, e); - None - } - } - }); + let pagefile_reader = + disk_path_str.as_ref().and_then( + |d| match vmkatz::paging::pagefile::PagefileReader::open(Path::new(d)) { + Ok(pf) => { + println!( + "[+] Pagefile: {:.1} MB", + pf.pagefile_size() as f64 / (1024.0 * 1024.0), + ); + Some(pf) + } + Err(e) => { + log::info!("No pagefile from {}: {}", d, e); + None + } + }, + ); let disk_ref = disk_path_str.as_ref().map(|d| Path::new(d.as_str())); run_lsass(input_path, &args, pagefile_reader.as_ref(), disk_ref) } @@ -156,12 +208,19 @@ fn main() -> anyhow::Result<()> { #[cfg(feature = "sam")] fn run_sam(input_path: &Path, args: &Args) -> anyhow::Result<()> { + #[cfg(feature = "ntds.dit")] + { + if args.ntds { + return run_ntds(input_path, args); + } + } + if args.verbose { println!("[*] SAM hash extraction from: {}", input_path.display()); } - let secrets = vmkatz::sam::extract_disk_secrets(input_path) - .context("Disk secrets extraction failed")?; + let secrets = + vmkatz::sam::extract_disk_secrets(input_path).context("Disk secrets extraction failed")?; match args.format.as_str() { "ntlm" => print_sam_ntlm(&secrets.sam_entries), @@ -184,6 +243,115 @@ fn run_sam(input_path: &Path, args: &Args) -> anyhow::Result<()> { Ok(()) } +#[cfg(feature = "ntds.dit")] +fn run_ntds(input_path: &Path, args: &Args) -> anyhow::Result<()> { + if args.verbose { + println!("[*] NTDS extraction from: {}", input_path.display()); + } + + let artifacts = vmkatz::sam::extract_ntds_artifacts(input_path) + .context("NTDS artifact extraction failed")?; + let ctx = vmkatz::sam::ntds::build_context(&artifacts.ntds_data, &artifacts.system_data) + .context("NTDS context validation failed")?; + let hashes = vmkatz::sam::ntds::extract_ad_hashes( + &artifacts.ntds_data, + &artifacts.system_data, + args.ntds_history, + ) + .context("NTDS hash extraction failed")?; + + println!("\n[+] NTDS Artifacts:"); + println!(" Partition offset : 0x{:x}", artifacts.partition_offset); + println!(" ntds.dit size : {} bytes", ctx.ntds_size); + println!(" SYSTEM size : {} bytes", artifacts.system_data.len()); + println!(" Bootkey : {}", hex::encode(ctx.boot_key)); + println!(" Hashes extracted : {}", hashes.len()); + + match args.format.as_str() { + "csv" => print_ntds_csv(&hashes), + "hashcat" => print_ntds_hashcat(&hashes), + "ntlm" => print_ntds_ntlm(&hashes), + _ => print_ntds_text(&hashes), + } + + Ok(()) +} + +#[cfg(feature = "ntds.dit")] +fn print_ntds_text(entries: &[vmkatz::sam::ntds::AdHashEntry]) { + println!("\n[+] AD NTLM Hashes:"); + for entry in entries { + let hist = if entry.is_history { + match entry.history_index { + Some(idx) => format!("history{}", idx), + None => "history".to_string(), + } + } else { + "current".to_string() + }; + println!( + " RID: {:<6} {:<24} {:<10} NT:{} LM:{}", + entry.rid, + entry.username, + hist, + hex::encode(entry.nt_hash), + hex::encode(entry.lm_hash), + ); + } +} + +#[cfg(feature = "ntds.dit")] +fn print_ntds_ntlm(entries: &[vmkatz::sam::ntds::AdHashEntry]) { + for entry in entries { + let user = if entry.is_history { + match entry.history_index { + Some(idx) => format!("{}_history{}", entry.username, idx), + None => format!("{}_history", entry.username), + } + } else { + entry.username.clone() + }; + + println!( + "{}:{}:{}:{}:::", + user, + entry.rid, + hex::encode(entry.lm_hash), + hex::encode(entry.nt_hash), + ); + } +} + +#[cfg(feature = "ntds.dit")] +fn print_ntds_csv(entries: &[vmkatz::sam::ntds::AdHashEntry]) { + println!("rid,username,is_history,history_index,nt_hash,lm_hash"); + for entry in entries { + let history_index = entry + .history_index + .map(|v| v.to_string()) + .unwrap_or_default(); + println!( + "{},{},{},{},{},{}", + entry.rid, + entry.username, + entry.is_history, + history_index, + hex::encode(entry.nt_hash), + hex::encode(entry.lm_hash), + ); + } +} + +#[cfg(feature = "ntds.dit")] +fn print_ntds_hashcat(entries: &[vmkatz::sam::ntds::AdHashEntry]) { + let zero_hash = [0u8; 16]; + for entry in entries { + if entry.nt_hash != zero_hash { + println!("{}", hex::encode(entry.nt_hash)); + } + } +} + #[cfg(feature = "sam")] fn print_sam_text(entries: &[vmkatz::sam::SamEntry]) { println!("\n[+] SAM Hashes:"); @@ -266,8 +434,7 @@ fn print_cached_credentials(creds: &[vmkatz::sam::cache::CachedCredential]) { } fn run_directory(dir: &Path, args: &Args) -> anyhow::Result<()> { - let discovery = vmkatz::discover::discover_vm_files(dir) - .context("VM file discovery failed")?; + let discovery = vmkatz::discover::discover_vm_files(dir).context("VM file discovery failed")?; println!( "[*] Found {} LSASS snapshot(s), {} disk image(s) in: {}", @@ -316,7 +483,12 @@ fn run_directory(dir: &Path, args: &Args) -> anyhow::Result<()> { #[cfg(not(feature = "sam"))] let disk_path: vmkatz::lsass::finder::DiskPathRef<'_> = Default::default(); - #[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] + #[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" + ))] for file in &discovery.lsass_files { let name = file.file_name().unwrap_or_default().to_string_lossy(); println!("\n[*] LSASS: {}", name); @@ -342,9 +514,17 @@ fn run_directory(dir: &Path, args: &Args) -> anyhow::Result<()> { Ok(()) } -fn run_lsass(input_path: &Path, args: &Args, pagefile: PagefileRef<'_>, disk_path: vmkatz::lsass::finder::DiskPathRef<'_>) -> 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(""); + let ext = input_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); // Detect format by extension and magic bytes let format = detect_lsass_format(input_path, ext); @@ -356,12 +536,19 @@ fn run_lsass(input_path: &Path, args: &Args, pagefile: PagefileRef<'_>, disk_pat run_with_layer( || { if verbose { - println!("[*] Opening VirtualBox saved state: {}", input_path.display()); + println!( + "[*] Opening VirtualBox saved state: {}", + input_path.display() + ); } let layer = VBoxLayer::open(input_path) .context("Failed to open VirtualBox .sav file")?; if verbose { - println!("[+] RAM: {} MB ({} pages mapped)", layer.phys_size() / (1024 * 1024), layer.page_count()); + println!( + "[+] RAM: {} MB ({} pages mapped)", + layer.phys_size() / (1024 * 1024), + layer.page_count() + ); } Ok(layer) }, @@ -388,8 +575,11 @@ fn run_lsass(input_path: &Path, args: &Args, pagefile: PagefileRef<'_>, disk_pat let layer = QemuElfLayer::open(input_path) .context("Failed to open QEMU ELF core dump")?; if verbose { - println!("[+] ELF: {} MB physical, {} PT_LOAD segments", - layer.phys_size() / (1024 * 1024), layer.segment_count()); + println!( + "[+] ELF: {} MB physical, {} PT_LOAD segments", + layer.phys_size() / (1024 * 1024), + layer.segment_count() + ); } Ok(layer) }, @@ -416,7 +606,10 @@ fn run_lsass(input_path: &Path, args: &Args, pagefile: PagefileRef<'_>, disk_pat let layer = HypervLayer::open(input_path) .context("Failed to open Hyper-V .bin memory dump")?; if verbose { - println!("[+] RAM: {} MB identity-mapped", layer.phys_size() / (1024 * 1024)); + println!( + "[+] RAM: {} MB identity-mapped", + layer.phys_size() / (1024 * 1024) + ); } Ok(layer) }, @@ -467,7 +660,9 @@ fn run_lsass(input_path: &Path, args: &Args, pagefile: PagefileRef<'_>, disk_pat #[cfg(not(feature = "vmware"))] { 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)" + ) } } } @@ -517,12 +712,19 @@ fn detect_lsass_format(path: &Path, ext: &str) -> LsassFormat { /// Check if file starts with ELF magic bytes (reads only 4 bytes). fn has_elf_magic(path: &Path) -> bool { use std::io::Read; - let Ok(mut f) = std::fs::File::open(path) else { return false }; + let Ok(mut f) = std::fs::File::open(path) else { + return false; + }; let mut magic = [0u8; 4]; f.read_exact(&mut magic).is_ok() && magic == [0x7f, b'E', b'L', b'F'] } -#[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] +#[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" +))] fn run_with_layer anyhow::Result>( make_layer: F, args: &Args, @@ -534,34 +736,94 @@ fn run_with_layer anyhow::Result>( // Find System process (auto-detect Windows version from EPROCESS layout) match process::find_system_process_auto(&layer) { - Ok((system, eprocess_offsets)) => { - run_with_system(&layer, &system, &eprocess_offsets, args, verbose, pagefile, disk_path) - } + Ok((system, eprocess_offsets)) => run_with_system( + &layer, + &system, + &eprocess_offsets, + args, + verbose, + pagefile, + disk_path, + ), Err(_) => { // EPT fallback: try to find nested hypervisor page tables (VBS/Hyper-V) log::info!("System process not found in L1 physical memory, trying EPT scan..."); println!("[*] VBS detected: scanning for nested EPT..."); - let (ept_pml4, l2_size) = vmkatz::paging::ept::find_ept_root(&layer) + let candidates = vmkatz::paging::ept::find_ept_candidates(&layer) .context("Failed to find System process (no EPT found — VBS not supported for this snapshot)")?; - println!( - "[+] EPT found at L1=0x{:x}, L2 size={} MB", - ept_pml4, - l2_size / (1024 * 1024) - ); + // Try each EPT candidate (ranked by non-zero translated pages) + let mut last_err = None; + for (i, candidate) in candidates.iter().enumerate() { + println!( + "[*] Trying EPT #{} at L1=0x{:x} ({}/{} non-zero pages, {} PML4E)", + i + 1, + candidate.pml4_addr, + candidate.nonzero_pages, + candidate.total_sampled, + candidate.valid_pml4e, + ); - let ept_layer = vmkatz::paging::ept::EptLayer::new(&layer, ept_pml4, l2_size); + let ept_layer = vmkatz::paging::ept::EptLayer::new( + &layer, + candidate.pml4_addr, + candidate.l2_size, + ); - let (system, eprocess_offsets) = process::find_system_process_auto(&ept_layer) - .context("Failed to find System process (even with EPT translation)")?; + let mapped = ept_layer.mapped_page_count(); + println!( + "[*] EPT #{}: {} mapped pages ({} MB of L2 space)", + i + 1, + mapped, + mapped * 4 / 1024, + ); - run_with_system(&ept_layer, &system, &eprocess_offsets, args, verbose, pagefile, disk_path) + // Fast path: iterate only mapped pages for small EPTs. + // For huge EPTs (hypervisor-level), use generic scan with precomputed binary search. + let result = if mapped < 10_000_000 { + process::find_system_process_ept(&ept_layer, &layer).map_err(|e| e.into()) + } else { + process::find_system_process_auto(&ept_layer).map_err(|e| e.into()) + }; + + match result { + Ok((system, eprocess_offsets)) => { + println!( + "[+] System found via EPT #{} at L2=0x{:x}, DTB=0x{:x}", + i + 1, + system.eprocess_phys, + system.dtb, + ); + return run_with_system( + &ept_layer, + &system, + &eprocess_offsets, + args, + verbose, + pagefile, + disk_path, + ); + } + Err(e) => { + log::info!("EPT #{} (L1=0x{:x}): {}", i + 1, candidate.pml4_addr, e); + last_err = Some(e); + } + } + } + + Err(last_err + .unwrap_or_else(|| vmkatz::error::GovmemError::SystemProcessNotFound.into())) } } } -#[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] +#[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" +))] fn run_with_system( layer: &L, system: &vmkatz::windows::process::Process, @@ -652,7 +914,12 @@ fn run_with_system( Ok(()) } -#[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] +#[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" +))] fn find_process_by_name<'a>( processes: &'a [vmkatz::windows::process::Process], name: &str, @@ -670,7 +937,12 @@ fn find_process_by_name<'a>( }) } -#[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] +#[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" +))] fn print_text(credentials: &[Credential]) { let with_creds = credentials.iter().filter(|c| c.has_credentials()).count(); println!( @@ -683,7 +955,12 @@ fn print_text(credentials: &[Credential]) { } } -#[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] +#[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" +))] fn csv_escape(s: &str) -> String { if s.contains(',') || s.contains('"') || s.contains('\n') { format!("\"{}\"", s.replace('"', "\"\"")) @@ -692,7 +969,12 @@ fn csv_escape(s: &str) -> String { } } -#[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] +#[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" +))] fn print_csv(credentials: &[Credential]) { println!("luid,username,domain,nt_hash,lm_hash,sha1_hash,wdigest_password,kerberos_password,tspkg_password"); for cred in credentials.iter().filter(|c| c.has_credentials()) { @@ -736,7 +1018,12 @@ fn print_csv(credentials: &[Credential]) { } } -#[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] +#[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" +))] fn print_ntlm(credentials: &[Credential]) { let zero_hash = [0u8; 16]; for cred in credentials.iter().filter(|c| c.has_credentials()) { @@ -753,7 +1040,12 @@ fn print_ntlm(credentials: &[Credential]) { } } -#[cfg(any(feature = "vmware", feature = "vbox", feature = "qemu", feature = "hyperv"))] +#[cfg(any( + feature = "vmware", + feature = "vbox", + feature = "qemu", + feature = "hyperv" +))] fn print_hashcat(credentials: &[Credential]) { let zero_hash = [0u8; 16]; for cred in credentials.iter().filter(|c| c.has_credentials()) { diff --git a/src/paging/ept.rs b/src/paging/ept.rs index 70fc92f..0fa259e 100644 --- a/src/paging/ept.rs +++ b/src/paging/ept.rs @@ -6,6 +6,9 @@ //! //! This module finds and walks the EPT to reconstruct L2→L1 translation, //! allowing us to scan L2 physical memory for EPROCESS structures. +//! +//! Optimization: EptLayer precomputes the full L2→L1 mapping on construction, +//! so subsequent reads use O(log n) binary search instead of 4-level walk. use crate::error::{GovmemError, Result}; use crate::memory::PhysicalMemory; @@ -15,76 +18,217 @@ const EPT_PRESENT_MASK: u64 = 0x7; // bits 2:0 = RWX const EPT_ADDR_MASK: u64 = 0x000F_FFFF_FFFF_F000; // bits 51:12 const EPT_LARGE_PAGE: u64 = 1 << 7; // bit 7 = large page -/// EPT-translated physical memory layer. -/// Wraps an L1 PhysicalMemory and translates L2 addresses through the EPT. +/// A contiguous L2→L1 mapping region from an EPT leaf entry. +#[derive(Debug, Clone, Copy)] +struct EptMapping { + l2_base: u64, // L2 guest physical base address + l1_base: u64, // L1 physical base address + size: u64, // Region size: 4KB, 2MB, or 1GB +} + +/// EPT-translated physical memory layer with precomputed mappings. +/// Construction walks the full EPT once; reads use binary search. pub struct EptLayer<'a, P: PhysicalMemory> { l1: &'a P, - ept_pml4: u64, // L1 physical address of the EPT PML4 table - l2_size: u64, // Maximum L2 physical address observed + mappings: Vec, // sorted by l2_base + l2_size: u64, + mapped_count: usize, // total number of mapped 4KB-equivalent pages +} + +/// A scored EPT candidate, sorted by quality (non-zero translated pages). +#[derive(Debug)] +pub struct EptCandidate { + pub pml4_addr: u64, + pub l2_size: u64, + pub valid_pml4e: u32, + pub nonzero_pages: u32, + pub total_sampled: u32, } impl<'a, P: PhysicalMemory> EptLayer<'a, P> { - /// Create an EPT layer with a known PML4 root address. + /// Create an EPT layer by precomputing all L2→L1 mappings. + /// This walks the full 4-level EPT once, then all subsequent reads are O(log n). pub fn new(l1: &'a P, ept_pml4: u64, l2_size: u64) -> Self { + let l1_size = l1.phys_size(); + let mut mappings = Vec::new(); + let mut mapped_pages: u64 = 0; + + let mut pml4_buf = [0u8; PAGE_SIZE as usize]; + if l1.read_phys(ept_pml4, &mut pml4_buf).is_ok() { + for i in 0..512u64 { + let pml4e = read_entry(&pml4_buf, i); + if pml4e & EPT_PRESENT_MASK == 0 { + continue; + } + let pdpt_addr = pml4e & EPT_ADDR_MASK; + if pdpt_addr >= l1_size { + continue; + } + + let mut pdpt_buf = [0u8; PAGE_SIZE as usize]; + if l1.read_phys(pdpt_addr, &mut pdpt_buf).is_err() { + continue; + } + + for j in 0..512u64 { + let pdpte = read_entry(&pdpt_buf, j); + if pdpte & EPT_PRESENT_MASK == 0 { + continue; + } + + let l2_1g = (i << 39) | (j << 30); + + // 1GB large page + if pdpte & EPT_LARGE_PAGE != 0 { + let l1_base = pdpte & 0x000F_FFFF_C000_0000; + if l1_base < l1_size { + mappings.push(EptMapping { + l2_base: l2_1g, + l1_base, + size: 1 << 30, + }); + mapped_pages += 262144; + } + continue; + } + + let pd_addr = pdpte & EPT_ADDR_MASK; + if pd_addr >= l1_size { + continue; + } + + let mut pd_buf = [0u8; PAGE_SIZE as usize]; + if l1.read_phys(pd_addr, &mut pd_buf).is_err() { + continue; + } + + for k in 0..512u64 { + let pde = read_entry(&pd_buf, k); + if pde & EPT_PRESENT_MASK == 0 { + continue; + } + + let l2_2m = l2_1g | (k << 21); + + // 2MB large page + if pde & EPT_LARGE_PAGE != 0 { + let l1_base = pde & 0x000F_FFFF_FFE0_0000; + if l1_base < l1_size { + mappings.push(EptMapping { + l2_base: l2_2m, + l1_base, + size: 1 << 21, + }); + mapped_pages += 512; + } + continue; + } + + let pt_addr = pde & EPT_ADDR_MASK; + if pt_addr >= l1_size { + continue; + } + + let mut pt_buf = [0u8; PAGE_SIZE as usize]; + if l1.read_phys(pt_addr, &mut pt_buf).is_err() { + continue; + } + + for l in 0..512u64 { + let pte = read_entry(&pt_buf, l); + if pte & EPT_PRESENT_MASK == 0 { + continue; + } + let l1_addr = pte & EPT_ADDR_MASK; + if l1_addr >= l1_size { + continue; + } + mappings.push(EptMapping { + l2_base: l2_2m | (l << 12), + l1_base: l1_addr, + size: PAGE_SIZE, + }); + mapped_pages += 1; + } + } + } + } + } + + mappings.sort_by_key(|m| m.l2_base); + + log::info!( + "EPT prebuilt: {} mapping regions, ~{} mapped pages ({} MB of L2 space)", + mappings.len(), + mapped_pages, + mapped_pages * 4 / 1024, + ); + Self { l1, - ept_pml4, + mappings, l2_size, + mapped_count: mapped_pages as usize, } } - /// Translate an L2 guest physical address to L1 physical address via EPT. - pub fn translate_l2(&self, l2_phys: u64) -> Result { - let pml4_idx = (l2_phys >> 39) & 0x1FF; - let pdpt_idx = (l2_phys >> 30) & 0x1FF; - let pd_idx = (l2_phys >> 21) & 0x1FF; - let pt_idx = (l2_phys >> 12) & 0x1FF; - let offset = l2_phys & 0xFFF; - - // PML4 - let pml4e = self.read_ept_entry(self.ept_pml4 + pml4_idx * 8)?; - if pml4e & EPT_PRESENT_MASK == 0 { - return Err(GovmemError::UnmappablePhysical(l2_phys)); - } - - // PDPT - let pdpt_base = pml4e & EPT_ADDR_MASK; - let pdpte = self.read_ept_entry(pdpt_base + pdpt_idx * 8)?; - if pdpte & EPT_PRESENT_MASK == 0 { - return Err(GovmemError::UnmappablePhysical(l2_phys)); - } - // 1GB large page - if pdpte & EPT_LARGE_PAGE != 0 { - let base = pdpte & 0x000F_FFFF_C000_0000; // bits 51:30 - return Ok(base | (l2_phys & 0x3FFF_FFFF)); - } - - // PD - let pd_base = pdpte & EPT_ADDR_MASK; - let pde = self.read_ept_entry(pd_base + pd_idx * 8)?; - if pde & EPT_PRESENT_MASK == 0 { - return Err(GovmemError::UnmappablePhysical(l2_phys)); - } - // 2MB large page - if pde & EPT_LARGE_PAGE != 0 { - let base = pde & 0x000F_FFFF_FFE0_0000; // bits 51:21 - return Ok(base | (l2_phys & 0x001F_FFFF)); - } - - // PT - let pt_base = pde & EPT_ADDR_MASK; - let pte = self.read_ept_entry(pt_base + pt_idx * 8)?; - if pte & EPT_PRESENT_MASK == 0 { - return Err(GovmemError::UnmappablePhysical(l2_phys)); - } - - Ok((pte & EPT_ADDR_MASK) | offset) + /// Number of mapped 4KB-equivalent pages. + pub fn mapped_page_count(&self) -> usize { + self.mapped_count } - fn read_ept_entry(&self, l1_addr: u64) -> Result { - let mut buf = [0u8; 8]; - self.l1.read_phys(l1_addr, &mut buf)?; - Ok(u64::from_le_bytes(buf)) + /// Translate L2 → L1 via binary search on precomputed mappings. + fn translate_l2(&self, l2_phys: u64) -> Result { + // Binary search: find the mapping region containing this L2 address + let idx = self + .mappings + .partition_point(|m| m.l2_base + m.size <= l2_phys); + + if idx < self.mappings.len() { + let m = &self.mappings[idx]; + if l2_phys >= m.l2_base && l2_phys < m.l2_base + m.size { + let offset = l2_phys - m.l2_base; + return Ok(m.l1_base + offset); + } + } + + Err(GovmemError::UnmappablePhysical(l2_phys)) + } + + /// Iterate over all mapped L2 page-aligned addresses and their L1 targets. + /// Used for efficient EPROCESS scanning (scan L1 pages directly). + pub fn mapped_pages(&self) -> MappedPageIter<'_> { + MappedPageIter { + mappings: &self.mappings, + region_idx: 0, + page_offset: 0, + } + } +} + +/// Iterator over (L2_page_base, L1_page_addr) for all mapped pages. +pub struct MappedPageIter<'a> { + mappings: &'a [EptMapping], + region_idx: usize, + page_offset: u64, +} + +impl Iterator for MappedPageIter<'_> { + type Item = (u64, u64); // (L2 page base, L1 page address) + + fn next(&mut self) -> Option { + while self.region_idx < self.mappings.len() { + let m = &self.mappings[self.region_idx]; + if self.page_offset < m.size { + let l2 = m.l2_base + self.page_offset; + let l1 = m.l1_base + self.page_offset; + self.page_offset += PAGE_SIZE; + return Some((l2, l1)); + } + self.region_idx += 1; + self.page_offset = 0; + } + None } } @@ -98,7 +242,8 @@ impl<'a, P: PhysicalMemory> PhysicalMemory for EptLayer<'a, P> { let to_read = std::cmp::min(buf.len() - offset, page_remaining); let l1_addr = self.translate_l2(current_addr)?; - self.l1.read_phys(l1_addr, &mut buf[offset..offset + to_read])?; + self.l1 + .read_phys(l1_addr, &mut buf[offset..offset + to_read])?; offset += to_read; } Ok(()) @@ -109,13 +254,9 @@ impl<'a, P: PhysicalMemory> PhysicalMemory for EptLayer<'a, P> { } } -/// Scan L1 physical memory for potential EPT PML4 tables. -/// Returns the best candidate (L1 physical address of PML4, estimated L2 size). -pub fn find_ept_root(l1: &P) -> Result<(u64, u64)> { +/// Find all EPT candidates, ranked by quality (most non-zero translated pages first). +pub fn find_ept_candidates(l1: &P) -> Result> { let l1_size = l1.phys_size(); - let mut best_pml4: u64 = 0; - let mut best_mapped: u64 = 0; - let mut best_l2_max: u64 = 0; log::info!( "EPT scan: searching {} MB for EPT PML4 tables...", @@ -123,7 +264,7 @@ pub fn find_ept_root(l1: &P) -> Result<(u64, u64)> { ); let mut page_buf = vec![0u8; PAGE_SIZE as usize]; - let mut candidates = 0u32; + let mut candidates: Vec = Vec::new(); let mut addr: u64 = 0; while addr < l1_size { @@ -132,71 +273,106 @@ pub fn find_ept_root(l1: &P) -> Result<(u64, u64)> { continue; } - // Skip zero pages if page_buf.iter().all(|&b| b == 0) { addr += PAGE_SIZE; continue; } - // Check if this page looks like an EPT PML4 table: - // - EPT PML4 has 512 entries (8 bytes each) = 4KB - // - Valid entries have RWX bits set and point to valid L1 addresses - // - Most entries are zero (sparse) let (valid, zero, invalid, max_l2) = score_ept_page(&page_buf, l1_size); - // A good PML4: some valid entries, mostly zeros, no invalid entries - if valid >= 1 && valid <= 64 && zero >= 400 && invalid == 0 { - candidates += 1; + if (1..=64).contains(&valid) && zero >= 400 && invalid == 0 { + let (nonzero, sampled) = sample_nonzero_translated(l1, addr, l1_size, 64); - // Walk one level deeper to count total mapped pages - let mapped = count_ept_mapped_pages(l1, addr, l1_size); + let l2_size = std::cmp::min((max_l2 + 1) * (1u64 << 39), l1_size * 2); log::debug!( - "EPT candidate at L1=0x{:x}: {} valid PML4E, ~{} mapped pages, max_l2=0x{:x}", + "EPT candidate at L1=0x{:x}: {} PML4E, {}/{} non-zero, l2={} MB", addr, valid, - mapped, - max_l2 + nonzero, + sampled, + l2_size / (1024 * 1024) ); - if mapped > best_mapped { - best_mapped = mapped; - best_pml4 = addr; - best_l2_max = max_l2; - } + candidates.push(EptCandidate { + pml4_addr: addr, + l2_size, + valid_pml4e: valid, + nonzero_pages: nonzero, + total_sampled: sampled, + }); } addr += PAGE_SIZE; } + // Sort: prefer EPTs closer to Windows kernel (fewer PML4E = more specific). + // A Windows kernel EPT typically has 1-4 PML4E covering 8-16 GB of L2 space. + // Hypervisor-level EPTs have 8+ PML4E mapping all of L1 memory. + // Primary: non-zero > 0 (must have data), then fewer PML4E preferred, + // then non-zero count as tiebreaker. + candidates.sort_by(|a, b| { + // First: any non-zero data beats none + let a_has = if a.nonzero_pages > 0 { 1u32 } else { 0 }; + let b_has = if b.nonzero_pages > 0 { 1u32 } else { 0 }; + b_has + .cmp(&a_has) + // Fewer PML4E = more likely Windows kernel EPT + .then(a.valid_pml4e.cmp(&b.valid_pml4e)) + // More non-zero pages as tiebreaker + .then(b.nonzero_pages.cmp(&a.nonzero_pages)) + }); + + // Filter out zero-data candidates if we have any good ones + let good_count = candidates.iter().filter(|c| c.nonzero_pages > 0).count(); + if good_count > 0 { + candidates.retain(|c| c.nonzero_pages > 0); + } + log::info!( - "EPT scan: {} candidates found, best at L1=0x{:x} with ~{} mapped pages", - candidates, - best_pml4, - best_mapped + "EPT scan: {} candidates found{}", + candidates.len(), + if let Some(best) = candidates.first() { + format!( + ", best at L1=0x{:x} ({}/{} non-zero)", + best.pml4_addr, best.nonzero_pages, best.total_sampled + ) + } else { + String::new() + } ); - if best_mapped < 100 { + if candidates.is_empty() { return Err(GovmemError::SystemProcessNotFound); } - // L2 size = max L2 address + 1GB margin - let l2_size = (best_l2_max + 1) * (1u64 << 39); - Ok((best_pml4, l2_size)) + Ok(candidates) } -/// Score a page as a potential EPT PML4/PDPT/PD table. -/// Returns (valid_entries, zero_entries, invalid_entries, max_l2_index). +/// Convenience wrapper: returns the single best EPT root. +pub fn find_ept_root(l1: &P) -> Result<(u64, u64)> { + let candidates = find_ept_candidates(l1)?; + let best = candidates + .first() + .ok_or(GovmemError::SystemProcessNotFound)?; + Ok((best.pml4_addr, best.l2_size)) +} + +#[inline] +fn read_entry(buf: &[u8], idx: u64) -> u64 { + let off = (idx * 8) as usize; + u64::from_le_bytes(buf[off..off + 8].try_into().unwrap()) +} + +/// Score a page as a potential EPT PML4 table. fn score_ept_page(page: &[u8], l1_size: u64) -> (u32, u32, u32, u64) { let mut valid = 0u32; let mut zero = 0u32; let mut invalid = 0u32; let mut max_idx: u64 = 0; - for i in 0..512 { - let off = i * 8; - let entry = u64::from_le_bytes(page[off..off + 8].try_into().unwrap()); - + for i in 0..512u64 { + let entry = read_entry(page, i); if entry == 0 { zero += 1; continue; @@ -205,10 +381,9 @@ fn score_ept_page(page: &[u8], l1_size: u64) -> (u32, u32, u32, u64) { let rwx = entry & EPT_PRESENT_MASK; let phys = entry & EPT_ADDR_MASK; - // Valid EPT entry: has at least one RWX bit and points within L1 memory if rwx != 0 && phys < l1_size && phys != 0 { valid += 1; - max_idx = i as u64; + max_idx = i; } else { invalid += 1; } @@ -217,22 +392,25 @@ fn score_ept_page(page: &[u8], l1_size: u64) -> (u32, u32, u32, u64) { (valid, zero, invalid, max_idx) } -/// Count roughly how many pages are mapped by this EPT PML4. -/// Only walks 2 levels deep for speed (PML4 → PDPT). -fn count_ept_mapped_pages(l1: &P, pml4_addr: u64, l1_size: u64) -> u64 { - let mut total_pages: u64 = 0; +/// Sample translated pages to verify an EPT candidate maps non-zero data. +fn sample_nonzero_translated( + l1: &P, + pml4_addr: u64, + l1_size: u64, + max_samples: usize, +) -> (u32, u32) { + let mut targets: Vec = Vec::with_capacity(max_samples); let mut pml4_buf = [0u8; PAGE_SIZE as usize]; if l1.read_phys(pml4_addr, &mut pml4_buf).is_err() { - return 0; + return (0, 0); } - for i in 0..512u64 { - let pml4e = u64::from_le_bytes(pml4_buf[(i * 8) as usize..(i * 8 + 8) as usize].try_into().unwrap()); + 'outer: for i in 0..512u64 { + let pml4e = read_entry(&pml4_buf, i); if pml4e & EPT_PRESENT_MASK == 0 { continue; } - let pdpt_addr = pml4e & EPT_ADDR_MASK; if pdpt_addr >= l1_size { continue; @@ -244,16 +422,19 @@ fn count_ept_mapped_pages(l1: &P, pml4_addr: u64, l1_size: u6 } for j in 0..512u64 { - let pdpte = u64::from_le_bytes( - pdpt_buf[(j * 8) as usize..(j * 8 + 8) as usize].try_into().unwrap(), - ); + let pdpte = read_entry(&pdpt_buf, j); if pdpte & EPT_PRESENT_MASK == 0 { continue; } - // 1GB large page if pdpte & EPT_LARGE_PAGE != 0 { - total_pages += 262144; // 1GB / 4KB + let base = pdpte & 0x000F_FFFF_C000_0000; + if base < l1_size { + targets.push(base); + } + if targets.len() >= max_samples { + break 'outer; + } continue; } @@ -262,28 +443,64 @@ fn count_ept_mapped_pages(l1: &P, pml4_addr: u64, l1_size: u6 continue; } - // Count PD entries (don't go to PT level for speed) let mut pd_buf = [0u8; PAGE_SIZE as usize]; if l1.read_phys(pd_addr, &mut pd_buf).is_err() { continue; } for k in 0..512u64 { - let pde = u64::from_le_bytes( - pd_buf[(k * 8) as usize..(k * 8 + 8) as usize].try_into().unwrap(), - ); + let pde = read_entry(&pd_buf, k); if pde & EPT_PRESENT_MASK == 0 { continue; } + if pde & EPT_LARGE_PAGE != 0 { - total_pages += 512; // 2MB / 4KB - } else { - // Assume ~256 out of 512 PT entries are valid on average - total_pages += 256; + let base = pde & 0x000F_FFFF_FFE0_0000; + if base < l1_size { + targets.push(base); + } + if targets.len() >= max_samples { + break 'outer; + } + continue; + } + + let pt_addr = pde & EPT_ADDR_MASK; + if pt_addr >= l1_size { + continue; + } + + let mut pt_buf = [0u8; PAGE_SIZE as usize]; + if l1.read_phys(pt_addr, &mut pt_buf).is_err() { + continue; + } + + for l in 0..512u64 { + let pte = read_entry(&pt_buf, l); + if pte & EPT_PRESENT_MASK == 0 { + continue; + } + let target = pte & EPT_ADDR_MASK; + if target < l1_size { + targets.push(target); + } + if targets.len() >= max_samples { + break 'outer; + } } } } } - total_pages + let mut nonzero = 0u32; + let total = targets.len() as u32; + let mut page_buf = [0u8; PAGE_SIZE as usize]; + + for &target in &targets { + if l1.read_phys(target, &mut page_buf).is_ok() && !page_buf.iter().all(|&b| b == 0) { + nonzero += 1; + } + } + + (nonzero, total) } diff --git a/src/sam/mod.rs b/src/sam/mod.rs index a23b72a..4b11456 100644 --- a/src/sam/mod.rs +++ b/src/sam/mod.rs @@ -1,8 +1,9 @@ -pub mod hive; pub mod bootkey; pub mod cache; pub mod hashes; +pub mod hive; pub mod lsa; +pub mod ntds; mod ntfs_fallback; use std::collections::HashMap; @@ -16,6 +17,14 @@ use crate::error::Result; /// SAM + SYSTEM + optional SECURITY hive file data. type HiveFiles = (Vec, Vec, Option>); +/// NTDS + SYSTEM files extracted from a disk image. +#[derive(Debug)] +pub struct NtdsArtifacts { + pub ntds_data: Vec, + pub system_data: Vec, + pub partition_offset: u64, +} + /// A SAM user entry with RID, username, and NT/LM hashes. #[derive(Debug)] pub struct SamEntry { @@ -40,6 +49,14 @@ pub fn extract_sam_hashes(path: &Path) -> Result> { Ok(secrets.sam_entries) } +/// Extract NTDS artifacts (NTDS.dit + SYSTEM hive) from a disk image. +/// +/// This is the input set required by offline AD secrets extraction workflows. +pub fn extract_ntds_artifacts(path: &Path) -> Result { + let mut disk = crate::disk::open_disk(path)?; + extract_ntds_artifacts_from_reader(&mut disk) +} + /// Extract both SAM hashes and LSA secrets from a disk image. pub fn extract_disk_secrets(path: &Path) -> Result { let mut disk = crate::disk::open_disk(path)?; @@ -124,6 +141,34 @@ fn extract_secrets_from_reader(reader: &mut R) -> Result(reader: &mut R) -> Result { + let partitions = find_ntfs_partitions(reader).unwrap_or_default(); + + for &partition_offset in &partitions { + log::info!( + "Trying NTDS extraction on NTFS partition at offset 0x{:x}", + partition_offset + ); + match read_ntds_artifacts(reader, partition_offset) { + Ok((ntds_data, system_data)) => { + return Ok(NtdsArtifacts { + ntds_data, + system_data, + partition_offset, + }); + } + Err(e) => { + log::info!("Partition at 0x{:x}: {}", partition_offset, e); + } + } + } + + Err(crate::error::GovmemError::DecryptionError( + "NTDS.dit not found on readable NTFS partitions".to_string(), + )) +} + /// Process extracted hive data into DiskSecrets. fn process_hive_data( sam_data: Vec, @@ -264,7 +309,9 @@ pub(crate) fn find_ntfs_partitions(reader: &mut R) -> Result(reader: &mut R) -> Result> log::debug!( "GPT: entry_lba={}, num_entries={}, entry_size={}", - entry_lba, num_entries, entry_size + entry_lba, + num_entries, + entry_size ); // "Microsoft Basic Data" GUID: EBD0A0A2-B9E5-4433-87C0-68B6B72699C7 // Mixed-endian byte representation const BASIC_DATA_GUID: [u8; 16] = [ - 0xA2, 0xA0, 0xD0, 0xEB, 0xE5, 0xB9, 0x33, 0x44, - 0x87, 0xC0, 0x68, 0xB6, 0xB7, 0x26, 0x99, 0xC7, + 0xA2, 0xA0, 0xD0, 0xEB, 0xE5, 0xB9, 0x33, 0x44, 0x87, 0xC0, 0x68, 0xB6, 0xB7, 0x26, 0x99, + 0xC7, ]; let mut partitions = Vec::new(); @@ -348,10 +397,7 @@ fn find_gpt_ntfs_partitions(reader: &mut R) -> Result> } /// Read SAM, SYSTEM, and (optionally) SECURITY hive files from NTFS filesystem. -fn read_hive_files( - reader: &mut R, - partition_offset: u64, -) -> Result { +fn read_hive_files(reader: &mut R, partition_offset: u64) -> Result { // Wrap reader with partition offset let mut part_reader = PartitionReader::new(reader, partition_offset); @@ -359,10 +405,7 @@ fn read_hive_files( Ok(n) => n, Err(e) => { log::info!("NTFS parse error: {}, trying MFTMirr fallback", e); - return ntfs_fallback::try_mftmirr_fallback( - part_reader.inner_mut(), - partition_offset, - ); + return ntfs_fallback::try_mftmirr_fallback(part_reader.inner_mut(), partition_offset); } }; @@ -387,20 +430,43 @@ fn read_hive_files( Ok((sam_data, system_data, security_data)) } Err(e) => { - log::info!( - "NTFS root dir error: {}, trying MFTMirr fallback", - e, - ); + log::info!("NTFS root dir error: {}, trying MFTMirr fallback", e,); // Drop ntfs/part_reader borrows, then use MFTMirr fallback drop(ntfs); - ntfs_fallback::try_mftmirr_fallback( - part_reader.inner_mut(), - partition_offset, - ) + ntfs_fallback::try_mftmirr_fallback(part_reader.inner_mut(), partition_offset) } } } +/// Read NTDS.dit + SYSTEM hive from NTFS filesystem. +fn read_ntds_artifacts( + reader: &mut R, + partition_offset: u64, +) -> Result<(Vec, Vec)> { + let mut part_reader = PartitionReader::new(reader, partition_offset); + + let ntfs = ntfs::Ntfs::new(&mut part_reader).map_err(|e| { + crate::error::GovmemError::DecryptionError(format!("NTFS parse error: {}", e)) + })?; + + let root = ntfs.root_directory(&mut part_reader).map_err(|e| { + crate::error::GovmemError::DecryptionError(format!("NTFS root dir error: {}", e)) + })?; + + let windows = find_entry(&ntfs, &root, &mut part_reader, "Windows")?; + + let ntds_dir = find_entry(&ntfs, &windows, &mut part_reader, "NTDS")?; + let ntds_file = find_entry(&ntfs, &ntds_dir, &mut part_reader, "ntds.dit")?; + let ntds_data = read_file_data(&ntds_file, &mut part_reader)?; + + let system32 = find_entry(&ntfs, &windows, &mut part_reader, "System32")?; + let config = find_entry(&ntfs, &system32, &mut part_reader, "config")?; + let system_file = find_entry(&ntfs, &config, &mut part_reader, "SYSTEM")?; + let system_data = read_file_data(&system_file, &mut part_reader)?; + + Ok((ntds_data, system_data)) +} + /// Find a directory entry by name (case-insensitive). pub(crate) fn find_entry<'n, R: Read + Seek>( ntfs: &'n ntfs::Ntfs, @@ -454,9 +520,7 @@ pub(crate) fn read_file_data( .ok_or_else(|| { crate::error::GovmemError::DecryptionError("No $DATA attribute".to_string()) })? - .map_err(|e| { - crate::error::GovmemError::DecryptionError(format!("$DATA error: {}", e)) - })?; + .map_err(|e| crate::error::GovmemError::DecryptionError(format!("$DATA error: {}", e)))?; let data_attr = data_item.to_attribute().map_err(|e| { crate::error::GovmemError::DecryptionError(format!("to_attribute error: {}", e)) })?; @@ -498,7 +562,9 @@ impl Seek for PartitionReader<'_, R> { fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { match pos { std::io::SeekFrom::Start(offset) => { - let actual = self.inner.seek(std::io::SeekFrom::Start(self.offset + offset))?; + let actual = self + .inner + .seek(std::io::SeekFrom::Start(self.offset + offset))?; Ok(actual - self.offset) } std::io::SeekFrom::Current(delta) => { @@ -580,11 +646,7 @@ fn scan_for_hives(reader: &mut R) -> Result { if sam_data.is_some() && system_data.is_some() { log::info!("Found all required hives ({} total regf)", found_count); #[allow(clippy::unnecessary_unwrap)] - return Ok(( - sam_data.unwrap(), - system_data.unwrap(), - security_data, - )); + return Ok((sam_data.unwrap(), system_data.unwrap(), security_data)); } } // Restore read position for continued scanning @@ -631,11 +693,7 @@ fn try_read_hive(reader: &mut R, offset: u64) -> Option<(String, // hive_bins_data_size at offset 0x28 let bins_size = u32::from_le_bytes(header[0x28..0x2C].try_into().unwrap()) as u64; if bins_size == 0 || bins_size > MAX_HIVE_SIZE { - log::debug!( - "regf at 0x{:x}: bins_size={} (skipped)", - offset, - bins_size - ); + log::debug!("regf at 0x{:x}: bins_size={} (skipped)", offset, bins_size); return None; } @@ -728,10 +786,8 @@ fn scan_for_hbin_roots(reader: &mut R) -> Result { while pos + 0x60 <= n { if &chunk[pos..pos + 4] == b"hbin" { // hbin header: "hbin"(4) + offset_in_hive(4) + size(4) + ... - let hbin_hive_off = - u32::from_le_bytes(chunk[pos + 4..pos + 8].try_into().unwrap()); - let hbin_size = - u32::from_le_bytes(chunk[pos + 8..pos + 12].try_into().unwrap()); + let hbin_hive_off = u32::from_le_bytes(chunk[pos + 4..pos + 8].try_into().unwrap()); + let hbin_size = u32::from_le_bytes(chunk[pos + 8..pos + 12].try_into().unwrap()); // Only interested in first hbin of a hive (offset_in_hive == 0) if hbin_hive_off == 0 && (0x1000..=0x100000).contains(&hbin_size) { @@ -753,11 +809,7 @@ fn scan_for_hbin_roots(reader: &mut R) -> Result { if sam_data.is_some() && system_data.is_some() { log::info!("Found all required hives via hbin scan"); #[allow(clippy::unnecessary_unwrap)] - return Ok(( - sam_data.unwrap(), - system_data.unwrap(), - security_data, - )); + return Ok((sam_data.unwrap(), system_data.unwrap(), security_data)); } } reader.seek(SeekFrom::Start(offset + n as u64))?; @@ -774,10 +826,7 @@ fn scan_for_hbin_roots(reader: &mut R) -> Result { if let (Some(sam), Some(system)) = (sam_data, system_data) { Ok((sam, system, security_data)) } else { - let mut detail = format!( - "hbin scan found {} candidate(s) but missing", - found_count - ); + let mut detail = format!("hbin scan found {} candidate(s) but missing", found_count); if !has_sam { detail.push_str(" SAM"); } @@ -826,16 +875,17 @@ fn try_read_hbin_hive( // Since we already filtered for hbin offset_in_hive==0, the NK cell at // offset 0x20 IS the root key by definition. - let name_len = - u16::from_le_bytes(first_block[cell_off + 0x4C..cell_off + 0x4E].try_into().unwrap()) - as usize; + let name_len = u16::from_le_bytes( + first_block[cell_off + 0x4C..cell_off + 0x4E] + .try_into() + .unwrap(), + ) as usize; if name_len == 0 || cell_off + 0x50 + name_len > first_block.len() { return None; } - let name = - String::from_utf8_lossy(&first_block[cell_off + 0x50..cell_off + 0x50 + name_len]) - .to_uppercase(); + let name = String::from_utf8_lossy(&first_block[cell_off + 0x50..cell_off + 0x50 + name_len]) + .to_uppercase(); // Only accept target hive names if !matches!(name.as_str(), "SAM" | "SYSTEM" | "SECURITY") { @@ -873,10 +923,8 @@ fn try_read_hbin_hive( break; } - let hbin_hive_off = - u32::from_le_bytes(hbin_buf[4..8].try_into().unwrap()) as usize; - let block_size = - u32::from_le_bytes(hbin_buf[8..12].try_into().unwrap()) as usize; + let hbin_hive_off = u32::from_le_bytes(hbin_buf[4..8].try_into().unwrap()) as usize; + let block_size = u32::from_le_bytes(hbin_buf[8..12].try_into().unwrap()) as usize; if !(0x1000..=0x100000).contains(&block_size) { break; } @@ -911,19 +959,11 @@ fn try_read_hbin_hive( // Apply same size validation match name.as_str() { "SYSTEM" if total_size < MIN_SYSTEM_HIVE_SIZE => { - log::debug!( - "hbin hive '{}' too small ({}B), skipping", - name, - total_size - ); + log::debug!("hbin hive '{}' too small ({}B), skipping", name, total_size); return None; } "SAM" if total_size < MIN_SAM_HIVE_SIZE => { - log::debug!( - "hbin hive '{}' too small ({}B), skipping", - name, - total_size - ); + log::debug!("hbin hive '{}' too small ({}B), skipping", name, total_size); return None; } _ => {} @@ -962,7 +1002,7 @@ fn scan_vmdk_grains_for_hives( // Phase 1: Collect candidates from grain data let mut regf_candidates: Vec<(u64, u64)> = Vec::new(); // (virtual_offset, bins_size) let mut hbin_root_candidates: Vec<(u64, String)> = Vec::new(); // (virtual_offset, name) - // ALL hbin blocks: (virtual_offset, offset_in_hive, block_size) + // ALL hbin blocks: (virtual_offset, offset_in_hive, block_size) let mut all_hbin_blocks: Vec<(u64, u32, u32)> = Vec::new(); vmdk.scan_all_grains(|virtual_byte, grain_data| { @@ -974,8 +1014,7 @@ fn scan_vmdk_grains_for_hives( // Check for "regf" signature if pos + 0x2C <= grain_data.len() && chunk[0..4] == *b"regf" { - let bins_size = - u32::from_le_bytes(chunk[0x28..0x2C].try_into().unwrap()) as u64; + let bins_size = u32::from_le_bytes(chunk[0x28..0x2C].try_into().unwrap()) as u64; if bins_size > 0 && bins_size <= MAX_HIVE_SIZE { regf_candidates.push((virtual_byte + pos as u64, bins_size)); } @@ -983,19 +1022,13 @@ fn scan_vmdk_grains_for_hives( // Check for "hbin" signature (ANY hbin block, not just offset=0) if pos + 0x20 <= grain_data.len() && chunk[0..4] == *b"hbin" { - let hbin_hive_off = - u32::from_le_bytes(chunk[4..8].try_into().unwrap()); - let hbin_size = - u32::from_le_bytes(chunk[8..12].try_into().unwrap()); + let hbin_hive_off = u32::from_le_bytes(chunk[4..8].try_into().unwrap()); + let hbin_size = u32::from_le_bytes(chunk[8..12].try_into().unwrap()); if (0x1000..=0x100000).contains(&hbin_size) && (hbin_hive_off as u64) < MAX_HIVE_SIZE && hbin_hive_off % 0x1000 == 0 { - all_hbin_blocks.push(( - virtual_byte + pos as u64, - hbin_hive_off, - hbin_size, - )); + all_hbin_blocks.push((virtual_byte + pos as u64, hbin_hive_off, hbin_size)); // For offset=0 blocks, parse root NK cell to identify hive if hbin_hive_off == 0 { @@ -1004,9 +1037,7 @@ fn scan_vmdk_grains_for_hives( && &chunk[cell_off + 4..cell_off + 6] == b"nk" { let name_len = u16::from_le_bytes( - chunk[cell_off + 0x4C..cell_off + 0x4E] - .try_into() - .unwrap(), + chunk[cell_off + 0x4C..cell_off + 0x4E].try_into().unwrap(), ) as usize; if name_len > 0 && cell_off + 0x50 + name_len <= chunk.len() { let name = String::from_utf8_lossy( @@ -1020,10 +1051,7 @@ fn scan_vmdk_grains_for_hives( virtual_byte, pos, ); - hbin_root_candidates.push(( - virtual_byte + pos as u64, - name, - )); + hbin_root_candidates.push((virtual_byte + pos as u64, name)); } } } @@ -1078,7 +1106,9 @@ fn scan_vmdk_grains_for_hives( }; log::info!( "Grain scan: read {} hive at virt 0x{:x} ({} bytes)", - name, virt_off, total_size + name, + virt_off, + total_size ); *target = Some(data); @@ -1089,7 +1119,10 @@ fn scan_vmdk_grains_for_hives( match (sam_data, system_data) { (Some(sam), Some(system)) => return Ok(((sam, system, security_data), None)), - (s, sys) => { sam_data = s; system_data = sys; } + (s, sys) => { + sam_data = s; + system_data = sys; + } } // 2b: Try hbin root candidates — read contiguous hbin blocks @@ -1118,10 +1151,8 @@ fn scan_vmdk_grains_for_hives( if &hbin_buf[0..4] != b"hbin" { break; } - let hbin_hive_off = - u32::from_le_bytes(hbin_buf[4..8].try_into().unwrap()) as usize; - let block_size = - u32::from_le_bytes(hbin_buf[8..12].try_into().unwrap()) as usize; + let hbin_hive_off = u32::from_le_bytes(hbin_buf[4..8].try_into().unwrap()) as usize; + let block_size = u32::from_le_bytes(hbin_buf[8..12].try_into().unwrap()) as usize; if !(0x1000..=0x100000).contains(&block_size) { break; } @@ -1139,15 +1170,12 @@ fn scan_vmdk_grains_for_hives( read_offset += block_size as u64; } - if let Some(hive_data) = build_hive_from_hbins( - vmdk, - name, - &hbin_data, - ®f_candidates, - ) { + if let Some(hive_data) = build_hive_from_hbins(vmdk, name, &hbin_data, ®f_candidates) { log::info!( "Grain scan: valid {} hive from contiguous hbin at virt 0x{:x} ({} bytes)", - name, hbin_virt, hive_data.len() + name, + hbin_virt, + hive_data.len() ); *target = Some(hive_data); } @@ -1156,7 +1184,10 @@ fn scan_vmdk_grains_for_hives( // If SYSTEM hive is too small, save it as fallback but allow Phase 2c to try // assembling a more complete hive from scattered hbin blocks. let mut small_system_fallback: Option> = None; - if system_data.as_ref().is_some_and(|d| (d.len() as u64) < MIN_SYSTEM_HIVE_SIZE) { + if system_data + .as_ref() + .is_some_and(|d| (d.len() as u64) < MIN_SYSTEM_HIVE_SIZE) + { log::info!( "SYSTEM hive only {} bytes (< {} minimum), will try fragmented assembly", system_data.as_ref().unwrap().len(), @@ -1167,7 +1198,10 @@ fn scan_vmdk_grains_for_hives( match (sam_data, system_data) { (Some(sam), Some(system)) => return Ok(((sam, system, security_data), None)), - (s, sys) => { sam_data = s; system_data = sys; } + (s, sys) => { + sam_data = s; + system_data = sys; + } } // Phase 2c: Fragmented hive assembly @@ -1208,11 +1242,12 @@ fn scan_vmdk_grains_for_hives( // Greedy assembly will fill gaps with zeros. let default = match name.as_str() { "SYSTEM" => 0x800000u32, // 8MB - _ => 0x10000u32, // 64KB for SAM/SECURITY + _ => 0x10000u32, // 64KB for SAM/SECURITY }; log::info!( "Fragmented {}: no matching regf header, using default bins_size=0x{:x}", - name, default, + name, + default, ); default } @@ -1240,12 +1275,9 @@ fn scan_vmdk_grains_for_hives( ); if let Some(hbin_data) = assembled { - if let Some(hive_data) = build_hive_from_hbins( - vmdk, - name, - &hbin_data, - ®f_candidates, - ) { + if let Some(hive_data) = + build_hive_from_hbins(vmdk, name, &hbin_data, ®f_candidates) + { log::info!( "Grain scan: valid {} hive assembled from fragmented hbin blocks ({} bytes)", name, @@ -1338,7 +1370,10 @@ fn try_scattered_bootkey( blocks.push((off_in_hive, data)); } - log::info!("Read {} hbin blocks for scattered bootkey scan", blocks.len()); + log::info!( + "Read {} hbin blocks for scattered bootkey scan", + blocks.len() + ); bootkey::scan_blocks_for_bootkey(&blocks) } @@ -1377,7 +1412,10 @@ fn find_regf_for_hives( // Prefer the regf with the largest bins_size (most recent/complete) log::info!( "Matched regf at 0x{:x} as {} (bins_size=0x{:x}, path={})", - roff, hive_name, rbins, path.trim() + roff, + hive_name, + rbins, + path.trim() ); if result.get(hive_name).is_none_or(|&(_, prev)| rbins > prev) { result.insert(hive_name, (roff, rbins)); @@ -1421,7 +1459,10 @@ fn assemble_fragmented_hive( { return Some(result); } - log::info!("Fragmented {}: backtracking failed, trying greedy fallback", name); + log::info!( + "Fragmented {}: backtracking failed, trying greedy fallback", + name + ); } assemble_greedy(vmdk, hbin_by_offset, name, bins_size, root_data) } @@ -1644,8 +1685,7 @@ fn assemble_greedy( continue; } if block.len() >= 12 && &block[0..4] == b"hbin" { - let actual_off = - u32::from_le_bytes(block[4..8].try_into().unwrap()); + let actual_off = u32::from_le_bytes(block[4..8].try_into().unwrap()); if actual_off == next_offset { assembled.extend_from_slice(&block); next_offset += blk_size; @@ -1776,7 +1816,7 @@ fn build_hive_from_hbins( "SECURITY" => path.contains("CONFIG\\SECURITY") || path.ends_with("\\SECURITY"), _ => false, }; - if matches && best_regf.is_none_or(|(_,prev)| rbins > prev) { + if matches && best_regf.is_none_or(|(_, prev)| rbins > prev) { best_regf = Some((roff, rbins)); } } diff --git a/src/sam/ntds.rs b/src/sam/ntds.rs new file mode 100644 index 0000000..a72ea07 --- /dev/null +++ b/src/sam/ntds.rs @@ -0,0 +1,220 @@ +//! NTDS.dit helpers for AD secrets extraction workflows. +//! +//! This module validates extracted NTDS artifacts and prepares metadata +//! needed for downstream domain credential extraction. + +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::error::{GovmemError, Result}; + +/// High-level NTDS context extracted from disk artifacts. +#[derive(Debug, Clone)] +pub struct NtdsContext { + pub ntds_size: usize, + pub boot_key: [u8; 16], +} + +/// A single AD NTLM hash entry extracted from NTDS. +#[derive(Debug, Clone)] +pub struct AdHashEntry { + pub username: String, + pub rid: u32, + pub lm_hash: [u8; 16], + pub nt_hash: [u8; 16], + pub is_history: bool, + pub history_index: Option, +} + +/// Build NTDS context from raw NTDS.dit + SYSTEM hive bytes. +pub fn build_context(ntds_data: &[u8], system_data: &[u8]) -> Result { + if !is_ese_database(ntds_data) { + return Err(GovmemError::DecryptionError( + "NTDS.dit does not look like a valid ESE database".to_string(), + )); + } + + let boot_key = super::bootkey::extract_bootkey(system_data)?; + Ok(NtdsContext { + ntds_size: ntds_data.len(), + boot_key, + }) +} + +/// Minimal ESE validity check for NTDS.dit. +/// +/// ESE databases use little-endian 0x89ABCDEF at offset 0x04. +fn is_ese_database(data: &[u8]) -> bool { + if data.len() < 8 { + return false; + } + data[4..8] == [0xEF, 0xCD, 0xAB, 0x89] +} + +/// Extract AD NTLM hashes from NTDS + SYSTEM using local impacket-secretsdump. +/// +/// This keeps orchestration in Rust while leveraging the battle-tested parser +/// already present in the runtime environment. +pub fn extract_ad_hashes( + ntds_data: &[u8], + system_data: &[u8], + include_history: bool, +) -> Result> { + let _ctx = build_context(ntds_data, system_data)?; + let temp_dir = make_temp_dir()?; + let ntds_path = temp_dir.join("ntds.dit"); + let system_path = temp_dir.join("SYSTEM"); + + fs::write(&ntds_path, ntds_data).map_err(GovmemError::Io)?; + fs::write(&system_path, system_data).map_err(GovmemError::Io)?; + + let output = run_secretsdump(&system_path, &ntds_path, include_history); + let _ = fs::remove_file(&ntds_path); + let _ = fs::remove_file(&system_path); + let _ = fs::remove_dir(&temp_dir); + + let stdout = output?; + parse_secretsdump_output(&stdout) +} + +fn run_secretsdump( + system_path: &PathBuf, + ntds_path: &PathBuf, + include_history: bool, +) -> Result { + let mut cmd = Command::new("impacket-secretsdump"); + cmd.arg("-system") + .arg(system_path) + .arg("-ntds") + .arg(ntds_path) + .arg("-just-dc-ntlm"); + if include_history { + cmd.arg("-history"); + } + cmd.arg("LOCAL"); + + let output = cmd.output().map_err(|e| { + GovmemError::DecryptionError(format!( + "Failed to execute impacket-secretsdump (is it installed?): {}", + e + )) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GovmemError::DecryptionError(format!( + "impacket-secretsdump failed: {}", + stderr.trim() + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn parse_secretsdump_output(stdout: &str) -> Result> { + let mut entries = Vec::new(); + + for line in stdout.lines() { + // Format: username:rid:lmhash:nthash::: + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() < 4 { + continue; + } + + let username_raw = parts[0].trim(); + let rid_raw = parts[1].trim(); + let lm_raw = parts[2].trim(); + let nt_raw = parts[3].trim(); + + if username_raw.is_empty() || rid_raw.is_empty() || lm_raw.len() != 32 || nt_raw.len() != 32 + { + continue; + } + + let rid = match rid_raw.parse::() { + Ok(v) => v, + Err(_) => continue, + }; + + let lm_hash = parse_hash_16(lm_raw)?; + let nt_hash = parse_hash_16(nt_raw)?; + let (username, is_history, history_index) = parse_history_name(username_raw); + + entries.push(AdHashEntry { + username, + rid, + lm_hash, + nt_hash, + is_history, + history_index, + }); + } + + if entries.is_empty() { + return Err(GovmemError::DecryptionError( + "No NTDS NTLM hashes found in secretsdump output".to_string(), + )); + } + + Ok(entries) +} + +fn parse_hash_16(hex_str: &str) -> Result<[u8; 16]> { + let bytes = hex::decode(hex_str).map_err(|e| { + GovmemError::DecryptionError(format!("Invalid hex hash '{}': {}", hex_str, e)) + })?; + if bytes.len() != 16 { + return Err(GovmemError::DecryptionError(format!( + "Invalid hash length for '{}': {}", + hex_str, + bytes.len() + ))); + } + + let mut out = [0u8; 16]; + out.copy_from_slice(&bytes); + Ok(out) +} + +fn parse_history_name(name: &str) -> (String, bool, Option) { + let marker = "_history"; + if let Some(idx) = name.rfind(marker) { + let base = &name[..idx]; + let suffix = &name[idx + marker.len()..]; + if !base.is_empty() { + if suffix.is_empty() { + return (base.to_string(), true, None); + } + if suffix.chars().all(|c| c.is_ascii_digit()) { + let hist_idx = suffix.parse::().ok(); + return (base.to_string(), true, hist_idx); + } + } + } + + (name.to_string(), false, None) +} + +fn make_temp_dir() -> Result { + let base = std::env::temp_dir(); + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| GovmemError::DecryptionError(format!("System clock error: {}", e)))? + .as_millis(); + let pid = std::process::id(); + + for attempt in 0..16u32 { + let dir = base.join(format!("vmkatz-ntds-{}-{}-{}", pid, ts, attempt)); + match fs::create_dir(&dir) { + Ok(()) => return Ok(dir), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(GovmemError::Io(e)), + } + } + + Err(GovmemError::DecryptionError( + "Failed to create temporary directory for NTDS extraction".to_string(), + )) +}