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:
NK
2026-03-21 00:52:56 +01:00
parent 131a3693b2
commit 56632a865c
6 changed files with 108 additions and 12 deletions
+5 -2
View File
@@ -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
View File
@@ -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",
+15 -3
View File
@@ -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
View File
@@ -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,
})
}
+15
View File
@@ -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.
+6 -1
View File
@@ -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;
}