guideonce.
← All guides

Active Directory · PowerShell · Monitoring

Kerberos RC4 Remediation: Live Monitoring and Diagnostic Scripts

The PowerShell tooling I built while working through the RC4 deprecation, with notes on where each pattern is useful far beyond Kerberos.

While working through the RC4 deprecation across a multi-domain Active Directory environment (the full account lives in the remediation walkthrough), I ended up building a set of PowerShell tools to monitor authentication failures in real time, query event logs across multiple DCs at once, and generate automatic rollback scripts before making any changes. They started as RC4-specific utilities, but a lot of them turned out to be useful in a much wider range of scenarios. This post collects them with a note on each about where the same pattern is reusable.

When to reach for each tool
  • live-auth-monitor.ps1: when you are about to change anything authentication-related and want to see breakage the moment it happens, not from a helpdesk ticket twenty minutes later.
  • baseline-failures.ps1: before any change, to capture a "before" snapshot you can diff against later.
  • rc4-audit.ps1: after at least a week of audit logging, to find any account actually negotiating RC4.
  • account-encryption-check.ps1: a quick inventory of where every account sits on the encryption spectrum. Run before, after, and during.
  • bulk-change-with-rollback.ps1: the template I now use for any bulk AD attribute change. Auto-generates a rollback script before it runs.

Live Authentication Failure Monitor

This turned out to be the most useful tool of the whole project. Rather than querying logs after the fact, it polls all your domain controllers every 15 seconds and prints only new authentication failures as they happen, colour coded by severity.

Beyond this project

The same pattern is useful any time you are making changes that touch authentication: password policy changes, Conditional Access rollouts, MFA enforcement, NTLM restriction, trust configuration changes, or smart card / certificate authentication rollouts. Anything where you want to know immediately if something breaks rather than finding out from a helpdesk ticket twenty minutes later.

live-auth-monitor.ps1 Live Screen output Saves to log
Polls every listed DC every 15 seconds. Prints new failures only. Colour codes by severity. Writes to a timestamped log file. Auto-stops after 48 hours. Writes a clean closing summary if interrupted with Ctrl+C.
$StartTime = Get-Date
$EndTime   = $StartTime.AddHours(48)
$LogFile   = "$env:USERPROFILE\Desktop\auth_monitor_$(Get-Date -Format 'ddMMyyyy_HHmm').log"
$DCs       = @("DC1.domain.local","DC2.domain.local","DC3.domain.local")
$LastCheck = $StartTime

function Write-Log {
    param($Message, $Color = "White")
    $Line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message"
    Write-Host $Line -ForegroundColor $Color
    Add-Content -Path $LogFile -Value $Line
}

function Close-Log {
    $Duration = (Get-Date) - $StartTime
    Write-Log "---"
    Write-Log "Monitor stopped at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
    Write-Log "Total duration: $([math]::Round($Duration.TotalHours, 2)) hours"
    Write-Log "Log saved to: $LogFile"
}

Write-Log "---"
Write-Log "LIVE AUTH FAILURE MONITOR"
Write-Log "Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Log "Auto-stop: $($EndTime.ToString('yyyy-MM-dd HH:mm:ss'))"
Write-Log "DCs: $($DCs -join ', ')"
Write-Log "---"

try {
    while ((Get-Date) -lt $EndTime) {
        foreach ($DC in $DCs) {
            try {
                $Events = Get-WinEvent -ComputerName $DC -FilterHashtable @{
                    LogName   = 'Security'
                    Id        = @(4768, 4769, 4771)
                    StartTime = $LastCheck
                } -ErrorAction SilentlyContinue |
                Where-Object {
                    $_.Message -match "Result Code:.*0xE\b"  -or
                    $_.Message -match "Result Code:.*0x37\b" -or
                    $_.Message -match "Result Code:.*0x17\b" -or
                    $_.Message -match "Result Code:.*0x18\b" -or
                    $_.Message -match "Result Code:.*0x25\b" -or
                    $_.Message -match "Result Code:.*0x12\b"
                }

                foreach ($Event in $Events) {
                    $Account = if ($Event.Message -match "Account Name:\s+(\S+)") { $Matches[1] } else { "?" }
                    $Result  = if ($Event.Message -match "Result Code:\s+(\S+)")  { $Matches[1] } else { "?" }
                    $IP      = if ($Event.Message -match "Client Address:\s+(\S+)") { $Matches[1] } else { "?" }
                    $Meaning = switch ($Result) {
                        "0xE"  { "ENCRYPTION TYPE NOT SUPPORTED" }
                        "0x37" { "ENCRYPTION TYPE NOT SUPPORTED (service ticket)" }
                        "0x17" { "Password expired, may need key refresh" }
                        "0x18" { "Wrong password, check stored credentials" }
                        "0x25" { "Clock skew too large, check time sync" }
                        "0x12" { "Account disabled, locked, or expired" }
                        default { "Other: $Result" }
                    }
                    $Color = switch ($Result) {
                        "0xE"  { "Red" }
                        "0x37" { "Red" }
                        "0x17" { "Yellow" }
                        "0x18" { "Yellow" }
                        "0x25" { "Magenta" }
                        "0x12" { "Gray" }
                        default { "White" }
                    }
                    Write-Log "[$DC] $Account $Result $Meaning $IP" $Color
                }
            } catch {
                Write-Log "[$DC] Query error: ${_}" "DarkGray"
            }
        }
        $LastCheck = Get-Date
        Write-Host "." -NoNewline -ForegroundColor DarkGray
        Start-Sleep -Seconds 15
    }
    Write-Log ""
    Write-Log "48 hour monitoring period complete."
} finally {
    Close-Log
}

