Account lockouts are a persistent challenge in Active Directory environments. Traditional manual investigation involves checking multiple domain controllers, searching through netlogon files, and correlating authentication failures across the infrastructure. This process is time-consuming, error-prone, and often misses critical details buried in gigabytes of log data.
The lockout will depend on your organisation password policy, if for example you have a lockout after 4 attempts this is now it flows:
Bad Password, Bad Password, Bad Password, Lockout
This means if you look at that from errors in netlogon.log that looks like this:
0xC000006A > 0xC000006A > 0xC000006A > 0xC0000234
This means if you are looking for the lockout event of 4740, you are skipping the bad passwords and going straight for the the 4th bad attempt which means a lockout, for many scenarios this "Caller Computer" will record the correct computer, but sometimes it will not have the caller computer or simply be blank, this is where this investigation comes in.
I developed a PowerShell solution that automates this entire process, providing comprehensive lockout analysis in minutes rather than hours. The script systematically searches all domain controllers for authentication failures, focusing specifically on the error codes that matter: bad passwords (0xC000006A) and actual lockouts (0xC0000234).
Website Visuals
The information comes later but the is the website you can utilise if you cannot read JSON files, this is is the website that requires the JSON file from the lockout script (which is below)
Once you provide the JSON file it will confirm this as below then use the analyse button:
This will then show you the results as below where you can see "lee.user" has entered a bad password on WrkDevice25665:
How the Script Works
The core challenge in lockout analysis is that authentication failures are scattered across multiple domain controllers in different log files. Manual investigation requires:
- Identifying all domain controllers
- Accessing netlogon.log and netlogon.bak on each DC
- Searching through hundreds of thousands of log entries
- Correlating timestamps and error codes
- Distinguishing between different failure types
The script automates each step:
Basic Usage
.\LockoutAnalyzer.ps1 -Username lee.croucher -InputTupe sam
The script accepts either SAM account names or User Principal Names:
.\LockoutAnalyzer.ps1 -Username lee.croucher@bear.local -InputType upn
The Search Process
The script begins by querying Active Directory to resolve the username and gather account details. It then discovers all domain controllers in the environment:
$DCs = Get-ADDomainController -Filter * |
Select-Object -ExpandProperty HostName
For each domain controller, the script systematically searches both current and archived netlogon files. The key insight is understanding that authentication failures appear in netlogon logs with specific patterns that differ from successful authentications.
Pattern Recognition
Traditional approaches often capture all authentication events, creating noise in the analysis. The script focuses exclusively on authentication failures using precise regex patterns:
$matches = $content | Where-Object {
($_ -match "\[LOGON\].*$escapedSearchUser.*Returns 0xC000006A") -or
($_ -match "\[LOGON\].*$escapedSearchUser.*Returns 0xC0000234") -or
($_ -match "\[LOGON\].*\(null\)\\$SamAccountName.*Returns 0xC000006A") -or
($_ -match "\[LOGON\].*\(null\)\\$SamAccountName.*Returns 0xC0000234")
}
The script searches for both domain\username and (null)\username patterns because failed authentication attempts often appear with null domain context in the logs.
Error Code Classification
Each matched event is categorized based on the Windows authentication error code:
if ($match -match "Returns 0xC000006A") { $eventType = "bad-password" }
elseif ($match -match "Returns 0xC0000234") { $eventType = "lockout" }
- 0xC000006A: Wrong password (bad-password events)
- 0xC0000234: Account locked out (lockout events)
JSON File Output
The script builds a comprehensive data structure containing user information, statistics, and detailed event logs:
$result = @{
Success = $true
UserInfo = @{
Name = $userInfo.Name
SamAccountName = $userInfo.SamAccountName
UserPrincipalName = $userInfo.UserPrincipalName
Enabled = $userInfo.Enabled
LockedOut = $userInfo.LockedOut
BadLogonCount = $userInfo.BadLogonCount
}
Analysis = @{
TotalEvents = $searchResults.TotalEvents
LockoutEvents = $searchResults.LockoutEvents
BadPasswordEvents = $searchResults.BadPasswordEvents
DomainControllers = $searchResults.DomainControllers
Events = @($searchResults.Events)
}
}
The output is saved as structured JSON, enabling programmatic consumption and integration with other tools.
Web Interface for Analysis
While the PowerShell script provides complete functionality, interpreting raw JSON output can be cumbersome for daily operations. I created a web interface that transforms the script's output into an intuitive dashboard.
Website Process
The web interface uses a simple file-based approach:
- Run the PowerShell script to generate LockoutResults.json
- Upload the JSON file to the web interface
- View formatted results with visual indicators
Key Features
The interface provides several advantages over raw script output:
- Visual Event Classification: Events are color-coded and categorized with clear icons. Bad password attempts appear with warning indicators, while lockout events are highlighted as critical issues.
- Timeline Presentation: Authentication failures are sorted chronologically, making it easy to Script identify patterns and escalation sequences.
- Domain Controller Mapping: Each event clearly identifies which domain controller logged the failure, essential for understanding authentication flow and potential infrastructure issues.
- Account Status Overview: Current account state is displayed with status indicators, showing whether the account is currently locked, enabled, and the current bad logon count.
File Processing Logic
The interface handles JSON parsing with error checking and data validation:
const data = JSON.parse(jsonContent);
// Ensure events is always an array for consistent processing
let events = data.Analysis.Events;
if (!Array.isArray(events)) {
events = events ? [events] : [];
}
This approach handles edge cases where PowerShell might return a single object instead of an array when only one event is found.
Script : LockoutAnalyzer.ps1
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)]
[string]$Username,
[Parameter(Mandatory=$false)]
[string]$InputType = "sam",
[string[]]$ExcludedDCs = @()
)
# Create log and output files in same directory as script
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$logFile = Join-Path $scriptDir "LockoutAnalysis.log"
$jsonOutputFile = Join-Path $scriptDir "LockoutResults.json"
function Write-ScriptLog {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logEntry = "[$timestamp] $Message"
try {
$logEntry | Out-File -FilePath $logFile -Append -Encoding UTF8
Write-Output $logEntry
} catch {
# Ignore logging errors
}
}
Write-ScriptLog "=== Lockout Analysis Started ==="
Write-ScriptLog "Username: $Username, InputType: $InputType"
try {
Import-Module ActiveDirectory -ErrorAction Stop
Write-ScriptLog "Active Directory module loaded"
} catch {
Write-ScriptLog "ERROR: Active Directory module failed to load"
$errorResult = @{
Success = $false
Error = "Active Directory module not available"
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
$errorJson = $errorResult | ConvertTo-Json -Compress
$errorJson | Out-File -FilePath $jsonOutputFile -Encoding UTF8 -Force
Write-Output $errorJson
exit 1
}
function Get-UserInfo {
param([string]$UserInput, [string]$Type)
Write-ScriptLog "Looking up user: $UserInput (Type: $Type)"
Write-Host "Searching for user in Active Directory..." -ForegroundColor Yellow
try {
if ($Type -eq "upn") {
Write-ScriptLog "Querying AD with UPN filter"
$user = Get-ADUser -Filter "UserPrincipalName -eq '$UserInput'" -Properties *
} else {
Write-ScriptLog "Querying AD with Identity"
$user = Get-ADUser -Identity $UserInput -Properties *
}
Write-ScriptLog "AD query completed"
if (-not $user) {
throw "User not found"
}
$domain = $env:USERDOMAIN.ToLower()
$searchString = "$domain\$($user.SamAccountName)"
Write-ScriptLog "User found: $searchString"
Write-Host "Found user: $($user.Name) ($($user.SamAccountName))" -ForegroundColor Green
return @{
SearchString = $searchString
Name = $user.Name
SamAccountName = $user.SamAccountName
UserPrincipalName = $user.UserPrincipalName
Enabled = $user.Enabled
LockedOut = $user.LockedOut
BadLogonCount = $user.BadLogonCount
}
} catch {
Write-ScriptLog "ERROR: User lookup failed - $($_.Exception.Message)"
Write-Host "ERROR: Failed to find user - $($_.Exception.Message)" -ForegroundColor Red
throw
}
}
function Search-NetlogonLogs {
param([string]$SearchUser, [string]$SamAccountName)
Write-ScriptLog "Searching netlogon logs for: $SearchUser and (null)\$SamAccountName"
Write-Host "Getting list of domain controllers..." -ForegroundColor Yellow
# Escape backslashes in the search string for regex
$escapedSearchUser = $SearchUser -replace '\\', '\\'
Write-ScriptLog "Escaped search string: $escapedSearchUser"
Write-ScriptLog "Also searching for: (null)\\$SamAccountName"
# Get domain controllers
try {
Write-ScriptLog "Querying domain controllers..."
$DCs = Get-ADDomainController -Filter * |
Select-Object -ExpandProperty HostName |
Where-Object { $_ -notin $ExcludedDCs }
Write-ScriptLog "Found $($DCs.Count) domain controllers: $($DCs -join ', ')"
Write-Host "Found $($DCs.Count) domain controllers: $($DCs -join ', ')" -ForegroundColor Green
} catch {
Write-ScriptLog "ERROR: Failed to get domain controllers - $($_.Exception.Message)"
Write-Host "ERROR: Failed to get domain controllers - $($_.Exception.Message)" -ForegroundColor Red
throw "Cannot get domain controllers"
}
# Initialize arrays properly
$allEvents = @()
$dcResults = @{}
$dcCounter = 0
Write-ScriptLog "Initialized empty event arrays"
foreach ($DC in $DCs) {
$dcCounter++
Write-ScriptLog "[$dcCounter/$($DCs.Count)] Searching DC: $DC"
Write-Host "[$dcCounter/$($DCs.Count)] Checking $DC..." -ForegroundColor Cyan
try {
$dcEvents = @()
# Test DC connectivity
Write-ScriptLog " Testing connectivity to $DC"
Write-Host " Testing connectivity..." -NoNewline
try {
$pingResult = Test-Connection -ComputerName $DC -Count 1 -Quiet
if (-not $pingResult) {
Write-Host " FAILED" -ForegroundColor Red
Write-ScriptLog " WARNING: Cannot ping $DC"
$dcResults[$DC] = @{
EventCount = 0
Events = @()
Error = "Cannot ping DC"
}
continue
}
Write-Host " OK" -ForegroundColor Green
} catch {
Write-Host " FAILED ($($_.Exception.Message))" -ForegroundColor Red
Write-ScriptLog " WARNING: Ping failed for $DC - $($_.Exception.Message)"
$dcResults[$DC] = @{
EventCount = 0
Events = @()
Error = "Ping failed: $($_.Exception.Message)"
}
continue
}
# Search both netlogon files
$logFiles = @(
"\\$DC\C$\Windows\debug\netlogon.log",
"\\$DC\C$\Windows\debug\netlogon.bak"
)
foreach ($logPath in $logFiles) {
$logName = Split-Path $logPath -Leaf
Write-ScriptLog " Checking: $logPath"
Write-Host " Checking $logName..." -NoNewline
if (Test-Path $logPath) {
Write-Host " Found" -ForegroundColor Green
Write-ScriptLog " Reading: $logPath"
Write-Host " Reading file..." -NoNewline
try {
$content = Get-Content -Path $logPath -ErrorAction Stop
Write-Host " $($content.Count) lines" -ForegroundColor Green
Write-ScriptLog " File read successfully: $($content.Count) lines"
Write-Host " Searching for authentication failures..." -NoNewline
# Search for both domain\username and (null)\username patterns
$matches = $content | Where-Object {
($_ -match "\[LOGON\].*$escapedSearchUser.*Returns 0xC000006A") -or
($_ -match "\[LOGON\].*$escapedSearchUser.*Returns 0xC0000234") -or
($_ -match "\[LOGON\].*\(null\)\\$SamAccountName.*Returns 0xC000006A") -or
($_ -match "\[LOGON\].*\(null\)\\$SamAccountName.*Returns 0xC0000234")
}
Write-Host " $($matches.Count) failures" -ForegroundColor $(if ($matches.Count -gt 0) { "Red" } else { "Gray" })
foreach ($match in $matches) {
$eventType = "unknown"
if ($match -match "Returns 0xC000006A") { $eventType = "bad-password" }
elseif ($match -match "Returns 0xC0000234") { $eventType = "lockout" }
$event = @{
Type = $eventType
DC = $DC
Details = $match.Trim()
Source = Split-Path $logPath -Leaf
}
$dcEvents += $event
$allEvents += $event
}
Write-ScriptLog " Found $($matches.Count) authentication failures in $logName"
} catch {
Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-ScriptLog " ERROR reading file: $($_.Exception.Message)"
}
} else {
Write-Host " Not accessible" -ForegroundColor Gray
Write-ScriptLog " File not accessible: $logPath"
}
}
$dcResults[$DC] = @{
EventCount = $dcEvents.Count
Events = $dcEvents
}
Write-Host " DC $DC complete: $($dcEvents.Count) authentication failures" -ForegroundColor $(if ($dcEvents.Count -gt 0) { "Red" } else { "Gray" })
} catch {
Write-ScriptLog " ERROR accessing $DC - $($_.Exception.Message)"
Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red
$dcResults[$DC] = @{
EventCount = 0
Events = @()
Error = $_.Exception.Message
}
}
}
# Calculate statistics
$lockoutCount = ($allEvents | Where-Object { $_.Type -eq "lockout" }).Count
$badPasswordCount = ($allEvents | Where-Object { $_.Type -eq "bad-password" }).Count
Write-ScriptLog "Search complete: $($allEvents.Count) total authentication failures ($lockoutCount lockouts, $badPasswordCount bad passwords)"
Write-Host "`nSearch Summary:" -ForegroundColor White
Write-Host " Total Authentication Failures: $($allEvents.Count)" -ForegroundColor White
Write-Host " Lockout Events: $lockoutCount" -ForegroundColor $(if ($lockoutCount -gt 0) { "Red" } else { "Gray" })
Write-Host " Bad Password Events: $badPasswordCount" -ForegroundColor $(if ($badPasswordCount -gt 0) { "Yellow" } else { "Gray" })
return @{
TotalEvents = $allEvents.Count
LockoutEvents = $lockoutCount
BadPasswordEvents = $badPasswordCount
LoginAttemptEvents = 0
DomainControllers = $DCs.Count
Events = $allEvents
DCResults = $dcResults
}
}
# Main execution
try {
$userInfo = Get-UserInfo -UserInput $Username -Type $InputType
$searchResults = Search-NetlogonLogs -SearchUser $userInfo.SearchString -SamAccountName $userInfo.SamAccountName
# Create structured result object
$result = @{
Success = $true
UserInfo = @{
SearchString = $userInfo.SearchString
Name = $userInfo.Name
SamAccountName = $userInfo.SamAccountName
UserPrincipalName = $userInfo.UserPrincipalName
Enabled = $userInfo.Enabled
LockedOut = $userInfo.LockedOut
BadLogonCount = $userInfo.BadLogonCount
}
Analysis = @{
TotalEvents = $searchResults.TotalEvents
LockoutEvents = $searchResults.LockoutEvents
BadPasswordEvents = $searchResults.BadPasswordEvents
LoginAttemptEvents = $searchResults.LoginAttemptEvents
DomainControllers = $searchResults.DomainControllers
Events = @($searchResults.Events)
DCResults = $searchResults.DCResults
}
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
Write-ScriptLog "Analysis completed successfully"
# Write JSON output file
$jsonContent = $result | ConvertTo-Json -Depth 10
$jsonContent | Out-File -FilePath $jsonOutputFile -Encoding UTF8 -Force
Write-ScriptLog "JSON results written to: $jsonOutputFile"
# Console output for direct script execution
Write-Output ($result | ConvertTo-Json -Depth 10 -Compress)
} catch {
Write-ScriptLog "ERROR: Script failed - $($_.Exception.Message)"
$errorResult = @{
Success = $false
Error = $_.Exception.Message
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
# Write error to JSON file
$errorJson = $errorResult | ConvertTo-Json -Compress
$errorJson | Out-File -FilePath $jsonOutputFile -Encoding UTF8 -Force
Write-ScriptLog "Error JSON written to: $jsonOutputFile"
Write-Output $errorJson
}
Write-ScriptLog "=== Script Completed ==="