Add NTDS feature pipeline and release workflow

This commit is contained in:
NK
2026-02-19 16:21:49 +01:00
parent 6120e4a50d
commit 9df9c2fca2
7 changed files with 1413 additions and 395 deletions
+37
View File
@@ -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
+1
View File
@@ -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 }
+308 -97
View File
@@ -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<u64, MsvSessionInfo> = std::collections::HashMap::new();
let mut session_map: std::collections::HashMap<u64, MsvSessionInfo> =
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<u64, MsvSessionInfo>,
new: MsvSessionInfo,
) {
fn merge_session(map: &mut std::collections::HashMap<u64, MsvSessionInfo>, 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<u64> {
fn find_credentials_ptr_in_entry(vmem: &impl VirtualMemory, entry_addr: u64) -> Option<u64> {
// 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<Vec<u64>> {
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<Vec<(u64, usize)>> {
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::<Vec<_>>().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::<Vec<_>>()
.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());
+354 -62
View File
@@ -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<L: PhysicalMemory, F: FnOnce() -> anyhow::Result<L>>(
make_layer: F,
args: &Args,
@@ -534,34 +736,94 @@ fn run_with_layer<L: PhysicalMemory, F: FnOnce() -> anyhow::Result<L>>(
// 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<L: PhysicalMemory>(
layer: &L,
system: &vmkatz::windows::process::Process,
@@ -652,7 +914,12 @@ fn run_with_system<L: PhysicalMemory>(
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()) {
+337 -120
View File
@@ -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<EptMapping>, // 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<u64> {
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<u64> {
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<u64> {
// 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<Self::Item> {
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<P: PhysicalMemory>(l1: &P) -> Result<(u64, u64)> {
/// Find all EPT candidates, ranked by quality (most non-zero translated pages first).
pub fn find_ept_candidates<P: PhysicalMemory>(l1: &P) -> Result<Vec<EptCandidate>> {
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<P: PhysicalMemory>(l1: &P) -> Result<(u64, u64)> {
);
let mut page_buf = vec![0u8; PAGE_SIZE as usize];
let mut candidates = 0u32;
let mut candidates: Vec<EptCandidate> = Vec::new();
let mut addr: u64 = 0;
while addr < l1_size {
@@ -132,71 +273,106 @@ pub fn find_ept_root<P: PhysicalMemory>(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<P: PhysicalMemory>(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<P: PhysicalMemory>(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<P: PhysicalMemory>(
l1: &P,
pml4_addr: u64,
l1_size: u64,
max_samples: usize,
) -> (u32, u32) {
let mut targets: Vec<u64> = 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<P: PhysicalMemory>(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<P: PhysicalMemory>(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)
}
+156 -116
View File
@@ -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<u8>, Vec<u8>, Option<Vec<u8>>);
/// NTDS + SYSTEM files extracted from a disk image.
#[derive(Debug)]
pub struct NtdsArtifacts {
pub ntds_data: Vec<u8>,
pub system_data: Vec<u8>,
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<Vec<SamEntry>> {
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<NtdsArtifacts> {
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<DiskSecrets> {
let mut disk = crate::disk::open_disk(path)?;
@@ -124,6 +141,34 @@ fn extract_secrets_from_reader<R: Read + Seek>(reader: &mut R) -> Result<DiskSec
}
}
/// Extract NTDS artifacts from any Read+Seek source.
fn extract_ntds_artifacts_from_reader<R: Read + Seek>(reader: &mut R) -> Result<NtdsArtifacts> {
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<u8>,
@@ -264,7 +309,9 @@ pub(crate) fn find_ntfs_partitions<R: Read + Seek>(reader: &mut R) -> Result<Vec
log::debug!(
"MBR Partition {}: type=0x{:02x}, LBA_start={}",
i, part_type, lba_start
i,
part_type,
lba_start
);
// NTFS partition type is 0x07
@@ -304,14 +351,16 @@ fn find_gpt_ntfs_partitions<R: Read + Seek>(reader: &mut R) -> Result<Vec<u64>>
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<R: Read + Seek>(reader: &mut R) -> Result<Vec<u64>>
}
/// Read SAM, SYSTEM, and (optionally) SECURITY hive files from NTFS filesystem.
fn read_hive_files<R: Read + Seek>(
reader: &mut R,
partition_offset: u64,
) -> Result<HiveFiles> {
fn read_hive_files<R: Read + Seek>(reader: &mut R, partition_offset: u64) -> Result<HiveFiles> {
// Wrap reader with partition offset
let mut part_reader = PartitionReader::new(reader, partition_offset);
@@ -359,10 +405,7 @@ fn read_hive_files<R: Read + Seek>(
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<R: Read + Seek>(
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<R: Read + Seek>(
reader: &mut R,
partition_offset: u64,
) -> Result<(Vec<u8>, Vec<u8>)> {
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<R: Read + Seek>(
.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<R: Read + Seek> Seek for PartitionReader<'_, R> {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
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<R: Read + Seek>(reader: &mut R) -> Result<HiveFiles> {
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<R: Read + Seek>(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<R: Read + Seek>(reader: &mut R) -> Result<HiveFiles> {
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<R: Read + Seek>(reader: &mut R) -> Result<HiveFiles> {
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<R: Read + Seek>(reader: &mut R) -> Result<HiveFiles> {
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<R: Read + Seek>(
// 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<R: Read + Seek>(
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<R: Read + Seek>(
// 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,
&regf_candidates,
) {
if let Some(hive_data) = build_hive_from_hbins(vmdk, name, &hbin_data, &regf_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<Vec<u8>> = 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,
&regf_candidates,
) {
if let Some(hive_data) =
build_hive_from_hbins(vmdk, name, &hbin_data, &regf_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));
}
}
+220
View File
@@ -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<u32>,
}
/// Build NTDS context from raw NTDS.dit + SYSTEM hive bytes.
pub fn build_context(ntds_data: &[u8], system_data: &[u8]) -> Result<NtdsContext> {
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<Vec<AdHashEntry>> {
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<String> {
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<Vec<AdHashEntry>> {
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::<u32>() {
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<u32>) {
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::<u32>().ok();
return (base.to_string(), true, hist_idx);
}
}
}
(name.to_string(), false, None)
}
fn make_temp_dir() -> Result<PathBuf> {
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(),
))
}