Baseline Log Snapshot

Before making any changes, capture the existing failure landscape. This gives you a "before" picture so you can later distinguish pre-existing noise from new issues introduced by your changes.

Beyond this project

A quick pre-change health check before any authentication infrastructure work. Run it, save the output, make the change, run it again. The diff tells you exactly what your change introduced. Also useful after an incident to characterise what failures were happening before and during.

baseline-failures.ps1 Screen output
Queries all DCs for the last 24 hours of authentication failures. Outputs a summary by result code and by account, then pages through the full detail.
$StartTime = (Get-Date).AddDays(-1)
$DCs = @("DC1.domain.local","DC2.domain.local","DC3.domain.local")

$Results = foreach ($DC in $DCs) {
    Write-Host "Querying $DC..." -ForegroundColor Cyan
    try {
        Get-WinEvent -ComputerName $DC -FilterHashtable @{
            LogName   = 'Security'
            Id        = @(4768, 4769, 4771)
            StartTime = $StartTime
        } -MaxEvents 1000 -ErrorAction Stop |
        Where-Object {
            $_.Message -match "Result Code:.*0x" -and
            $_.Message -notmatch "Result Code:.*0x0\b"
        } |
        Select-Object TimeCreated,
            @{N='DC';       E={ $DC }},
            @{N='Account';  E={ if ($_.Message -match "Account Name:\s+(\S+)") { $Matches[1] } }},
            @{N='Result';   E={ if ($_.Message -match "Result Code:\s+(\S+)") { $Matches[1] } }},
            @{N='ClientIP'; E={ if ($_.Message -match "Client Address:\s+(\S+)") { $Matches[1] } }},
            @{N='Meaning';  E={
                $code = if ($_.Message -match "Result Code:\s+(\S+)") { $Matches[1] } else { "" }
                switch ($code) {
                    "0x6"  { "Account not found" }
                    "0xE"  { "ENCRYPTION TYPE NOT SUPPORTED" }
                    "0x12" { "Account disabled, locked, or expired" }
                    "0x17" { "Password expired" }
                    "0x18" { "Wrong password" }
                    "0x25" { "Clock skew" }
                    "0x37" { "ENCRYPTION TYPE NOT SUPPORTED (service ticket)" }
                    default { "Other: $code" }
                }
            }}
    } catch { Write-Warning "Could not query ${DC}: ${_}" }
}

Write-Host "`n--- Summary by Result Code ---" -ForegroundColor Yellow
$Results | Group-Object Result | Sort-Object Count -Descending |
    Select-Object Count, Name, @{N='Meaning';E={($_.Group[0].Meaning)}} |
    Format-Table -AutoSize

Write-Host "`n--- Summary by Account ---" -ForegroundColor Yellow
$Results | Group-Object Account | Sort-Object Count -Descending |
    Select-Object Count, Name | Format-Table -AutoSize

Write-Host "`n--- Full Detail ---" -ForegroundColor Yellow
$Results | Sort-Object TimeCreated -Descending |
    Format-Table TimeCreated, DC, Account, Result, Meaning, ClientIP -AutoSize |
    Out-Host -Paging

RC4 Activity Audit

The foundational audit script. Queries all DCs for Event ID 4769 (service ticket requests) where the encryption type was RC4 (0x17). Run this after at least a week of audit logging, and again before touching any accounts.

