Knowing What Breaks Before You Turn Off RC4

A while ago I wrote a script called Convert-RC4toAES.ps1. It does exactly what the name says: it finds accounts whose msDS-SupportedEncryptionTypes is sitting at zero or at RC4 only, and it widens them to RC4 plus AES128 plus AES256. It has a -WhatIf mode, an exclusion list, and a log file. It works fine. It is also, in retrospect, the easy half of the problem.

Setting the attribute is one line. I have never once been nervous about the line. What I have been nervous about, every single time, is the conversation that happens two days later, when an application owner calls to say that the nightly job stopped running and nobody can explain why. The attribute change is trivial. Predicting the blast radius of the attribute change is not, and there is a real shortage of tooling that even tries.

So I built KrbEtypeInsight. It is a PowerShell module that answers one question and only one question: if I remove RC4 from this domain, what breaks, and whose is it? It does not change anything. It reads audit events, reads the directory, reads the per-controller policy, correlates the three, and hands you a list of principals with a severity, a score, and, most importantly, the names of the specific client machines that will stop authenticating.

The module now lives in my TechbyJeff repo under Powershell/Active Directory/Kerberos/KrbEtypeInsight, next to the RC4 conversion script that started all this.

Why this suddenly stopped being optional

I started this as a personal itch. It turned into a deadline.

Microsoft has been walking RC4 out of Kerberos on a published schedule, and that schedule ran out this summer. The short version, tied to CVE-2026-20833:

Date Phase What changed
13 January 2026 Audit RC4DefaultDisablementPhase defaults to 1. The KDC logs warnings about RC4 service ticket issuance but keeps issuing.
14 April 2026 Enforcement, reversible The phase value defaults to 2. Domain controllers assume RC4 is not enabled, with DefaultDomainSupportedEncTypes effectively 0x18. You can still roll back with the registry value.
July 2026 Enforcement, permanent RC4DefaultDisablementPhase is ignored entirely. The escape hatch is gone.

Separately, Microsoft's Windows Server blog said in December 2025 that by mid-2026 the assumed default supported encryption types on domain controllers would move from RC4 to AES-SHA1, which is the same change described from the other direction.

If you are reading this in the second half of 2026, the rollback lever no longer exists. Any account that was quietly depending on RC4 has either already broken or is one password-policy event away from breaking, and the diagnostic you get is a KDC event, not a phone call from the service that failed.

That is the environment this module was built for. It is a pre-flight check for a change that, in a lot of estates, has already been made on your behalf.

The question configuration auditing cannot answer

There is no shortage of scripts that dump msDS-SupportedEncryptionTypes across a domain and colour the RC4 rows red. I have written one. They are useful and they are not enough, because a configuration audit tells you what an account is allowed to do, and hardening breaks on what an account and its clients actually did.

Getting to a real answer means putting three independent sources next to each other:

Source Question it answers
Events 4768, 4769, 4771 What the KDC has actually been issuing
msDS-SupportedEncryptionTypes, userAccountControl What the directory says each principal supports
Per-controller DefaultDomainSupportedEncTypes, trusts, krbtgt What the domain permits by default

None of the three is sufficient alone. Events tell you what happened but not why. The directory tells you the intent but not the reality. The domain baseline tells you what an unconfigured account inherits, which is the case that bites hardest.

Put them together and the output stops being a configuration statement and starts being a prediction. Instead of "this account is configured for RC4", you get something like this:

svc-payroll is Critical, score 53. 412 service ticket requests in 30 days, all RC4. The account holds no AES key material, so the KDC would return KDC_ERR_NULL_KEY. 12 distinct clients depend on it, of which 3 advertise no AES encryption type at all: APPLIANCE-SCAN01$, LEGACY-ETL02$, KIOSK-114$.

That last list is the deliverable. It names the machines an application owner has to go fix, and no amount of configuration auditing produces it.

Four things that make this harder than it looks

I want to walk through these, because each one is a place where a reasonable-looking script gives you a confidently wrong answer.

There are two Kerberos numbering systems and they are easy to confuse

