Notice: Due to size constraints and loading performance considerations, scripts referenced in blog posts are not attached directly. To request access, please complete the following form: Script Request Form Note: A Google account is required to access the form.
Disclaimer: I do not accept responsibility for any issues arising from scripts being run without adequate understanding. It is the user's responsibility to review and assess any code before execution. More information

Eye in the Sky : Tracking Hashes and Password Similarity’s


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

PowerShell with Active Directory module
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)
The results will be exported in a CSV file, to which when you do some analysis on that data you will think the CSV file is wrong - this is where the next script comes in so you can confirm the hash between audits which is in the next step.

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:

  1. Export enabled users from Active Directory
  2. Run multi-audit matcher to see cracked passwords across time periods
  3. Analyze password changes to understand user behavior patterns
  4. Use hash lookup for investigating specific users
  5. 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:

  1. Users with identical passwords: Require immediate password changes, this logic should be applied to uncracked hashes that remain the same
  2. Users with minor changes (1-3 characters): May need education about strong password creation
  3. Users with persistent hashes: Priority targets for enhanced cracking attempts or mandatory password resets
  4. 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.

Previous Post Next Post

نموذج الاتصال