mirror of
https://github.com/nikaiw/VMkatz
synced 2026-09-25 07:51:06 +00:00
Improve DPAPI and SAM display: account status, key dedup, NTFS 8.3 fix
- Show account status in SAM text output: (DISABLED), (NO PASSWORD), (BLANK PASSWORD) from per-user ACB flags in registry F value - Add modification date to DPAPI master key text output from NTFS mtime - Deduplicate DPAPI keys: show only most recent per user (--all for all) - Filter NTFS DOS 8.3 short names in list_directory() — fixes S-1-5-~1 SID leak from short name aliases - Add cargo test step to CI workflow
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
name: Clippy
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
clippy:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -20,3 +20,6 @@ jobs:
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-features -- -D warnings
|
||||
|
||||
- name: Tests
|
||||
run: cargo test --all-features
|
||||
|
||||
+54
-2
@@ -804,6 +804,7 @@ fn run_sam(input_path: &Path, args: &Args) -> anyhow::Result<()> {
|
||||
|
||||
// Extract DPAPI master key hashes from user profiles (independent of SAM)
|
||||
let dpapi_hashes = vmkatz::sam::extract_dpapi_masterkeys(input_path);
|
||||
let dpapi_hashes = dedup_dpapi_hashes(dpapi_hashes, args.all);
|
||||
if !dpapi_hashes.is_empty() {
|
||||
found_anything = true;
|
||||
match args.format.as_str() {
|
||||
@@ -856,6 +857,7 @@ fn run_ntds(input_path: &Path, args: &Args) -> anyhow::Result<()> {
|
||||
|
||||
// Also extract DPAPI master key hashes from user profiles on the same disk
|
||||
let dpapi_hashes = vmkatz::sam::extract_dpapi_masterkeys(input_path);
|
||||
let dpapi_hashes = dedup_dpapi_hashes(dpapi_hashes, args.all);
|
||||
if !dpapi_hashes.is_empty() {
|
||||
match args.format.as_str() {
|
||||
"csv" => print_dpapi_masterkey_csv(&dpapi_hashes),
|
||||
@@ -1152,23 +1154,47 @@ fn print_ntds_brief(entries: &[vmkatz::ntds::AdHashEntry]) {
|
||||
fn print_sam_text(entries: &[vmkatz::sam::SamEntry], c: &Colors) {
|
||||
println!("\n{}[+] SAM Hashes:{}", c.green, c.reset);
|
||||
for entry in entries {
|
||||
// Build status annotation
|
||||
let status = sam_status_label(entry, c);
|
||||
|
||||
if entry.lm_hash != ZERO_HASH_16 {
|
||||
println!(
|
||||
" RID: {:<5} {}{:<20}{} NT:{} LM:{}",
|
||||
" RID: {:<5} {}{:<20}{} NT:{} LM:{}{}",
|
||||
entry.rid, c.bold, entry.username, c.reset,
|
||||
fmt_hash(&entry.nt_hash, c),
|
||||
fmt_hash(&entry.lm_hash, c),
|
||||
status,
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" RID: {:<5} {}{:<20}{} NT:{}",
|
||||
" RID: {:<5} {}{:<20}{} NT:{}{}",
|
||||
entry.rid, c.bold, entry.username, c.reset,
|
||||
fmt_hash(&entry.nt_hash, c),
|
||||
status,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a human-readable status label for a SAM entry.
|
||||
#[cfg(feature = "sam")]
|
||||
fn sam_status_label(entry: &vmkatz::sam::SamEntry, c: &Colors) -> String {
|
||||
let mut tags = Vec::new();
|
||||
if entry.is_disabled() {
|
||||
tags.push("DISABLED");
|
||||
}
|
||||
if entry.nt_hash == ZERO_HASH_16 {
|
||||
tags.push("NO PASSWORD");
|
||||
} else if hex::encode(entry.nt_hash) == BLANK_NT_HEX {
|
||||
tags.push("BLANK PASSWORD");
|
||||
}
|
||||
if tags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}({}){}", c.dim, tags.join(", "), c.reset)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sam")]
|
||||
fn print_sam_ntlm(entries: &[vmkatz::sam::SamEntry]) {
|
||||
for entry in entries {
|
||||
@@ -1263,6 +1289,27 @@ fn print_lsa_csv(secrets: &[vmkatz::sam::lsa::LsaSecret]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only the most recent DPAPI master key per (username, sid) unless `show_all` is set.
|
||||
#[cfg(feature = "sam")]
|
||||
fn dedup_dpapi_hashes(
|
||||
mut hashes: Vec<vmkatz::sam::dpapi_masterkey::DpapiMasterKeyHash>,
|
||||
show_all: bool,
|
||||
) -> Vec<vmkatz::sam::dpapi_masterkey::DpapiMasterKeyHash> {
|
||||
if show_all || hashes.len() <= 1 {
|
||||
return hashes;
|
||||
}
|
||||
// Sort by (username, sid, modified DESC) so the most recent key comes first
|
||||
hashes.sort_by(|a, b| {
|
||||
a.username
|
||||
.cmp(&b.username)
|
||||
.then(a.sid.cmp(&b.sid))
|
||||
.then(b.modified.cmp(&a.modified))
|
||||
});
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
hashes.retain(|h| seen.insert((h.username.clone(), h.sid.clone())));
|
||||
hashes
|
||||
}
|
||||
|
||||
#[cfg(feature = "sam")]
|
||||
fn print_dpapi_masterkey_csv(hashes: &[vmkatz::sam::dpapi_masterkey::DpapiMasterKeyHash]) {
|
||||
println!("provider,username,domain,secret_type,secret,target");
|
||||
@@ -1345,6 +1392,8 @@ fn print_dpapi_masterkey_text(
|
||||
hashes: &[vmkatz::sam::dpapi_masterkey::DpapiMasterKeyHash],
|
||||
c: &Colors,
|
||||
) {
|
||||
use vmkatz::lsass::types::filetime_to_string;
|
||||
|
||||
println!(
|
||||
"\n{}[+] DPAPI Master Key Files ({} found):{}",
|
||||
c.green,
|
||||
@@ -1354,6 +1403,9 @@ fn print_dpapi_masterkey_text(
|
||||
for h in hashes {
|
||||
println!(" User: {} ({})", h.username, h.sid);
|
||||
println!(" GUID : {}", h.guid);
|
||||
if h.modified != 0 {
|
||||
println!(" Modified: {}", filetime_to_string(h.modified));
|
||||
}
|
||||
let mode_desc = match h.mode {
|
||||
15300 => "3DES/SHA1, local",
|
||||
15310 => "3DES/SHA1, domain",
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct DpapiMasterKeyHash {
|
||||
pub hash: String,
|
||||
/// Hashcat mode (15300 or 15900).
|
||||
pub mode: u32,
|
||||
/// NTFS modification time (Windows FILETIME, 0 if unavailable).
|
||||
pub modified: u64,
|
||||
}
|
||||
|
||||
/// Parse the 128-byte file header.
|
||||
@@ -161,10 +163,12 @@ fn hash_name(alg: u32) -> Option<&'static str> {
|
||||
/// Parse a DPAPI master key file and generate a Hashcat hash string.
|
||||
///
|
||||
/// Returns None if the file is not a valid DPAPI master key file.
|
||||
/// `modified` is the NTFS modification time as a Windows FILETIME (0 if unavailable).
|
||||
pub fn parse_masterkey_file(
|
||||
data: &[u8],
|
||||
username: &str,
|
||||
sid: &str,
|
||||
modified: u64,
|
||||
) -> Option<DpapiMasterKeyHash> {
|
||||
if data.len() < MIN_FILE_SIZE {
|
||||
return None;
|
||||
@@ -238,6 +242,7 @@ pub fn parse_masterkey_file(
|
||||
guid: header.guid,
|
||||
hash: hash_str,
|
||||
mode,
|
||||
modified,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -368,7 +373,14 @@ fn scan_protect_dir<'n, R: Read + Seek>(
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(hash) = parse_masterkey_file(&mk_data, username, sid_name) {
|
||||
// Read NTFS modification time as a proxy for key creation date
|
||||
let modified = mk_file
|
||||
.info()
|
||||
.ok()
|
||||
.map(|info| info.modification_time().nt_timestamp())
|
||||
.unwrap_or(0);
|
||||
|
||||
if let Some(hash) = parse_masterkey_file(&mk_data, username, sid_name, modified) {
|
||||
log::info!(
|
||||
"DPAPI masterkey: user={} SID={} GUID={} mode={}",
|
||||
username, sid_name, hash.guid, hash.mode
|
||||
@@ -466,7 +478,7 @@ mod tests {
|
||||
*b = 0xBB;
|
||||
}
|
||||
|
||||
let result = parse_masterkey_file(&data, "TestUser", "S-1-5-21-111-222-333-1001");
|
||||
let result = parse_masterkey_file(&data, "TestUser", "S-1-5-21-111-222-333-1001", 0);
|
||||
assert!(result.is_some());
|
||||
let hash = result.unwrap();
|
||||
assert_eq!(hash.mode, 15900);
|
||||
@@ -506,7 +518,7 @@ mod tests {
|
||||
*b = 0xDD;
|
||||
}
|
||||
|
||||
let result = parse_masterkey_file(&data, "Admin", "S-1-5-21-999-888-777-500");
|
||||
let result = parse_masterkey_file(&data, "Admin", "S-1-5-21-999-888-777-500", 0);
|
||||
assert!(result.is_some());
|
||||
let hash = result.unwrap();
|
||||
assert_eq!(hash.mode, 15300);
|
||||
|
||||
+13
-4
@@ -59,13 +59,21 @@ pub fn extract_hashes(sam_hive_data: &[u8], bootkey: &[u8; 16]) -> Result<Vec<Sa
|
||||
}
|
||||
};
|
||||
|
||||
match extract_user_hashes(&v_data, &hashed_bootkey, rid) {
|
||||
// Read per-user F value for Account Control Bits (ACB flags at offset 0x38)
|
||||
let acb_flags = user_key.value(&hive, "F").ok()
|
||||
.filter(|f| f.len() >= 0x3C)
|
||||
.map(|f| u32::from_le_bytes([f[0x38], f[0x39], f[0x3A], f[0x3B]]))
|
||||
.unwrap_or(0);
|
||||
|
||||
match extract_user_hashes(&v_data, &hashed_bootkey, rid, acb_flags) {
|
||||
Ok(entry) => {
|
||||
log::info!(
|
||||
"RID {}: user={}, NT={}",
|
||||
"RID {}: user={}, NT={}, flags=0x{:04x}{}",
|
||||
rid,
|
||||
entry.username,
|
||||
hex::encode(entry.nt_hash)
|
||||
hex::encode(entry.nt_hash),
|
||||
entry.acb_flags,
|
||||
if entry.is_disabled() { " [DISABLED]" } else { "" },
|
||||
);
|
||||
entries.push(entry);
|
||||
}
|
||||
@@ -121,7 +129,7 @@ fn decrypt_hashed_bootkey(f: &[u8], bootkey: &[u8; 16]) -> Result<[u8; 16]> {
|
||||
}
|
||||
|
||||
/// Extract username and hashes from a user's V value.
|
||||
fn extract_user_hashes(v: &[u8], hashed_bootkey: &[u8; 16], rid: u32) -> Result<SamEntry> {
|
||||
fn extract_user_hashes(v: &[u8], hashed_bootkey: &[u8; 16], rid: u32, acb_flags: u32) -> Result<SamEntry> {
|
||||
if v.len() < 0xCC {
|
||||
return Err(sam_err("V value too short"));
|
||||
}
|
||||
@@ -168,6 +176,7 @@ fn extract_user_hashes(v: &[u8], hashed_bootkey: &[u8; 16], rid: u32) -> Result<
|
||||
username,
|
||||
nt_hash,
|
||||
lm_hash,
|
||||
acb_flags,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,21 @@ pub struct SamEntry {
|
||||
pub username: String,
|
||||
pub nt_hash: [u8; 16],
|
||||
pub lm_hash: [u8; 16],
|
||||
/// Account Control Bits from per-user F value (offset 0x38).
|
||||
pub acb_flags: u32,
|
||||
}
|
||||
|
||||
// Account Control Bit flags (from SAM per-user F value).
|
||||
impl SamEntry {
|
||||
/// Account is disabled (ACB_DISABLED).
|
||||
pub fn is_disabled(&self) -> bool {
|
||||
self.acb_flags & 0x0001 != 0
|
||||
}
|
||||
|
||||
/// Password not required (ACB_PWNOTREQ).
|
||||
pub fn password_not_required(&self) -> bool {
|
||||
self.acb_flags & 0x0004 != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined extraction result: SAM hashes + LSA secrets + cached credentials.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::io::{Read, Seek};
|
||||
|
||||
use ntfs::structured_values::NtfsFileNamespace;
|
||||
use ntfs::NtfsReadSeek;
|
||||
|
||||
use crate::error::Result;
|
||||
@@ -161,8 +162,12 @@ pub(crate) fn list_directory<'n, R: Read + Seek>(
|
||||
Some(Ok(k)) => k,
|
||||
_ => continue,
|
||||
};
|
||||
// Skip DOS 8.3 short names — always prefer the Win32 long name
|
||||
if key.namespace() == NtfsFileNamespace::Dos {
|
||||
continue;
|
||||
}
|
||||
let name = key.name().to_string_lossy().to_string();
|
||||
// Skip NTFS special entries and dedup (NTFS may list WIN8.3 short + long names)
|
||||
// Skip NTFS special entries and dedup
|
||||
if name == "." || name == ".." || name.starts_with('$') {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user