RC4 is 23 as an RFC 3961 ticket encryption type, and bit 0x4 as an MS-KILE supported-types flag. AES256-SHA1 is 18 in one system and 0x10 in the other. These are not two encodings of the same number, they are two unrelated registries that happen to both describe ciphers.

A script that tests a ticket's TicketEncryptionType against 4 is testing for DES-CBC-MD4, and will report every RC4 ticket in the estate as clean. That failure mode is silent and it is very believable, because the numbers look like they should line up.

The module models the two as separate types and never converts between them by arithmetic. The mapping is an explicit table, because it is not a computable relationship:

$flagToTicketEtype = @{
    0x00000001 = @{ Name = 'DES-CBC-CRC';                TicketEtypes = @(1);  SessionKeyEtypes = @(1)  }
    0x00000002 = @{ Name = 'DES-CBC-MD5';                TicketEtypes = @(3);  SessionKeyEtypes = @(3)  }
    0x00000004 = @{ Name = 'RC4-HMAC';                   TicketEtypes = @(23); SessionKeyEtypes = @(23) }
    0x00000008 = @{ Name = 'AES128-CTS-HMAC-SHA1-96';    TicketEtypes = @(17); SessionKeyEtypes = @(17) }
    0x00000010 = @{ Name = 'AES256-CTS-HMAC-SHA1-96';    TicketEtypes = @(18); SessionKeyEtypes = @(18) }
    0x00000020 = @{ Name = 'AES256-CTS-HMAC-SHA1-96-SK'; TicketEtypes = @();   SessionKeyEtypes = @(18) }

Look at the last row. Bit 0x20 authorises AES256 for the session key only, not for the ticket. So TicketEtypes is deliberately empty and SessionKeyEtypes is not. An account carrying only that bit issues AES session keys inside RC4 tickets, which is exactly the state that looks hardened in a spreadsheet and is not hardened at all.

There is a second reason the two lists are separate, and it is a capability question rather than a cipher question:

    # Capability bits. Deliberately carry empty etype lists so that any code summing
    # TicketEtypes across set bits cannot mistake a FAST-capable account for a
    # cipher-capable one.
    0x00010000 = @{ Name = 'FAST-Supported';                    TicketEtypes = @(); SessionKeyEtypes = @() }
    0x00020000 = @{ Name = 'Compound-Identity-Supported';       TicketEtypes = @(); SessionKeyEtypes = @() }

If you naively sum bits and treat any non-RC4 bit as progress, a FAST-capable RC4-only account reads as modernised.

Configuration does not tell you whether the key exists

This is the one that causes rollbacks, and it is the reason I kept building after the first prototype.

Kerberos keys are derived when the password is set. An account whose password predates the domain's AES support has no AES key, no matter what its attributes claim. Setting that account to AES-only does not harden it, it breaks it, and the KDC returns KDC_ERR_NULL_KEY. You cannot see this from msDS-SupportedEncryptionTypes, because that attribute describes intent and the key material describes reality.

What you can see it from is a version 2 audit event, which reports the account's available keys directly. That is the module's KRB002 finding:

# KRB002 - the decisive one. Available keys are ground truth from the KDC, so
# this outranks anything the configuration says.
if ($targetSupportsAes -and $obs.AvailableKeysKnown) {
    $hasAesKey = @($obs.AvailableKeys) -match '^AES'
    if (-not $hasAesKey) {
        $findings.Add((New-KrbRiskFinding -Code 'KRB002' -Severity 'Critical' `
            -Title 'Principal holds no AES key material' `

The recommended action attached to that finding is the part people get wrong: reset the password first, confirm AES appears in AvailableKeys on subsequent events, and only then change the attribute. Changing the attribute on an account with no AES key is how you turn a hardening window into an incident.

This is also precisely what the July 2026 enforcement makes unforgiving. Before, a broken account could be walked back with the registry override. Now it cannot.

An unset attribute is not "supports nothing"

When msDS-SupportedEncryptionTypes is absent or zero, the KDC falls back to the domain controller's DefaultDomainSupportedEncTypes registry value. Historically that value's own unwritten default was 0x27, and 0x27 includes the AES256 session-key bit:

    # MS-KILE: when msDS-SupportedEncryptionTypes is absent or zero, the KDC falls back
    # to the DefaultDomainSupportedEncTypes registry value on the DC, whose own default
    # is 0x27. Anything that reads a null attribute as "supports nothing" is wrong, and
    # anything that reads it as "supports RC4 only" is also wrong - 0x27 includes the
    # AES256 session-key bit.
    DefaultDomainSupportedEncTypes = 0x27

Two wrong readings are common here. Reading a null attribute as "supports nothing" produces a flood of false Criticals against perfectly healthy accounts. Reading it as "supports RC4 only" is closer but still wrong, because the session-key bit is present and will make some accounts look partially AES-capable when their tickets are still RC4.

Since April 2026 the effective default has moved to 0x18, which is why the module reads the value rather than assuming it. Whatever your controllers actually have is what your unconfigured accounts inherit, and a module that hardcodes either default will misjudge the entire population of accounts that never had the attribute set. In most domains that population is the majority.

DefaultDomainSupportedEncTypes is per-controller, and controllers disagree

This one is not documented loudly enough. DefaultDomainSupportedEncTypes is a registry value on each domain controller. It is not a replicated directory attribute. Controllers can and do disagree, usually because someone set it during an earlier hardening push and a DC built afterwards never got it.

A domain where controllers disagree authenticates differently depending on which controller a client happens to reach. The symptom is intermittent authentication failure that survives every attempt to reproduce it, because reproducing it means hitting the right DC. Get-KrbDomainEtypeContext reads the value from every controller and reports the disagreement rather than picking one and calling it the domain policy.

$context = Get-KrbDomainEtypeContext -Credential (Get-Credential)
$context.DomainDefaultSource                                   # 'Registry' = measured
$context.DomainControllers | Where-Object { -not $_.RegistryReachable }

DomainDefaultSource is worth checking every run. If it does not say Registry, the baseline was assumed rather than measured, and every per-account finding inherits that assumption.

The bit where I had to believe measurement over documentation

MS-KILE documents bits 0x40 and 0x80 as the RFC 8009 AES-SHA2 encryption types. The module originally mapped them to etypes 19 and 20 accordingly, because that is what the specification says.

A Windows Server 2025 KDC, build 26100, does not honour them. Tested directly against a lab controller:

msDS-SupportedEncryptionTypes Result Etype issued
0x80 KDC_ERR_ETYPE_NOTSUPP none
0xC0 KDC_ERR_ETYPE_NOTSUPP none
0x90 (0x80 + 0x10) ticket issued 0x12, the SHA1 type. 0x80 ignored
0x10 (control) ticket issued 0x12

An account carrying only those bits cannot obtain a service ticket at all. Because the module counted them toward SupportsAes, it reported such an account as AES-capable and therefore safe. That is a false negative on an account that could not authenticate, which is the worst direction for this kind of error to point.

The fix is in the catalog, with the reasoning recorded next to it so nobody later "corrects" it back to the spec:

    # TicketEtypes deliberately EMPTY, on observed evidence rather than on the spec.
    #
    # Claiming a ticket etype here is therefore not a harmless overstatement: it made
    # SupportsAes true for an account that cannot authenticate, so the risk engine reported
    # a dead account as safe. An empty list is the honest reading of what Windows does.
    0x00000040 = @{ Name = 'AES128-CTS-HMAC-SHA256-128'; TicketEtypes = @(); SessionKeyEtypes = @() }
    0x00000080 = @{ Name = 'AES256-CTS-HMAC-SHA384-192'; TicketEtypes = @(); SessionKeyEtypes = @() }

The bits are still decoded and named, so nothing is hidden from a report, and a CarriesUnhonouredSha2Bits property surfaces the discrepancy. If a future Windows release starts honouring them, the catalog entry is the one place to change, and it should be changed against a measurement rather than against a document.

The named client list, and the bug that nearly killed it

KRB005 is the finding that makes this module worth running. It names the client machines that advertise no AES support and depend on a service you are about to harden. Everything else in the output could, with effort, be reconstructed from a configuration dump. That list cannot.

It also very nearly did not work, and the reason is a lovely piece of Windows behaviour.

Client capability is accumulated from what each client advertises in its own 4768 events. Originally that accumulation was a union: if a client advertised AES anywhere in the window, it was marked AES-capable. Reasonable. Also completely wrong, because every domain-joined Windows machine emits exactly one AES256 request at boot regardless of its configuration:

# Measured, not assumed. Seven instrumented boots across two machines, both with
# SupportedEncryptionTypes = 0x4 (RC4 only), captured on the wire:
#
#   - 0 of 19 AS-REQs advertised AES. The client's own capability is reported
#     faithfully there, every time.
#   - Every normal boot emitted EXACTLY ONE AES advertisement, always the same
#     request: TGS-REQ for krbtgt/REALM, etypes [AES256] alone, kdc-options
#     0x60810010 (forwardable, forwarded, renewable, canonicalize, renewable-ok).
#   - The same lone request appeared on a second, unrelated machine, so this is
#     Windows behaviour rather than one host's misconfiguration.

With a 30-day default window essentially every client boots at least once, so essentially every client got marked AES-capable, and the named-client list could not fire for the population it exists to describe. The fix is to exclude service ticket requests whose service is krbtgt from capability evidence:

$isTgtServiceRequest = $item.EventId -eq 4769 -and
    $item.ServiceName -and
    ($item.ServiceName -eq 'krbtgt' -or $item.ServiceName -like 'krbtgt/*')

if ($isTgtServiceRequest) {
    if ($null -ne $item.ClientAdvertizedSupportsAes) { $entry.IgnoredTgtRenewals++ }
    continue
}

Note that it is the krbtgt service that disqualifies the evidence, not the forwarded flag. Normal boots also emit forwarded krbtgt requests carrying ordinary RC4 lists, so filtering on the flag would keep the contaminating request and drop clean ones.

The reason I am telling you this in a post about a hardening tool rather than burying it in a changelog is that it generalises. Any tool that infers client capability from observed traffic has to reckon with traffic the client did not choose to send. If you are building your own analysis on top of 4768 and 4769, this is the trap.

Running it

Prerequisites are modest. PowerShell 7.6, which went LTS in March 2026, the Kerberos Authentication Service and Kerberos Service Ticket Operations audit subcategories enabled on the domain controllers, and RSAT for the directory-reading functions. The decode and correlation core runs offline with no domain present at all.

Rights differ per function, and I got this wrong in an earlier revision of my own README by claiming Event Log Readers was sufficient for the whole module:

Function Needs
ConvertFrom-KrbEtype nothing, pure decode
Get-KrbEvent Event Log Readers on each controller
Get-KrbPrincipalEtype read access to the directory, any authenticated user by default
Get-KrbDomainEtypeContext local administrator on each controller, or -SkipRegistry
Get-KrbEtypeRisk, Export-KrbEtypeReport whatever the above needed

Domain Admin is not required for anything, and no function writes to the directory. The integration suite asserts that every principal's msDS-SupportedEncryptionTypes is byte-identical before and after a full assessment run.

Two setup gotchas cost me real time, so they are worth stating plainly.

Remote Event Log Management has to be enabled on every controller you collect from. Get-WinEvent -ComputerName uses the legacy Event Log RPC protocol, not WinRM, and its firewall rules are off by default on a fresh Windows install. A controller that pings, resolves, replicates cleanly and answers WinRM perfectly will still fail every event log call with "The RPC server is unavailable".

Invoke-Command -ComputerName DC02 { Enable-NetFirewallRule -DisplayGroup 'Remote Event Log Management' }

Do not infer the audit schema version from the operating system version. The rich version 2 fields, available keys and client advertisement, arrived with the November 2022 cumulative update (KB5021131, the CVE-2022-37966 change). A newer build does not guarantee them. Measured in one domain on one day: a Server 2022 controller emitted version 2, and a Server 2025 controller emitted version 1 for all 75 of its events. Check rather than assume:

Get-KrbEvent -MaxEvents 2000 | Group-Object Source, EventVersion | Select-Object Count, Name

Where the fields are absent, the module says so on every affected finding rather than presenting an inference as an observation.

The assessment itself is one line:

Import-Module .\KrbEtypeInsight.psd1

Get-KrbEvent -MaxEvents 50000 | Get-KrbEtypeRisk | Sort-Object RiskScore -Descending
Level      Score Principal                    Reqs  Clients   NoAES Codes
-----      ----- ---------                    ----  -------   ----- -----
Critical     100 svc-payroll                    412       12       3 KRB002 KRB001 KRB005
Critical      80 MSSQLSvc/legacydb01:1433        89        4       0 KRB002 KRB001
Critical      53 APPLIANCE-SCAN01$               31        0       0 KRB005 KRB008
Medium        10 svc-reporting                 1204       47       0 KRB013

That column layout is the real default table view. The rows are illustrative, drawn from test fixtures with invented counts, because I am not publishing output captured from a production domain.

Pulling out the machines behind the NoAES column:

$risks = Get-KrbEvent | Get-KrbEtypeRisk
$risks | Where-Object RiskLevel -eq 'Critical' |
    Select-Object PrincipalName, RequestCount, ClientCount,
                  @{ n = 'BreaksClients'; e = { $_.ClientsWithoutAesSupport -join ', ' } }

And a report to attach to the change record:

$context = Get-KrbDomainEtypeContext
Get-KrbEvent | Get-KrbEtypeRisk -DomainContext $context |
    Export-KrbEtypeReport -Path .\krb-readiness.html -DomainContext $context `
        -Title 'RC4 removal readiness - Phase 1'

The export supports self-contained HTML, per-finding CSV, and full-fidelity JSON. Bear in mind that the reports name accounts, service principal names and client addresses, so treat them as internal documents.

Reading the output

There are fifteen finding codes. Rather than list all of them, here are the ones that decide whether you proceed:

Code Severity Meaning
KRB001 Critical Every observed ticket used an encryption type the change removes
KRB002 Critical Principal holds no AES key material, hardening yields KDC_ERR_NULL_KEY
KRB004 Critical USE_DES_KEY_ONLY set, overriding the encryption type attribute
KRB005 Critical Named clients advertise no AES support
KRB011 Critical Encryption-type failures are already occurring
KRB014 High Trust does not permit AES for cross-realm authentication

Two codes matter more than their severity suggests, and they are the pair at the bottom of the list. KRB009 and KRB015 both mean "nothing was found wrong". KRB009 says so on the strength of version 2 events and directory configuration. KRB015 says the fields that would have revealed a problem were never written, so nothing was found wrong and nothing would have been found wrong had it been broken.

Reporting the second as the first is how an assessment gives false assurance about exactly the principals it understood least. If you take one design idea away from this module, take that one: missing information should produce a lower-confidence finding, never a lower-severity one.

The same principle governs cipher survival. The default target 0x18 names etypes 17 and 18, AES-SHA1. It does not name 19 and 20, the RFC 8009 types Windows Server 2025 can issue. Testing exact membership would classify the strongest cipher Windows produces as "removed by the change" and raise Criticals against the most modern machines in the estate. Survival is therefore judged by cipher family, and anything the catalog does not recognise is treated as surviving. Unknown is not the same as incapable.

Level and score

The output carries two numbers, because neither alone answers what you are actually asking.

Level is the highest severity present. It answers "does this principal block the change". Score is a capped weighted sum. It answers "how do I order a backlog when a hundred principals all come back Critical".

Deriving one from the other is the obvious simplification and it loses the distinction. Four Medium findings and one Critical finding can reach the same total, and treating them as equivalent puts a tidy-up task ahead of an outage.

Blast radius scales the score and never the level:

# Log base 10 of the client count, scaled. One client contributes nothing, ten contribute
# 10, a hundred contribute 20. A principal with no findings stays at zero regardless of
# how many clients use it - blast radius multiplies a problem, it does not create one.
$radiusUplift = 0
if ($ClientCount -gt 1 -and $baseScore -gt 0) {
    $radiusUplift = [int][Math]::Round([Math]::Log10($ClientCount) * 10)
}

Logarithmic rather than linear, because the difference between one client and ten is a change in kind, while the difference between three hundred and three thousand is not. Both of those are "the whole estate".

Three workflows worth stealing

Scope the project. Use a window of at least 30 days. Shorter, and the monthly batch job, reliably the thing hardening breaks, falls outside the collection entirely.

$context = Get-KrbDomainEtypeContext
$principals = Get-KrbPrincipalEtype -All -DomainDefaultEncryptionTypes $context.DomainDefaultEncryptionTypes

Get-KrbEvent -StartTime (Get-Date).AddDays(-45) |
    Get-KrbEtypeRisk -Principal $principals -DomainContext $context |
    Where-Object WillBreakOnHardening |
    Export-KrbEtypeReport -Path .\phase1-blockers.html -DomainContext $context

Validate the staged rollout. Everyone treats "add AES while leaving RC4 in place" as the safe intermediate step. Model it and check:

Get-KrbEvent | Get-KrbEtypeRisk -TargetEncryptionTypes 0x1C |
    Where-Object RiskLevel -in 'Critical', 'High'

It should produce no Critical findings. If it does, that step is not as safe as it is usually assumed to be in your particular domain, and you have just found that out for free.

Verify during the maintenance window.

Get-KrbEvent -IncludeFailureOnly -StartTime (Get-Date).AddHours(-4) |
    Group-Object StatusName | Sort-Object Count -Descending

KDC_ERR_ETYPE_NOTSUPP appearing here is the change breaking something. This is your roll-back-or-proceed signal, and it is available within minutes rather than within the time it takes a user to file a ticket.

There is also a fully offline path, which is how I would assess a domain I do not administer. Archive the security logs per controller with wevtutil, which exports in seconds because nothing leaves the machine, then process the files anywhere:

# On each controller
wevtutil epl Security C:\Temp\$env:COMPUTERNAME-krb.evtx `
    "/q:*[System[(EventID=4768 or EventID=4769 or EventID=4771)]]"

# Anywhere, with no domain connectivity
Get-ChildItem \\fileserver\krbaudit\*.evtx | Get-KrbEvent -MaxEvents 0 | Get-KrbEtypeRisk -Offline

What it does not tell you

WillBreakOnHardening means "predicted to break given observed use". It does not mean "cannot work". An account configured without AES that produced no traffic in the collection window is reported as KRB012, which is a Medium meaning unknown, rather than as breaking. Absence of traffic is not evidence of safety, and a report should not be read as though it were.

The predictions have been validated by outcome, in both directions, at small scale. Three hardening dry runs withdrew RC4 at the KDC and compared what actually failed against predictions recorded beforehand. The one service the module named failed and nothing else did, an AES-capable service predicted safe kept working and moved silently to AES256, and both clients named by KRB005 failed at the AS exchange with KDC_ERR_ETYPE_NOTSUPP, unable to obtain a TGT at all. No run produced a false negative. What remains untested is scale, and the predicted-to-break principals that have no traffic and therefore could not be exercised.

I would rather say that plainly than imply more confidence than three dry runs earn.

Get it

The module is at Powershell/Active Directory/Kerberos/KrbEtypeInsight in the TechbyJeff repo, released under GPL v3. The README covers the full command surface, and the Troubleshooting folder has a guide per failure mode, including the ones above that cost me an evening each.

If you still have RC4 anywhere in your estate, and after July 2026 you might have less of it than you think, the useful move is to run the collection before you touch anything. The list of client machines it produces is the piece of work that has to happen regardless, and it is much cheaper to produce it from audit events on a Tuesday than from a help desk queue on a Sunday.

Sources