rc4-audit.ps1 Screen output Saves to CSV
Queries all DCs since a specified start date. Filters for RC4 encryption type (0x17 / decimal 23). Outputs summary by account and saves full detail to CSV.
$DCs = @("DC1.domain.local","DC2.domain.local","DC3.domain.local")
$StartDate = (Get-Date "2026-01-15")  # Date you enabled audit logging

$RC4Events = foreach ($DC in $DCs) {
    Write-Host "Querying $DC..." -ForegroundColor Cyan
    try {
        Get-WinEvent -ComputerName $DC -FilterHashtable @{
            LogName   = 'Security'
            Id        = 4769
            StartTime = $StartDate
        } -MaxEvents 20000 -ErrorAction Stop |
        Where-Object { $_.Message -match "Ticket Encryption Type:\s+(0x17|23)\b" } |
        Select-Object `
            @{N='DC';      E={ $DC }},
            @{N='Time';    E={ $_.TimeCreated }},
            @{N='Account'; E={ if ($_.Message -match "Account Name:\s+(\S+)") { $Matches[1] } }},
            @{N='Service'; E={ if ($_.Message -match "Service Name:\s+(\S+)") { $Matches[1] } }},
            @{N='ClientIP';E={ if ($_.Message -match "Client Address:\s+(\S+)") { $Matches[1] } }}
    } catch { Write-Warning "Could not query ${DC}: ${_}" }
}

if ($RC4Events) {
    Write-Host "`nRC4 usage found:" -ForegroundColor Red
    $RC4Events | Group-Object Account | Sort-Object Count -Descending |
        Select-Object Count, Name | Format-Table -AutoSize
    $RC4Events | Export-Csv "$env:USERPROFILE\Desktop\rc4_audit.csv" -NoTypeInformation
    Write-Host "Full detail saved to Desktop\rc4_audit.csv"
} else {
    Write-Host "`nNo RC4 detected." -ForegroundColor Green
}

Account Encryption State Check

A quick inventory of where every account currently sits on the encryption spectrum. Run this before and after remediation to verify your changes landed correctly.

account-encryption-check.ps1 Screen output
Groups all user, computer, and inetOrgPerson objects by their msDS-SupportedEncryptionTypes value. After remediation you should see only 24. Any other values need investigating.
# Change -Server to target a specific domain
Get-ADObject -Filter * -Properties msDS-SupportedEncryptionTypes, ObjectClass |
Where-Object { $_.ObjectClass -in @("user","computer","inetOrgPerson") } |
Group-Object msDS-SupportedEncryptionTypes |
Select-Object Count, Name | Format-Table -AutoSize

# Value reference:
# blank / null  = No explicit setting, falls back to domain default (includes RC4)
# 0             = Same as null
# 4             = RC4 only
# 24            = AES128 + AES256 (target state)
# 28            = AES128 + AES256 + RC4 (safe interim)
# 31            = DES + RC4 + AES (DES needs removing)

Bulk Changes With Logging and Rollback

Every bulk change I made during this project used the same pattern: log every change before and after, and auto-generate a rollback script from the log. If something breaks after a bulk update, the rollback script is already sitting on your desktop, ready to run.

Do this before every bulk change

The temptation is just to run the Set-ADUser command across thousands of accounts. Resist it. The extra thirty lines to generate a rollback script take seconds to add, and they have saved me from a few situations that would otherwise have been a much worse afternoon.

Beyond this project

Any bulk AD attribute change. Moving users between OUs, updating UPN suffixes, changing password policies on groups of accounts, updating proxy addresses, anything where you are touching hundreds or thousands of objects, this pattern gives you a complete audit trail and a one-click way back. I now use it as the standard template for any bulk AD change, regardless of what the change is.

bulk-change-with-rollback.ps1 Screen output Saves log + rollback Auto rollback
Template for any bulk AD attribute change. Logs every account changed (name, old value, new value, success / error). Auto-generates a complete rollback script that restores every account to its previous state, including null values.
$Server     = "DC1.domain.local"
$LogPath    = "$env:USERPROFILE\Desktop\changes_$(Get-Date -Format 'ddMMyy_HHmm').csv"
$RollbackPath = "$env:USERPROFILE\Desktop\rollback_$(Get-Date -Format 'ddMMyy_HHmm').ps1"
$Log        = New-Object System.Collections.Generic.List[PSObject]

# === Define what you're changing ===
$Accounts = Get-ADObject -Filter * -Properties msDS-SupportedEncryptionTypes, ObjectClass -Server $Server |
Where-Object {
    $_.ObjectClass -in @("user","computer","inetOrgPerson") -and
    $_."msDS-SupportedEncryptionTypes" -eq 28  # Change this filter as needed
}

Write-Host "Found $($Accounts.Count) accounts to update" -ForegroundColor Cyan

foreach ($Account in $Accounts) {
    $OldValue = $Account.'msDS-SupportedEncryptionTypes'
    $OldValueStr = if ($null -eq $OldValue -or $OldValue -eq "") { "null" } else { "$OldValue" }

    try {
        # === Make your change here ===
        if ($Account.ObjectClass -eq 'user') {
            Set-ADUser $Account -KerberosEncryptionType AES128,AES256 -Server $Server
        } elseif ($Account.ObjectClass -eq 'computer') {
            Set-ADComputer $Account -KerberosEncryptionType AES128,AES256 -Server $Server
        } else {
            Set-ADObject $Account -Replace @{"msDS-SupportedEncryptionTypes" = 24} -Server $Server
        }

        Write-Host "Updated: $($Account.Name) ($OldValueStr -> 24)" -ForegroundColor Green
        $Log.Add((New-Object PSObject -Property @{
            Name = $Account.Name; ObjectClass = $Account.ObjectClass
            DN = $Account.DistinguishedName; OldValue = $OldValueStr
            NewValue = 24; Status = "Success"
        }))
    } catch {
        Write-Host "Failed: $($Account.Name) - ${_}" -ForegroundColor Red
        $Log.Add((New-Object PSObject -Property @{
            Name = $Account.Name; ObjectClass = $Account.ObjectClass
            DN = $Account.DistinguishedName; OldValue = $OldValueStr
            NewValue = "Failed"; Status = "Error: ${_}"
        }))
    }
}

# Export change log
$Log | Select-Object Name, ObjectClass, DN, OldValue, NewValue, Status |
    Export-Csv $LogPath -NoTypeInformation
Write-Host "`nChange log: $LogPath" -ForegroundColor Yellow

# Generate rollback script
$Lines = New-Object System.Collections.Generic.List[String]
$Lines.Add("# Rollback script, generated $(Get-Date -Format 'dd/MM/yyyy HH:mm')")
$Lines.Add('$Server = "DC1.domain.local"')
$Lines.Add("")

foreach ($Entry in ($Log | Where-Object { $_.Status -eq "Success" })) {
    if ($Entry.OldValue -eq "null") {
        $Lines.Add("Set-ADObject -Identity '$($Entry.DN)' -Clear msDS-SupportedEncryptionTypes -Server `$Server")
    } else {
        $Lines.Add("Set-ADObject -Identity '$($Entry.DN)' -Replace @{'msDS-SupportedEncryptionTypes'=$($Entry.OldValue)} -Server `$Server")
    }
}

$Lines.Add('Write-Host "Rollback complete" -ForegroundColor Green')
$Lines | Out-File $RollbackPath -Encoding UTF8
Write-Host "Rollback script: $RollbackPath" -ForegroundColor Yellow

Write-Host "`nSummary: $($Log.Count) processed, $(($Log | Where-Object {$_.Status -eq 'Success'}).Count) successful, $(($Log | Where-Object {$_.Status -ne 'Success'}).Count) failed" -ForegroundColor Cyan

Kerberos Result Code Reference

When you are reading authentication failure events, these are the result codes you will encounter and what each one means in practice:

CodeMeaningLikely causeFix
0x6 Account not found Machine in a trusted domain querying the wrong DC. Often appears as IP-named cloud instances. Pre-existing, investigate DNS or domain suffix configuration on the source machine. Not urgent.
0xE Encryption type not supported Account has no AES keys. Password was last set before AES was configured in the domain, only RC4 keys on record. Reset password to same value to regenerate keys: Set-ADAccountPassword -Reset
0x12 Account disabled, locked, or expired Stale local profile, scheduled task, or service running under a leaver's account. Find what is using the account (scheduled tasks, services, stored credentials) and remove or update.
0x17 Password expired Account past its expiry date. Often a service account nobody noticed had expiry enabled. Reset password, or use the pwdLastSet trick if you cannot change the password: set to 0, then -1.
0x18 Wrong password Stored credential not updated after a password change. Common in services, scheduled tasks, mapped drives. Find the stored credential (cmdkey /list, Get-ScheduledTask, Win32_Service) and update it.
0x25 Clock skew too large Client clock more than 5 minutes out of sync with the DC. Kerberos is strict about this. Check NTP configuration on the machine shown in ClientIP. w32tm /query /status
0x37 Encryption type not supported (service ticket) Same root cause as 0xE but during service ticket requests rather than initial TGT requests. Same fix as 0xE, password reset to regenerate AES keys.

Troubleshooting Common Issues

Audit policy not working

If event logs are empty despite running the auditpol commands, a GPO is almost certainly overriding your local settings. Check the effective policy:

auditpol /get /subcategory:"Kerberos Service Ticket Operations"

If it shows "No Auditing" after you have set it to Success and Failure, configure it via Group Policy instead: Default Domain Controllers Policy, Advanced Audit Policy Configuration.

Remote event log queries failing (RPC error)

If Get-WinEvent returns "RPC server unavailable" when querying DCs remotely, the Remote Event Log Management firewall rules are disabled:

netsh advfirewall firewall set rule group="Remote Event Log Management" new enable=yes

Account still showing RC4 after setting to 28

Setting the encryption type attribute tells AD what the account supports. The actual cryptographic keys are generated from the password. An account whose password was last set years ago, before AES was properly configured in your domain, will only have RC4 keys on record, regardless of the attribute value. The fix is a password reset:

# If you know the current password, reset to the same value
$Password = Read-Host -AsSecureString "Enter current password"
Set-ADAccountPassword -Identity "accountname" -Reset -NewPassword $Password

# If the password is expired but you do not want to change it
Set-ADUser -Identity "accountname" -Replace @{pwdLastSet=0}
Set-ADUser -Identity "accountname" -Replace @{pwdLastSet=-1}

0xE errors after moving to AES-only

If you see 0xE errors appearing in the live monitor after enforcing AES, check the PasswordLastSet on the failing account first:

Get-ADUser -Identity "accountname" -Properties PasswordLastSet, msDS-SupportedEncryptionTypes |
    Select-Object Name, PasswordLastSet, msDS-SupportedEncryptionTypes

If PasswordLastSet is more than a couple of years ago, that is almost certainly your problem. Reset the password to regenerate AES keys. If it is recent, the issue is something else. Check whether the account's computer account is also correctly configured.

Finding what is using a stale account

When you see repeated 0x12 failures for what should be a disabled account, something on the source machine is still trying to authenticate as it. Check in this order:

# Scheduled tasks
Get-ScheduledTask | Where-Object { $_.Principal.UserId -like "*accountname*" }

# Services
Get-WmiObject Win32_Service | Where-Object { $_.StartName -like "*accountname*" }

# Stored credentials
cmdkey /list

# Cached profiles
Get-WmiObject Win32_UserProfile | Select-Object LocalPath, SID, LastUseTime |
    Sort-Object LastUseTime -Descending

Trust object modification failing

If Set-ADObject returns "illegal modify operation" on a trustedDomain object, use the GUI instead. In Active Directory Domains and Trusts, right-click the domain, Properties, Trusts tab, select the trust, Properties, and tick the AES checkbox. For AWS Managed AD trusts where even the GUI is not available, raise a support ticket. AWS needs to action it on their side.


Quick-Action Checklist

For when you just want the steps in order. Each one links back to the section above with the script and the detail.

  1. Capture a baseline log snapshot before doing anything else. You will want this later to distinguish pre-existing noise from new failures.
  2. Start the live authentication failure monitor in a side terminal. Leave it running for the duration of any work that touches authentication. Take screenshots if you need to share with anyone.
  3. Run the account encryption state check per domain. Note the counts at each value before changes. Re-run after every batch of changes.
  4. Run the RC4 activity audit after at least a week of audit logging. Confirm no live dependencies.
  5. For every bulk change, use the bulk-change-with-rollback pattern. Generate the rollback script even if you do not expect to need it. Treat the rollback as part of the deliverable, not an afterthought.
  6. If you see 0xE or 0x37 in the live monitor, check the affected account's PasswordLastSet. Old passwords need a reset to regenerate AES keys.
  7. If audit logging looks empty, do not assume it is working. Check with a direct Get-WinEvent query on each DC. A GPO may be overriding your auditpol settings.
  8. For RPC errors querying remote DCs, enable the Remote Event Log Management firewall rules.
  9. For AWS Managed AD trust objects, do not try to fix via PowerShell. Use the GUI if available, otherwise raise an AWS support ticket.

Closing Thoughts

The general lesson from building all of this is that a small amount of upfront investment in observability and reversibility pays off massively the moment something starts behaving in a way you did not expect. The live monitor is the bit I now reach for any time I am touching authentication, regardless of what the change is. The bulk-change-with-rollback pattern has become my default template for any operation that loops over more than a handful of AD objects. Neither was complicated to build, both have saved me hours of incident triage many times over.

For the project context, the decisions about what to set when, and the more discursive walkthrough of how to think about Kerberos hardening across a multi-domain estate, see the main remediation walkthrough.

← All guides