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:
NK
2026-02-10 11:18:55 +01:00
parent e80c80afb3
commit af70499b7c
4 changed files with 285 additions and 6 deletions
+12
View File
@@ -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")?;
+219
View File
@@ -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
View File
@@ -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
View File
@@ -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;