mirror of
https://github.com/nikaiw/VMkatz
synced 2026-09-25 07:51:06 +00:00
Add NTDS feature pipeline and release workflow
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
®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<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,
|
||||
®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));
|
||||
}
|
||||
}
|
||||
|
||||
+220
@@ -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(),
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user