mirror of
https://github.com/nikaiw/VMkatz
synced 2026-09-25 07:51:06 +00:00
Add DCC2 domain cached credential extraction from SECURITY hive
Extract MsCacheV2 (DCC2) hashes from SECURITY\Cache\NL$n values, decrypted with the NL$KM key from LSA secrets. Output in hashcat mode 2100 format ($DCC2$<iter>#<user>#<hash>).
This commit is contained in:
+12
@@ -154,6 +154,10 @@ fn run_sam(input_path: &Path, args: &Args) -> anyhow::Result<()> {
|
||||
print_lsa_secrets(&secrets.lsa_secrets);
|
||||
}
|
||||
|
||||
if !secrets.cached_credentials.is_empty() {
|
||||
print_cached_credentials(&secrets.cached_credentials);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -206,6 +210,14 @@ fn print_lsa_secrets(secrets: &[vmkatz::sam::lsa::LsaSecret]) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sam")]
|
||||
fn print_cached_credentials(creds: &[vmkatz::sam::cache::CachedCredential]) {
|
||||
println!("\n[+] Domain Cached Credentials (DCC2):");
|
||||
for cred in creds {
|
||||
println!("{}", cred);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_directory(dir: &Path, args: &Args) -> anyhow::Result<()> {
|
||||
let discovery = vmkatz::discover::discover_vm_files(dir)
|
||||
.context("VM file discovery failed")?;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Domain Cached Credentials (DCC2 / MsCacheV2) extraction.
|
||||
//!
|
||||
//! Decrypts cached domain logon hashes from the SECURITY registry hive.
|
||||
//! These are stored in `SECURITY\Cache\NL$n` values and encrypted with
|
||||
//! the NL$KM key (an LSA secret). Output is hashcat mode 2100 format.
|
||||
|
||||
use crate::error::{GovmemError, Result};
|
||||
use super::hive::Hive;
|
||||
use super::hashes::{aes128_cbc_decrypt, decode_utf16le};
|
||||
|
||||
/// A single domain cached credential entry.
|
||||
#[derive(Debug)]
|
||||
pub struct CachedCredential {
|
||||
pub username: String,
|
||||
pub domain: String,
|
||||
pub dns_domain: String,
|
||||
pub dcc2_hash: [u8; 16],
|
||||
pub iteration_count: u32,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CachedCredential {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// hashcat mode 2100 format
|
||||
write!(
|
||||
f,
|
||||
" {}/{}:$DCC2${}#{}#{}",
|
||||
if self.dns_domain.is_empty() {
|
||||
&self.domain
|
||||
} else {
|
||||
&self.dns_domain
|
||||
},
|
||||
self.username,
|
||||
self.iteration_count,
|
||||
self.username.to_lowercase(),
|
||||
hex::encode(self.dcc2_hash),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract domain cached credentials from the SECURITY hive.
|
||||
///
|
||||
/// Requires the NL$KM key (64 bytes), which is extracted as an LSA secret.
|
||||
pub fn extract_cached_credentials(
|
||||
security_data: &[u8],
|
||||
nlkm_key: &[u8],
|
||||
) -> Result<Vec<CachedCredential>> {
|
||||
if nlkm_key.len() < 32 {
|
||||
return Err(GovmemError::DecryptionError(
|
||||
"NL$KM key too short (need at least 32 bytes)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let hive = Hive::new(security_data)?;
|
||||
let root = hive.root_key()?;
|
||||
|
||||
// Navigate to Cache key
|
||||
let cache_key = match root.subkey(&hive, "Cache") {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
log::info!("SECURITY\\Cache key not found, no cached credentials");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
|
||||
// Read iteration count
|
||||
let iteration_count = match cache_key.subkey(&hive, "NL$IterationCount") {
|
||||
Ok(iter_key) => {
|
||||
match iter_key.value(&hive, "") {
|
||||
Ok(data) if data.len() >= 4 => {
|
||||
let raw = u32::from_le_bytes(data[0..4].try_into().unwrap());
|
||||
if raw > 10240 {
|
||||
raw & 0xFFFF_FC00 // round down to nearest 1024
|
||||
} else {
|
||||
raw * 1024
|
||||
}
|
||||
}
|
||||
_ => 10240,
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// NL$IterationCount might be a value, not a subkey
|
||||
match cache_key.value(&hive, "NL$IterationCount") {
|
||||
Ok(data) if data.len() >= 4 => {
|
||||
let raw = u32::from_le_bytes(data[0..4].try_into().unwrap());
|
||||
if raw > 10240 {
|
||||
raw & 0xFFFF_FC00
|
||||
} else {
|
||||
raw * 1024
|
||||
}
|
||||
}
|
||||
_ => 10240,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
log::info!("DCC2 iteration count: {}", iteration_count);
|
||||
|
||||
// AES key = NL$KM[16..32]
|
||||
let aes_key = &nlkm_key[16..32];
|
||||
|
||||
let mut credentials = Vec::new();
|
||||
|
||||
// Try NL$1 through NL$50 (max configurable cache size)
|
||||
for i in 1..=50 {
|
||||
let value_name = format!("NL${}", i);
|
||||
|
||||
let data = match cache_key.value(&hive, &value_name) {
|
||||
Ok(d) => d,
|
||||
Err(_) => break, // No more entries
|
||||
};
|
||||
|
||||
// NL_RECORD header is 0x60 (96) bytes minimum
|
||||
if data.len() < 0x60 + 16 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse NL_RECORD header
|
||||
let user_length = u16::from_le_bytes(data[0x00..0x02].try_into().unwrap()) as usize;
|
||||
let domain_name_length = u16::from_le_bytes(data[0x02..0x04].try_into().unwrap()) as usize;
|
||||
let dns_domain_length = u16::from_le_bytes(data[0x3C..0x3E].try_into().unwrap()) as usize;
|
||||
|
||||
// IV at offset 0x40 (16 bytes)
|
||||
let iv = &data[0x40..0x50];
|
||||
|
||||
// Check if entry is empty (IV is all zeros)
|
||||
if iv.iter().all(|&b| b == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flags at 0x30
|
||||
let flags = u32::from_le_bytes(data[0x30..0x34].try_into().unwrap());
|
||||
if flags & 1 != 1 {
|
||||
continue; // not a valid/encrypted entry
|
||||
}
|
||||
|
||||
// Encrypted data starts at 0x60
|
||||
let encrypted = &data[0x60..];
|
||||
if encrypted.len() < 0x48 + user_length {
|
||||
log::warn!("NL${}: encrypted data too short ({} bytes)", i, encrypted.len());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decrypt with AES-128-CBC
|
||||
let plaintext = match aes128_cbc_decrypt(aes_key, iv, encrypted) {
|
||||
Ok(pt) => pt,
|
||||
Err(e) => {
|
||||
log::warn!("NL${}: decryption failed: {}", i, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if plaintext.len() < 0x48 + user_length {
|
||||
log::warn!("NL${}: decrypted data too short", i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract DCC2 hash (first 16 bytes of decrypted data)
|
||||
let mut dcc2_hash = [0u8; 16];
|
||||
dcc2_hash.copy_from_slice(&plaintext[0..16]);
|
||||
|
||||
// Check if hash is all zeros (empty entry)
|
||||
if dcc2_hash.iter().all(|&b| b == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract username at offset 0x48 (UTF-16LE)
|
||||
let username_end = 0x48 + user_length;
|
||||
let username = if username_end <= plaintext.len() {
|
||||
decode_utf16le(&plaintext[0x48..username_end])
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Extract domain name after username (with padding)
|
||||
let domain_offset = 0x48 + pad4(user_length);
|
||||
let domain = if domain_offset + domain_name_length <= plaintext.len() {
|
||||
decode_utf16le(&plaintext[domain_offset..domain_offset + domain_name_length])
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Extract DNS domain name after domain (with padding)
|
||||
let dns_offset = domain_offset + pad4(domain_name_length);
|
||||
let dns_domain = if dns_domain_length > 0 && dns_offset + dns_domain_length <= plaintext.len()
|
||||
{
|
||||
decode_utf16le(&plaintext[dns_offset..dns_offset + dns_domain_length])
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"NL${}: user={} domain={} dns={} hash={}",
|
||||
i,
|
||||
username,
|
||||
domain,
|
||||
dns_domain,
|
||||
hex::encode(dcc2_hash),
|
||||
);
|
||||
|
||||
credentials.push(CachedCredential {
|
||||
username,
|
||||
domain,
|
||||
dns_domain,
|
||||
dcc2_hash,
|
||||
iteration_count,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
/// Pad a length to DWORD alignment (Windows-style: add len & 3 if not aligned).
|
||||
fn pad4(len: usize) -> usize {
|
||||
if (len & 0x3) > 0 {
|
||||
len + (len & 0x3)
|
||||
} else {
|
||||
len
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -285,7 +285,7 @@ fn expand_des_key(src: &[u8; 7]) -> [u8; 8] {
|
||||
}
|
||||
|
||||
/// AES-128-CBC decryption (no padding).
|
||||
fn aes128_cbc_decrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Result<Vec<u8>> {
|
||||
pub(crate) fn aes128_cbc_decrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut buf = data.to_vec();
|
||||
// Pad to 16-byte boundary if needed (with zeros for decryption)
|
||||
let pad_len = (16 - (buf.len() % 16)) % 16;
|
||||
|
||||
+53
-5
@@ -1,5 +1,6 @@
|
||||
pub mod hive;
|
||||
pub mod bootkey;
|
||||
pub mod cache;
|
||||
pub mod hashes;
|
||||
pub mod lsa;
|
||||
mod ntfs_fallback;
|
||||
@@ -24,11 +25,12 @@ pub struct SamEntry {
|
||||
pub lm_hash: [u8; 16],
|
||||
}
|
||||
|
||||
/// Combined extraction result: SAM hashes + LSA secrets.
|
||||
/// Combined extraction result: SAM hashes + LSA secrets + cached credentials.
|
||||
#[derive(Debug)]
|
||||
pub struct DiskSecrets {
|
||||
pub sam_entries: Vec<SamEntry>,
|
||||
pub lsa_secrets: Vec<lsa::LsaSecret>,
|
||||
pub cached_credentials: Vec<cache::CachedCredential>,
|
||||
}
|
||||
|
||||
/// Extract SAM hashes from a disk image (backward-compatible wrapper).
|
||||
@@ -158,9 +160,9 @@ fn process_hive_data_with_bootkey(
|
||||
let sam_entries = hashes::extract_hashes(&sam_data, &boot_key)?;
|
||||
|
||||
// Extract LSA secrets from SECURITY hive (optional)
|
||||
let lsa_secrets = if let Some(sec_data) = &security_data {
|
||||
let (lsa_secrets, cached_credentials) = if let Some(sec_data) = &security_data {
|
||||
log::info!("SECURITY hive: {} bytes", sec_data.len());
|
||||
match lsa::extract_lsa_secrets(sec_data, &boot_key) {
|
||||
let secrets = match lsa::extract_lsa_secrets(sec_data, &boot_key) {
|
||||
Ok(secrets) => {
|
||||
log::info!("Extracted {} LSA secrets", secrets.len());
|
||||
secrets
|
||||
@@ -169,18 +171,64 @@ fn process_hive_data_with_bootkey(
|
||||
log::warn!("LSA secrets extraction failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Extract cached credentials (DCC2) using NL$KM from LSA secrets
|
||||
let cached = extract_dcc2_from_secrets(sec_data, &secrets);
|
||||
|
||||
(secrets, cached)
|
||||
} else {
|
||||
log::info!("SECURITY hive not found, skipping LSA secrets");
|
||||
Vec::new()
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
Ok(DiskSecrets {
|
||||
sam_entries,
|
||||
lsa_secrets,
|
||||
cached_credentials,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract DCC2 cached credentials using NL$KM from LSA secrets.
|
||||
fn extract_dcc2_from_secrets(
|
||||
security_data: &[u8],
|
||||
secrets: &[lsa::LsaSecret],
|
||||
) -> Vec<cache::CachedCredential> {
|
||||
// Find NL$KM key in LSA secrets
|
||||
let nlkm_key = secrets.iter().find_map(|s| {
|
||||
if let lsa::LsaSecretType::CachedDomainKey { key } = &s.parsed {
|
||||
Some(key.as_slice())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let nlkm_key = match nlkm_key {
|
||||
Some(k) if k.len() >= 32 => k,
|
||||
Some(k) => {
|
||||
log::info!("NL$KM key too short ({} bytes), skipping DCC2", k.len());
|
||||
return Vec::new();
|
||||
}
|
||||
None => {
|
||||
log::info!("NL$KM key not found in LSA secrets, skipping DCC2");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
match cache::extract_cached_credentials(security_data, nlkm_key) {
|
||||
Ok(creds) => {
|
||||
if !creds.is_empty() {
|
||||
log::info!("Extracted {} cached credential(s)", creds.len());
|
||||
}
|
||||
creds
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("DCC2 extraction failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse MBR/GPT and find all NTFS partitions, returning their byte offsets.
|
||||
pub(crate) fn find_ntfs_partitions<R: Read + Seek>(reader: &mut R) -> Result<Vec<u64>> {
|
||||
use std::io::SeekFrom;
|
||||
|
||||
Reference in New Issue
Block a user