Once you've used Hashcat to audit passwords and have cracked passwords from your organization, the next critical step is analyzing how password security evolves over time. The purpose of this comprehensive analysis is to:
- See differences between the first audit and future audits
- Understand password change patterns with similarity data
- Measure how many characters have changed between passwords from the first audit to subsequent audits
- Analyze passwords that were not reverse engineered to identify if the same hash values persist across audits for enabled accounts
- Multiple NTLM hash dumps from different time periods (
.ntds
files) - Corresponding cracked password files from Hashcat (hash:password format)
- Active Directory access for exporting enabled users
Step 1: Export Enabled Users from Active Directory
This guide provides a complete toolkit of PowerShell scripts to perform longitudinal password audit analysis, helping you identify users who improve their security versus those who remain vulnerable.
Prerequisites
Before starting, you'll need:
The first step is to get a current list of enabled users from Active Directory. This is much more efficient than querying each account individually during the analysis.
Command to export enabled users:
Get-ADUser -Filter {Enabled -eq $true} -Properties DistinguishedName | Select-Object SamAccountName, @{Name='OU';Expression={(($_.DistinguishedName -split ',OU=')[1..999] -join ',OU=')}} | Export-Csv "enabled_users.csv" -NoTypeInformation
This creates a CSV file with just the SamAccountName field for all enabled accounts, which I'll use to filter results to only include currently active users.
Example output format:
SamAccountName
bob.bear
admin.bearmanager
serviceacct1.1
Step 2: Multi-Audit Password Matcher
This script compares cracked passwords across multiple audit periods, showing which users had passwords cracked in each audit.
Script: hash-to-user.ps1
param(
[Parameter(Mandatory=$true)]
[string[]]$PasswordFile, # Multiple password files
[Parameter(Mandatory=$true)]
[string[]]$SecretsDump, # Multiple secrets dump files (must match PasswordFile order)
[Parameter(Mandatory=$false)]
[string]$OutputFile = "password_audit_results.csv"
)
# PROTECT THE ARRAYS BY COPYING THEM IMMEDIATELY
$PasswordFiles = @() + $PasswordFile # Force copy to prevent corruption
$SecretsDumpFiles = @() + $SecretsDump # Force copy to prevent corruption
$TotalFiles = $PasswordFiles.Count
# Check if we have matching numbers of password files and secrets dumps
if ($PasswordFiles.Count -ne $SecretsDumpFiles.Count) {
Write-Error "Number of PasswordFile entries ($($PasswordFiles.Count)) must match number of SecretsDump entries ($($SecretsDumpFiles.Count))"
exit 1
}
# Check if all files exist
for ($i = 0; $i -lt $TotalFiles; $i++) {
if (-not (Test-Path $PasswordFiles[$i])) {
Write-Error "Password file not found: $($PasswordFiles[$i])"
exit 1
}
if (-not (Test-Path $SecretsDumpFiles[$i])) {
Write-Error "Secrets dump file not found: $($SecretsDumpFiles[$i])"
exit 1
}
}
# Initialize data structures
$auditPasswordData = @{} # auditfile -> hash -> password
$auditUserData = @{} # auditfile -> username -> hash
Write-Host "Starting to process $TotalFiles audit pairs..."
# Process each password file and corresponding secrets dump
for ($i = 0; $i -lt $TotalFiles; $i++) {
Write-Host "=== Processing audit pair $($i + 1) of $TotalFiles ==="
$passwordFile = $PasswordFiles[$i]
$secretsFile = $SecretsDumpFiles[$i]
Write-Host "Files to process:"
Write-Host " Password: $passwordFile (exists: $(Test-Path $passwordFile))"
Write-Host " Secrets: $secretsFile (exists: $(Test-Path $secretsFile))"
$fileName = [System.IO.Path]::GetFileNameWithoutExtension($passwordFile)
Write-Host "Processing audit: $fileName"
$auditPasswordData[$fileName] = @{}
$auditUserData[$fileName] = @{}
try {
# Load password data
$passwordCount = 0
Get-Content $passwordFile | ForEach-Object {
if ($_ -match "^(.+):(.+)$") {
$hash = $matches[1].Trim()
$password = $matches[2].Trim()
$auditPasswordData[$fileName][$hash] = $password
$passwordCount++
}
}
Write-Host " - Loaded $passwordCount cracked passwords"
# Load user data from secrets dump
$userCount = 0
Get-Content $secretsFile | ForEach-Object {
$line = $_.Trim()
if ($line -and $line.Contains(":")) {
$fields = $line.Split(":")
if ($fields.Count -ge 4) {
$username = $fields[0].Trim()
$hash = $fields[3].Trim() # 4th field (0-indexed = 3)
$auditUserData[$fileName][$username] = $hash
$userCount++
}
}
}
Write-Host " - Loaded $userCount user records"
Write-Host "=== Completed audit pair $($i + 1) ===`n"
} catch {
Write-Error "Error processing audit pair $($i + 1): $_"
Write-Host "=== Failed audit pair $($i + 1) ===`n"
}
}
Write-Host "All audit processing completed!"
# Get audit file names for CSV headers (in order provided)
$auditColumns = @()
for ($i = 0; $i -lt $TotalFiles; $i++) {
$fileName = [System.IO.Path]::GetFileNameWithoutExtension($PasswordFiles[$i])
$auditColumns += $fileName
}
# Get all unique usernames across all audits
$allUsers = @{}
foreach ($auditFile in $auditUserData.Keys) {
foreach ($username in $auditUserData[$auditFile].Keys) {
$allUsers[$username] = $true
}
}
Write-Host "Found $($allUsers.Count) unique users across all audits"
Write-Host "Audit periods processed: $($auditColumns -join ', ')"
# Debug: Show some stats
foreach ($auditCol in $auditColumns) {
$crackedHashes = $auditPasswordData[$auditCol].Count
$totalUsers = $auditUserData[$auditCol].Count
Write-Host "Audit $auditCol : $crackedHashes cracked hashes, $totalUsers total users"
}
# Process each user across all audits
$results = @()
foreach ($username in $allUsers.Keys) {
# Create result object
$result = [PSCustomObject]@{
User = $username
}
# Check each audit period for this user
foreach ($auditCol in $auditColumns) {
if ($auditUserData[$auditCol].ContainsKey($username)) {
# User exists in this audit
$userHash = $auditUserData[$auditCol][$username]
# Check if I cracked this hash
if ($auditPasswordData[$auditCol].ContainsKey($userHash)) {
$password = $auditPasswordData[$auditCol][$userHash]
$result | Add-Member -NotePropertyName $auditCol -NotePropertyValue $password
Write-Host "Match: $username -> $password in $auditCol"
} else {
# User exists but password not cracked (good!)
$result | Add-Member -NotePropertyName $auditCol -NotePropertyValue ""
}
} else {
# User doesn't exist in this audit
$result | Add-Member -NotePropertyName $auditCol -NotePropertyValue ""
}
}
# Only add to results if user had at least one cracked password
$hasCrackedPassword = $false
foreach ($auditCol in $auditColumns) {
if (-not [string]::IsNullOrEmpty($result.$auditCol)) {
$hasCrackedPassword = $true
break
}
}
if ($hasCrackedPassword) {
$results += $result
}
}
# Output results
if ($results.Count -gt 0) {
Write-Host "`nFound $($results.Count) users with cracked passwords"
# Export to CSV
$results | Export-Csv -Path $OutputFile -NoTypeInformation -Encoding UTF8
Write-Host "Results exported to: $OutputFile"
# Display summary
Write-Host "`nSample results:"
$results | Select-Object -First 5 | Format-Table -AutoSize
if ($results.Count -gt 5) {
Write-Host "... and $($results.Count - 5) more entries in the CSV file"
}
} else {
Write-Host "`nNo users found with cracked passwords across the audit periods."
}
Usage:
.\hash-to-user.ps1 -PasswordFile "march2024.txt","september2024.txt" -SecretsDump "ntlm-extract-march2024.ntds","ntlm-extract-september2024.ntds" -OutputFile "password-history-audit.csv"
Example output CSV:
User,march2024,september2024
bear.local\jsmith1,Password123,
bear.local\mjones2,Winter2024,Winter2024
bear.local\plee3,,NewPassword456
This shows:
- jsmith1 had a cracked password in March but not September (improved security)
- mjones2 had the same weak password in both audits
- plee3had a new weak password in September that wasn't present in March
Step 3: Password Change Analysis
This script analyzes how passwords change between audit periods, comparing each password to the previous one chronologically and showing the number of characters that changed.
Script: password-analyzer.ps1
param(
[Parameter(Mandatory=$true)]
[string]$InputCSV,
[Parameter(Mandatory=$true)]
[string]$OutputCSV,
[Parameter(Mandatory=$true)]
[string]$EnabledUsersFile
)
# Check if input files exist
if (-not (Test-Path $InputCSV)) {
Write-Error "Input CSV file not found: $InputCSV"
exit 1
}
if (-not (Test-Path $EnabledUsersFile)) {
Write-Error "Enabled users file not found: $EnabledUsersFile"
exit 1
}
# Load enabled users list
try {
if ($EnabledUsersFile.EndsWith('.csv')) {
# CSV format with header
$enabledUserData = Import-Csv -Path $EnabledUsersFile
$enabledUsers = @{}
foreach ($user in $enabledUserData) {
$enabledUsers[$user.SamAccountName] = $true
}
Write-Host "Loaded $($enabledUsers.Count) enabled users from CSV file"
} else {
# Text file format (one username per line)
$enabledUsersList = Get-Content -Path $EnabledUsersFile
$enabledUsers = @{}
foreach ($user in $enabledUsersList) {
if (-not [string]::IsNullOrWhiteSpace($user)) {
$enabledUsers[$user.Trim()] = $true
}
}
Write-Host "Loaded $($enabledUsers.Count) enabled users from text file"
}
} catch {
Write-Error "Failed to read enabled users file: $_"
exit 1
}
# Function to extract SamAccountName from domain\username format
function Get-SamAccountName {
param([string]$FullUsername)
if ($FullUsername.Contains('\')) {
return $FullUsername.Split('\')[-1]
} else {
return $FullUsername
}
}
# Function to calculate character differences between two strings
function Get-PasswordDifference {
param(
[string]$Password1,
[string]$Password2
)
# If either password is empty, can't compare
if ([string]::IsNullOrEmpty($Password1) -or [string]::IsNullOrEmpty($Password2)) {
return "Cannot Compare"
}
# If passwords are identical
if ($Password1 -eq $Password2) {
return "Password Identical"
}
# Calculate character differences using Levenshtein distance approach
$len1 = $Password1.Length
$len2 = $Password2.Length
$maxLen = [Math]::Max($len1, $len2)
# Simple character-by-character comparison for changed characters
$changedChars = 0
$minLen = [Math]::Min($len1, $len2)
# Compare character by character up to the shorter length
for ($i = 0; $i -lt $minLen; $i++) {
if ($Password1[$i] -ne $Password2[$i]) {
$changedChars++
}
}
# Add difference in length as changed characters
$changedChars += [Math]::Abs($len1 - $len2)
# Determine the change level
if ($changedChars -eq 0) {
return "Password Identical"
} elseif ($changedChars -eq $maxLen) {
return "All Characters Changed"
} else {
return "$changedChars Characters Changed"
}
}
# Read the input CSV
try {
$inputData = Import-Csv -Path $InputCSV
Write-Host "Loaded $($inputData.Count) records from $InputCSV"
} catch {
Write-Error "Failed to read CSV file: $_"
exit 1
}
# Get the column names (excluding User column) - these are the audit periods
$auditColumns = $inputData[0].PSObject.Properties.Name | Where-Object { $_ -ne "User" }
Write-Host "Found audit columns: $($auditColumns -join ', ')"
# Find the most recent audit column (assume last column is most recent)
$mostRecentAudit = $auditColumns[-1] # Last column
Write-Host "Most recent audit period: $mostRecentAudit"
# Filter to only include users who:
# 1. Appear in the most recent audit (have a cracked password)
# 2. Are enabled in Active Directory
$enabledUsersWithPasswords = @()
$totalUsersInAudit = 0
$disabledUsersFiltered = 0
foreach ($record in $inputData) {
# Check if user has a password in the most recent audit
if (-not [string]::IsNullOrEmpty($record.$mostRecentAudit)) {
$totalUsersInAudit++
# Extract SamAccountName from the full username
$samAccountName = Get-SamAccountName -FullUsername $record.User
# Check if this user is enabled in AD
if ($enabledUsers.ContainsKey($samAccountName)) {
$enabledUsersWithPasswords += $record
} else {
$disabledUsersFiltered++
Write-Host "Filtered out disabled user: $($record.User) (SamAccountName: $samAccountName)"
}
}
}
Write-Host "Found $totalUsersInAudit users with cracked passwords in $mostRecentAudit"
Write-Host "Filtered out $disabledUsersFiltered disabled accounts"
Write-Host "Processing $($enabledUsersWithPasswords.Count) enabled accounts with cracked passwords"
# Process each enabled user record
$results = @()
foreach ($record in $enabledUsersWithPasswords) {
$user = $record.User
# Create result object starting with user
$result = [PSCustomObject]@{
User = $user
}
# Process each audit column - compare to previous password chronologically
$previousPassword = $null
$previousColumn = $null
foreach ($col in $auditColumns) {
$currentPassword = $record.$col
if ([string]::IsNullOrEmpty($currentPassword)) {
# No password in this audit
$result | Add-Member -NotePropertyName $col -NotePropertyValue ""
} else {
if ($previousPassword -eq $null) {
# This is the first password found (base password)
$result | Add-Member -NotePropertyName $col -NotePropertyValue "Base Password"
$previousPassword = $currentPassword
$previousColumn = $col
} else {
# Compare to the previous password (not base password)
if ($currentPassword -eq $previousPassword) {
$result | Add-Member -NotePropertyName $col -NotePropertyValue "Password Identical"
} else {
# Calculate difference from previous password
$difference = Get-PasswordDifference -Password1 $previousPassword -Password2 $currentPassword
$result | Add-Member -NotePropertyName $col -NotePropertyValue $difference
}
# Update previous password for next iteration
$previousPassword = $currentPassword
$previousColumn = $col
}
}
}
$results += $result
Write-Host "Processed enabled user: $user"
}
# Export results with proper CSV formatting (no quotes around headers)
try {
# Create CSV content manually to control header formatting
$csvContent = @()
# Create header row without quotes
$headers = @("User") + $auditColumns
$csvContent += $headers -join ","
# Add data rows
foreach ($result in $results) {
$row = @()
$row += "`"$($result.User)`"" # Quote the username in case it contains special characters
foreach ($col in $auditColumns) {
$value = $result.$col
if ([string]::IsNullOrEmpty($value)) {
$row += '""' # Empty quoted field
} else {
$row += "`"$value`"" # Quote the value
}
}
$csvContent += $row -join ","
}
# Write to file
$csvContent | Out-File -FilePath $OutputCSV -Encoding UTF8
Write-Host "`nAnalysis complete! Results saved to: $OutputCSV"
Write-Host "Processed $($results.Count) enabled accounts from $mostRecentAudit audit"
# Display sample results
Write-Host "`nSample results:"
$results | Select-Object -First 5 | Format-Table -AutoSize
if ($results.Count -gt 5) {
Write-Host "... and $($results.Count - 5) more entries in the CSV file"
}
} catch {
Write-Error "Failed to write output CSV: $_"
exit 1
}
Usage:
.\password-analyzer.ps1 -InputCSV "password-history-audit.csv" -OutputCSV "password-changes.csv" -EnabledUsersFile "enabled_users.csv"
Example output:
User,march2024,september2024
bear.local\jsmith1,Base Password,All Characters Changed
bear.local\mjones2,Base Password,Password Identical
bear.local\pthomas3,Base Password,2 Characters Changed
This shows:
- jsmith1 completely changed their password (good security practice)
- mjones2 kept the exact same password (security concern)
- pthomas3 made minor changes (2 characters different)
Step 4: User Hash Lookup
This utility script allows you to quickly find a specific user's hash across all NTDS files.
Script: user-hash-lookup.ps1
param(
[Parameter(Mandatory=$false)]
[string]$Username,
[Parameter(Mandatory=$false)]
[string]$SearchPath = "."
)
# Function to search for username in NTDS file
function Find-UserInNTDS {
param(
[string]$FilePath,
[string]$SearchUser
)
Write-Host "Searching in: $FilePath"
$found = $false
Get-Content $FilePath | ForEach-Object {
$line = $_.Trim()
if ($line -and $line.Contains(":")) {
$fields = $line.Split(":")
if ($fields.Count -ge 4) {
$username = $fields[0].Trim()
$rid = $fields[1].Trim()
$lmHash = $fields[2].Trim()
$ntlmHash = $fields[3].Trim()
# Check for exact match (case insensitive)
if ($username -eq $SearchUser) {
Write-Host "✓ FOUND: $username" -ForegroundColor Green
Write-Host " File: $FilePath"
Write-Host " RID: $rid"
Write-Host " LM Hash: $lmHash"
Write-Host " NTLM Hash: $ntlmHash"
Write-Host ""
$found = $true
}
}
}
}
if (-not $found) {
Write-Host " - User not found in this file" -ForegroundColor Yellow
}
return $found
}
# Get username if not provided
if ([string]::IsNullOrEmpty($Username)) {
$Username = Read-Host "Enter username to search for (e.g. domain\username or just username)"
}
Write-Host "=== User Hash Lookup ===" -ForegroundColor Cyan
Write-Host "Searching for user: $Username"
Write-Host "Search path: $SearchPath"
Write-Host ""
# Find all .ntds files in the current directory
$ntdsFiles = Get-ChildItem -Path $SearchPath -Filter "*.ntds" -File
if ($ntdsFiles.Count -eq 0) {
Write-Host "No .ntds files found in $SearchPath" -ForegroundColor Red
Write-Host "Available files:"
Get-ChildItem -Path $SearchPath -File | Select-Object Name | Format-Table -AutoSize
exit 1
}
Write-Host "Found $($ntdsFiles.Count) NTDS files:" -ForegroundColor Green
foreach ($file in $ntdsFiles) {
Write-Host " - $($file.Name)"
}
Write-Host ""
# Search for the user in each file
$userFound = $false
foreach ($file in $ntdsFiles) {
$found = Find-UserInNTDS -FilePath $file.FullName -SearchUser $Username
if ($found) { $userFound = $true }
}
# Only try the username part if I didn't find the full username
if (-not $userFound -and $Username.Contains('\')) {
$justUsername = $Username.Split('\')[-1]
Write-Host ""
Write-Host "User not found with full name. Trying just the username part: $justUsername" -ForegroundColor Yellow
Write-Host ""
foreach ($file in $ntdsFiles) {
Find-UserInNTDS -FilePath $file.FullName -SearchUser $justUsername
}
}
Write-Host "=== Search Complete ===" -ForegroundColor Cyan
Usage:
# Interactive mode
.\user-hash-lookup.ps1
# Direct lookup
.\user-hash-lookup.ps1 -Username "bear.local\jsmith1"
Example output or a password change:
=== User Hash Lookup ===
Searching for user: bear.local\jsmith1
Found 2 NTDS files:
- ntlm-extract-march2024.ntds
- ntlm-extract-september2024.ntds
Searching in: ntlm-extract-march2024.ntds
✓ FOUND: bear.local\jsmith1
File: ntlm-extract-march2024.ntds
RID: 12345
LM Hash: aad3b435b51404eeaad3b435b51404ee
NTLM Hash: 5f4dcc3b5aa765d61d8327deb882cf99
Searching in: ntlm-extract-september2024.ntds
✓ FOUND: bear.local\jsmith1
File: ntlm-extract-september2024.ntds
RID: 12345
LM Hash: aad3b435b51404eeaad3b435b51404ee
NTLM Hash: 098f6bcd4621d373cade4e832627b4f6
This shows the user changed their password between audits (different NTLM hashes).
Example of user who kept the same password:
=== User Hash Lookup ===
Searching for user: bear.local\mjones2
Found 2 NTDS files:
- ntlm-extract-march2024.ntds
- ntlm-extract-september2024.ntds
Searching in: ntlm-extract-march2024.ntds
✓ FOUND: bear.local\mjones2
File: ntlm-extract-march2024.ntds
RID: 67890
LM Hash: aad3b435b51404eeaad3b435b51404ee
NTLM Hash: e99a18c428cb38d5f260853678922e03
Searching in: ntlm-extract-september2024.ntds
✓ FOUND: bear.local\mjones2
File: ntlm-extract-september2024.ntds
RID: 67890
LM Hash: aad3b435b51404eeaad3b435b51404ee
NTLM Hash: e99a18c428cb38d5f260853678922e03
This shows the user kept exactly the same password (identical NTLM hashes: e99a18c428cb38d5f260853678922e03
). This user would be a candidate for the same hash analysis in Step 5, especially if their password wasn't cracked.
Step 5: Same Hash Analysis
Note : The script will compare all enabled accounts, regardless of whether that password has been cracked or not
This script identifies enabled users who have the same hash across multiple audit periods, indicating they haven't changed their passwords. These are particularly interesting because they represent persistent, uncracked passwords that might be weak but weren't in the wordlists.
Script: same-hash-finder.ps1
param(
[Parameter(Mandatory=$true)]
[string[]]$NTDSFiles, # Multiple NTDS files to compare
[Parameter(Mandatory=$true)]
[string]$EnabledUsersFile, # CSV or TXT file with enabled users
[Parameter(Mandatory=$false)]
[string]$OutputFile = "same_hash_analysis.csv"
)
# Check if NTDS files exist
foreach ($file in $NTDSFiles) {
if (-not (Test-Path $file)) {
Write-Error "NTDS file not found: $file"
exit 1
}
}
# Check if enabled users file exists
if (-not (Test-Path $EnabledUsersFile)) {
Write-Error "Enabled users file not found: $EnabledUsersFile"
exit 1
}
# Load enabled users list
try {
if ($EnabledUsersFile.EndsWith('.csv')) {
$enabledUserData = Import-Csv -Path $EnabledUsersFile
$enabledUsers = @{}
$userOUs = @{} # Store OU information
foreach ($user in $enabledUserData) {
$enabledUsers[$user.SamAccountName] = $true
# Store OU if it exists in the CSV
if ($user.PSObject.Properties.Name -contains 'OU') {
$userOUs[$user.SamAccountName] = $user.OU
} else {
$userOUs[$user.SamAccountName] = "OU not available"
}
}
Write-Host "Loaded $($enabledUsers.Count) enabled users from CSV file"
} else {
$enabledUsersList = Get-Content -Path $EnabledUsersFile
$enabledUsers = @{}
$userOUs = @{}
foreach ($user in $enabledUsersList) {
if (-not [string]::IsNullOrWhiteSpace($user)) {
$cleanUser = $user.Trim()
$enabledUsers[$cleanUser] = $true
$userOUs[$cleanUser] = "OU not available" # Text file doesn't contain OU info
}
}
Write-Host "Loaded $($enabledUsers.Count) enabled users from text file"
}
} catch {
Write-Error "Failed to read enabled users file: $_"
exit 1
}
# Function to extract SamAccountName from domain\username format
function Get-SamAccountName {
param([string]$FullUsername)
if ($FullUsername.Contains('\')) {
return $FullUsername.Split('\')[-1]
} else {
return $FullUsername
}
}
# Load user data from each NTDS file
$auditData = @{} # filename -> username -> hash
$fileNames = @()
foreach ($file in $NTDSFiles) {
$fileName = [System.IO.Path]::GetFileNameWithoutExtension($file)
$fileNames += $fileName
$auditData[$fileName] = @{}
Write-Host "Loading users from: $fileName"
$userCount = 0
Get-Content $file | ForEach-Object {
$line = $_.Trim()
if ($line -and $line.Contains(":")) {
$fields = $line.Split(":")
if ($fields.Count -ge 4) {
$username = $fields[0].Trim()
$hash = $fields[3].Trim() # NTLM hash
$auditData[$fileName][$username] = $hash
$userCount++
}
}
}
Write-Host " Loaded $userCount users"
}
Write-Host "`nAnalyzing for users with same hashes across all files..."
# Find users who appear in ALL audit files
$usersInAllAudits = @{}
$firstAudit = $fileNames[0]
# Start with users from first audit
foreach ($username in $auditData[$firstAudit].Keys) {
$appearsInAll = $true
# Check if user appears in all other audits
for ($i = 1; $i -lt $fileNames.Count; $i++) {
$auditName = $fileNames[$i]
if (-not $auditData[$auditName].ContainsKey($username)) {
$appearsInAll = $false
break
}
}
if ($appearsInAll) {
$usersInAllAudits[$username] = $true
}
}
Write-Host "Found $($usersInAllAudits.Count) users who appear in all $($fileNames.Count) audit files"
# Find users with identical hashes across all audits
$sameHashUsers = @()
$enabledSameHashUsers = @()
foreach ($username in $usersInAllAudits.Keys) {
# Get hash from first audit
$baseHash = $auditData[$firstAudit][$username]
$hashesMatch = $true
# Compare with hashes from all other audits
for ($i = 1; $i -lt $fileNames.Count; $i++) {
$auditName = $fileNames[$i]
$currentHash = $auditData[$auditName][$username]
if ($currentHash -ne $baseHash) {
$hashesMatch = $false
break
}
}
if ($hashesMatch) {
# Check if user is enabled
$samAccountName = Get-SamAccountName -FullUsername $username
if ($enabledUsers.ContainsKey($samAccountName)) {
# Get OU information
$userOU = $userOUs[$samAccountName]
# Create result object
$result = [PSCustomObject]@{
User = $username
OU = $userOU
Hash = $baseHash
}
# Add audit file columns showing "Same Hash" for each
foreach ($auditName in $fileNames) {
$result | Add-Member -NotePropertyName $auditName -NotePropertyValue "Same Hash"
}
$enabledSameHashUsers += $result
Write-Host "Enabled user with same hash: $username (OU: $userOU, Hash: $baseHash)"
} else {
# Create result object for disabled users (for total count)
$result = [PSCustomObject]@{
User = $username
OU = "Disabled Account"
Hash = $baseHash
}
# Add audit file columns showing "Same Hash" for each
foreach ($auditName in $fileNames) {
$result | Add-Member -NotePropertyName $auditName -NotePropertyValue "Same Hash"
}
$sameHashUsers += $result
Write-Host "Disabled user with same hash: $username (Hash: $baseHash)" -ForegroundColor DarkGray
}
}
}
Write-Host "`nSummary:"
Write-Host "Users with same hash across all audits: $($sameHashUsers.Count + $enabledSameHashUsers.Count)"
Write-Host "Enabled users with same hash: $($enabledSameHashUsers.Count)"
Write-Host "Disabled users with same hash: $($sameHashUsers.Count)"
# Export results
if ($enabledSameHashUsers.Count -gt 0) {
# Create CSV content manually to control formatting
$csvContent = @()
# Create header
$headers = @("User", "OU", "Hash") + $fileNames
$csvContent += $headers -join ","
# Add data rows for enabled users only
foreach ($result in $enabledSameHashUsers) {
$row = @()
$row += "`"$($result.User)`""
$row += "`"$($result.OU)`""
$row += "`"$($result.Hash)`""
foreach ($auditName in $fileNames) {
$row += "`"Same Hash`""
}
$csvContent += $row -join ","
}
# Write to file
$csvContent | Out-File -FilePath $OutputFile -Encoding UTF8
Write-Host "`nResults exported to: $OutputFile"
Write-Host "File contains only enabled users with consistent hashes"
# Display sample results
Write-Host "`nSample enabled users with same hashes:"
$enabledSameHashUsers | Select-Object -First 5 | Format-Table -AutoSize
if ($enabledSameHashUsers.Count -gt 5) {
Write-Host "... and $($enabledSameHashUsers.Count - 5) more entries in the CSV file"
}
} else {
Write-Host "`nNo enabled users found with same hashes across all audit files."
Write-Host "This could mean:"
Write-Host " - Users are changing their passwords between audits (good!)"
Write-Host " - Users with same hashes have been disabled"
Write-Host " - The audit files don't have overlapping users"
}
Usage:
.\same-hash-finder.ps1 -NTDSFiles "ntlm-extract-march2024.ntds","ntlm-extract-september2024.ntds" -EnabledUsersFile "enabled_users.csv"
Example output:
User,Hash,march2024,september2024
bear.local\serviceacct1,e19ccf75ee54e06b06a5907af13cef42,Same Hash,Same Hash
bear.local\olduser2,32ed87bdb5fdc5e9cba88547376818d4,Same Hash,Same Hash
bear.local\legacy1,5835048ce94ad0564e29a924a03510ef,Same Hash,Same Hash
This identifies users who:
- Are currently enabled in Active Directory
- Have the same password hash across multiple audit periods
- Represent potential weak passwords that haven't been cracked yet
- May be good targets for focused password attacks or policy enforcement
Workflow Summary
The complete analysis workflow is:
- Export enabled users from Active Directory
- Run multi-audit matcher to see cracked passwords across time periods
- Analyze password changes to understand user behavior patterns
- Use hash lookup for investigating specific users
- Find persistent hashes to identify uncracked but potentially weak passwords
Interpreting Results
Password Change Analysis Results
- "Base Password": The first password found for this user
- "Password Identical": User kept the exact same password (security concern)
- "X Characters Changed": User made minor modifications to their password
- "All Characters Changed": User completely changed their password (good security)
- Empty cell: No cracked password found in that audit period
Same Hash Analysis Results
Users appearing in this report represent high-value targets because:
- They haven't changed passwords between audits
- Their passwords weren't cracked with current wordlists/rules
- They may be using patterns not covered in the attack
- They're currently enabled and accessible
Security Recommendations
Based on the analysis results, I can make targeted recommendations:
- Users with identical passwords: Require immediate password changes, this logic should be applied to uncracked hashes that remain the same
- Users with minor changes (1-3 characters): May need education about strong password creation
- Users with persistent hashes: Priority targets for enhanced cracking attempts or mandatory password resets
- Users who disappeared from results: Likely improved their security (positive trend)
Conclusion
This toolkit transforms raw password audit data into actionable security intelligence. By tracking password evolution over time and identifying persistent weak patterns, security teams can move beyond one-time assessments to continuous improvement of organizational password hygiene.
The combination of cracked password analysis and persistent hash identification provides a complete picture of both known weaknesses and potential vulnerabilities, enabling targeted remediation efforts that maximize security impact.