Public/Get-KrbEtypeRisk.ps1
|
#Requires -Version 7.4 function Get-KrbEtypeRisk { <# .EXTERNALHELP KrbEtypeInsight-Help.xml .SYNOPSIS Predicts which principals and clients a Kerberos encryption type hardening change will break #> [CmdletBinding()] [OutputType('KrbEtypeInsight.Risk')] param( [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNull()] [Alias('Event')] [object[]]$KerberosEvent, [Parameter()] [object[]]$Principal, [Parameter()] [object]$DomainContext, [Parameter()] [int]$TargetEncryptionTypes = 0x18, [Parameter()] [switch]$Offline, [Parameter()] [switch]$IncludeHealthy ) begin { $correlationId = [System.Guid]::NewGuid() Write-Verbose "Starting $($MyInvocation.MyCommand.Name) - CorrelationId: $correlationId" $catalog = Get-KrbEtypeCatalog # Validate the RAW target before decoding it. ConvertFrom-KrbEtype treats a zero # bitmask as "inherit the domain default", which is the correct reading for an # account attribute and the wrong one for a target: a caller asking what breaks if # everything moves to 0 is asking about no ciphers, not about 0x27. Checking the # cipher bits on the raw value keeps that distinction where it belongs. if (($TargetEncryptionTypes -band 0xFF) -eq 0) { $message = 'TargetEncryptionTypes 0x{0:X} enables no encryption type at all. Every ' + 'principal would be reported as broken, which is true and useless.' Write-Error ($message -f $TargetEncryptionTypes) -ErrorAction Stop return } $target = ConvertFrom-KrbEtype -SupportedEncryptionTypes $TargetEncryptionTypes if (@($target.TicketEtypes).Count -eq 0) { # Reachable with a target of 0x20 alone, which authorises AES256 for the session # key and nothing for the ticket. It passes the cipher-bit check above and would # then mark every principal in the domain as broken. $message = 'TargetEncryptionTypes 0x{0:X} authorises no ticket encryption type. ' + 'Bit 0x20 permits an AES256 session key only and cannot encrypt a ticket.' Write-Error ($message -f $TargetEncryptionTypes) -ErrorAction Stop return } # Survival is judged by cipher FAMILY, not by exact etype membership in the target. # # The obvious implementation - "is the observed etype in $target.TicketEtypes" - is # wrong in the one direction that matters. The default target 0x18 authorises etypes # 17 and 18, the AES-SHA1 pair. It does not include 19 and 20, the RFC 8009 AES-SHA2 # types that Windows Server 2025 issues. Under exact-membership a client presenting # AES256-CTS-HMAC-SHA384-192 - the strongest thing Windows currently produces - is # reported as using "an encryption type the change removes", which reads out as a # Critical finding advising someone to confirm their client supports AES. It already # does; it supports more AES than the target names. # # What actually happens when such an account is narrowed to 0x18 is that the KDC # negotiates down from AES-SHA2 to AES-SHA1. The principal holds AES keys and keeps # working. That is not breakage and must not be reported as breakage - a tool that # raises Criticals against the most modern estate in the domain is one nobody # believes the second time. # # Comparing families gets every case right: RC4 and DES are lost when the target # drops them, AES survives whichever AES variant was observed. $targetFamilies = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) foreach ($etype in $target.TicketEtypes) { $entry = $catalog.TicketEtype[$etype] if ($entry) { [void]$targetFamilies.Add($entry.Family) } } # An etype the catalog does not recognise is treated as SURVIVING. The module cannot # know that an unfamiliar algorithm is weak, and condemning it would repeat the # mistake this whole comparison exists to avoid. It is surfaced as unrecognised # elsewhere, which is the honest place for it. $survivesTarget = { param([int]$Etype) $entry = $catalog.TicketEtype[$Etype] if (-not $entry) { return $true } return $targetFamilies.Contains($entry.Family) } Write-Verbose ("Assessing against target $($target.EffectiveHex): " + "$($target.CipherNames -join ', ') - surviving families: " + "$(@($targetFamilies) -join ', ')") if (-not $Offline -and -not $DomainContext) { try { $DomainContext = Get-KrbDomainEtypeContext -ErrorAction Stop } catch { Write-Warning ("Could not collect the domain baseline, continuing with the Windows " + "default of 0x27. Accounts with an unset encryption type attribute may be " + "misjudged. Error: $($_.Exception.Message)") } } $domainDefault = if ($DomainContext) { $DomainContext.DomainDefaultEncryptionTypes } else { $catalog.DefaultDomainSupportedEncTypes } # Directory index. One enumeration, two lookup tables - by account name and by SPN. # Resolving service names one directory query at a time would issue a query per # distinct SPN in the log, which in a domain of any size is tens of thousands of # round trips for data that fits comfortably in memory. $byAccount = [System.Collections.Generic.Dictionary[string, object]]::new( [StringComparer]::OrdinalIgnoreCase) $bySpn = [System.Collections.Generic.Dictionary[string, object]]::new( [StringComparer]::OrdinalIgnoreCase) if (-not $Offline) { if (-not $Principal) { try { Write-Verbose 'Enumerating directory principals for correlation' $Principal = @(Get-KrbPrincipalEtype -All -IncludeDisabled ` -DomainDefaultEncryptionTypes $domainDefault -ErrorAction Stop) } catch { Write-Warning ('Could not enumerate directory principals, continuing with event ' + "evidence only. Error: $($_.Exception.Message)") $Principal = @() } } foreach ($item in $Principal) { if ($item.SamAccountName) { $byAccount[$item.SamAccountName] = $item } foreach ($spn in @($item.ServicePrincipalNames)) { if ($spn) { $bySpn[$spn] = $item } } } Write-Verbose "Indexed $($byAccount.Count) account(s) and $($bySpn.Count) SPN(s)" } # Trust names, used to confirm a cross-realm referral authoritatively rather than by # name shape. Empty when running offline, in which case the decoder's shape test is # the only signal available. $trustNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) if ($DomainContext) { foreach ($t in @($DomainContext.Trusts)) { if ($t.Name) { [void]$trustNames.Add($t.Name) } if ($t.Target) { [void]$trustNames.Add($t.Target) } } } # Referral traffic, keyed by remote realm. Feeds the trust findings rather than # producing service rows of its own. $referralEvidence = [System.Collections.Generic.Dictionary[string, object]]::new( [StringComparer]::OrdinalIgnoreCase) $buffer = [System.Collections.Generic.List[object]]::new() } process { foreach ($item in $KerberosEvent) { if ($item) { $buffer.Add($item) } } } end { if ($buffer.Count -eq 0) { Write-Warning ('No events were supplied, so no assessment was produced. An empty ' + 'result here means nothing was measured, not that nothing is at risk.') return } Write-Verbose "Correlating $($buffer.Count) event(s) - CorrelationId: $correlationId" # Pass 1: index what each CLIENT account advertised. This is the table that turns a # service-level finding into a named list of machines, and it has to exist before any # service is assessed - which is why the correlation is two passes and not one. $clientCapability = [System.Collections.Generic.Dictionary[string, object]]::new( [StringComparer]::OrdinalIgnoreCase) foreach ($item in $buffer) { if (-not $item.ClientAccount) { continue } $entry = $null if (-not $clientCapability.TryGetValue($item.ClientAccount, [ref]$entry)) { $entry = [PSCustomObject]@{ Account = $item.ClientAccount AdvertisedAes = $null AdvertisedAny = $false AdvertisedNames = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) Addresses = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) LastSeen = $item.TimeCreated # Evidence counts, so a finding can state what it saw rather than only # asserting a boolean. See the KRB005 emission for why that matters. AesAdvertisements = 0 NoAesAdvertisements = 0 IgnoredTgtRenewals = 0 } $clientCapability[$item.ClientAccount] = $entry } if ($item.IpAddress) { [void]$entry.Addresses.Add($item.IpAddress) } if ($item.TimeCreated -gt $entry.LastSeen) { $entry.LastSeen = $item.TimeCreated } # A service ticket request whose service IS krbtgt is a TGT renewal or a request # for a forwarded TGT, not the client asking for a service. Its advertised # encryption types are NOT reliable evidence of what the client can do, and # trusting them silently disabled KRB005 for every domain-joined Windows machine. # # 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. # # Because the union below treats a single AES advertisement as proof of # capability, that one boot-time request marked every rebooted Windows client as # AES-capable. With a 30-day default window nearly every client boots at least # once, so the named-client list that is this module's whole reason to exist # could not be produced for the population it exists to describe. # # Note 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 RC4 evidence discarded here is redundant with the AS-REQs. $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 } if ($null -ne $item.ClientAdvertizedSupportsAes) { $entry.AdvertisedAny = $true foreach ($name in @($item.ClientAdvertizedNames)) { if ($name) { [void]$entry.AdvertisedNames.Add($name) } } if ($item.ClientAdvertizedSupportsAes) { $entry.AesAdvertisements++ } elseif ($item.ClientAdvertizedLegacyOnly) { $entry.NoAesAdvertisements++ } # Three states, not two. AdvertisedAes stays $null until the evidence settles # it either way, because "we could not tell" and "it cannot do AES" lead to # opposite findings. # # A client that has EVER advertised AES can do AES. Taking the union rather # than the latest observation matters because a single request made through a # constrained path would otherwise mark a healthy client as legacy-only. That # reasoning is still right - the filter above removes the evidence that was # wrong, rather than weakening the rule that was correct. # # The negative is taken from LegacyOnly rather than from SupportsAes being # false. SupportsAes is false whenever no AES name was RECOGNISED, which # includes a client advertising only algorithms newer than this catalog. # LegacyOnly is false in that case and true only when every advertised name # was understood and none was AES - which is the claim a Critical finding # needs behind it. if ($item.ClientAdvertizedSupportsAes) { $entry.AdvertisedAes = $true } elseif ($item.ClientAdvertizedLegacyOnly -and $null -eq $entry.AdvertisedAes) { $entry.AdvertisedAes = $false } } } Write-Verbose "Indexed advertisement evidence for $($clientCapability.Count) client account(s)" # Pass 2: bucket events by the principal they say something about. $observation = [System.Collections.Generic.Dictionary[string, object]]::new( [StringComparer]::OrdinalIgnoreCase) $newObservation = { param([string]$key, [string]$displayName, [string]$role) [PSCustomObject]@{ Key = $key Name = $displayName Roles = [System.Collections.Generic.HashSet[string]]::new() ServiceNames = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) TicketEtypes = [System.Collections.Generic.HashSet[int]]::new() SessionKeyEtypes = [System.Collections.Generic.HashSet[int]]::new() PreAuthEtypes = [System.Collections.Generic.HashSet[int]]::new() AvailableKeys = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) AvailableKeysKnown = $false EventSupportedEtypes = [System.Collections.Generic.HashSet[int]]::new() Clients = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) ClientAddresses = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) RequestCount = 0 FailureCount = 0 EtypeFailures = [System.Collections.Generic.List[object]]::new() FirstSeen = $null LastSeen = $null SawV2 = $false SawLegacySchema = $false Sources = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) InitialRole = $role } } $touch = { param($entry, $item) $entry.RequestCount++ if ($item.IsFailure) { $entry.FailureCount++ } if ($item.StatusIsEtypeRelated -and $item.IsFailure) { $entry.EtypeFailures.Add($item) } if ($item.Source) { [void]$entry.Sources.Add($item.Source) } if ($item.HasEtypeDetail) { $entry.SawV2 = $true } else { $entry.SawLegacySchema = $true } if ($null -eq $entry.FirstSeen -or $item.TimeCreated -lt $entry.FirstSeen) { $entry.FirstSeen = $item.TimeCreated } if ($null -eq $entry.LastSeen -or $item.TimeCreated -gt $entry.LastSeen) { $entry.LastSeen = $item.TimeCreated } } foreach ($item in $buffer) { # SERVICE role, from 4769 only. A 4768 is always a request against krbtgt, so # treating its ServiceName as a service under assessment would put krbtgt at the # top of every report and say nothing about the services that matter. # # Excluding 4768 is not sufficient on its own, which is what the first version of # this got wrong. A TGT renewal is a 4769 whose ServiceName is the bare string # 'krbtgt', so krbtgt reappeared as a service anyway - 46 requests on a quiet lab # domain, and proportionally more on a real one. krbtgt's encryption types are a # domain-wide property reported by Get-KrbDomainEtypeContext, not a per-service # finding, so the bare name is skipped here. # # 'krbtgt/REALM' is deliberately NOT skipped. That form is a cross-realm referral, # and its ticket encryption type is the best evidence available that a trust is # still forcing RC4 - which is the finding trusts exist in this module for. $isLocalKrbtgt = $item.ServiceName -and $item.ServiceName.Trim().Equals('krbtgt', [StringComparison]::OrdinalIgnoreCase) # Cross-realm referrals are evidence about a TRUST, not about a service principal. # The decoder marks them by shape; here that is confirmed against the real trust # names when a domain context is available, which is authoritative. Left as an # ordinary service row a referral produces a phantom principal named after the # remote realm, duplicating the KRB014 the trust already earns - and the etype on # it is the most direct evidence available that the trust is forcing RC4, so it # belongs on that finding rather than beside it. $isReferral = $false if ($item.EventId -eq 4769 -and $item.ServiceName) { if ($trustNames.Count -gt 0) { $isReferral = $trustNames.Contains($item.ServiceName.Trim()) } else { $isReferral = [bool]$item.IsProbableRealmName } } if ($isReferral) { $realm = $item.ServiceName.Trim() $seen = $null if (-not $referralEvidence.TryGetValue($realm, [ref]$seen)) { $seen = [PSCustomObject]@{ Realm = $realm Etypes = [System.Collections.Generic.SortedSet[int]]::new() Count = 0 Failures = 0 } $referralEvidence[$realm] = $seen } $seen.Count++ if ($item.IsFailure) { $seen.Failures++ } if ($null -ne $item.TicketEtype -and $item.TicketEtype -ne -1) { [void]$seen.Etypes.Add($item.TicketEtype) } } if ($item.EventId -eq 4769 -and $item.ServiceName -and -not $isLocalKrbtgt -and -not $isReferral) { $serviceName = $item.ServiceName $resolved = $null if (-not $Offline) { if (-not $bySpn.TryGetValue($serviceName, [ref]$resolved)) { # The KDC writes ServiceName as a bare account name as often as a # full SPN, so both lookups are needed before concluding it is # unresolvable. [void]$byAccount.TryGetValue($serviceName, [ref]$resolved) } } $key = if ($resolved) { $resolved.SamAccountName } else { $serviceName } $entry = $null if (-not $observation.TryGetValue($key, [ref]$entry)) { $entry = & $newObservation $key $key 'Service' $observation[$key] = $entry } [void]$entry.Roles.Add('Service') [void]$entry.ServiceNames.Add($serviceName) & $touch $entry $item if ($null -ne $item.TicketEtype -and $item.TicketEtype -ne -1) { [void]$entry.TicketEtypes.Add($item.TicketEtype) } if ($null -ne $item.SessionKeyEtype -and $item.SessionKeyEtype -ne -1) { [void]$entry.SessionKeyEtypes.Add($item.SessionKeyEtype) } if ($null -ne $item.ServiceSupportedEtypes) { [void]$entry.EventSupportedEtypes.Add($item.ServiceSupportedEtypes) } if ($null -ne $item.ServiceAvailableKeys) { $entry.AvailableKeysKnown = $true foreach ($k in $item.ServiceAvailableKeys) { [void]$entry.AvailableKeys.Add($k) } } if ($item.ClientAccount) { [void]$entry.Clients.Add($item.ClientAccount) } if ($item.IpAddress) { [void]$entry.ClientAddresses.Add($item.IpAddress) } } # CLIENT role, from 4768 and 4771. This is where advertisement and # pre-authentication evidence lives. if ($item.EventId -in 4768, 4771 -and $item.ClientAccount) { $key = $item.ClientAccount $entry = $null if (-not $observation.TryGetValue($key, [ref]$entry)) { $entry = & $newObservation $key $key 'Client' $observation[$key] = $entry } [void]$entry.Roles.Add('Client') & $touch $entry $item if ($null -ne $item.PreAuthEtype -and $item.PreAuthEtype -ne -1) { [void]$entry.PreAuthEtypes.Add($item.PreAuthEtype) } if ($null -ne $item.SessionKeyEtype -and $item.SessionKeyEtype -ne -1) { [void]$entry.SessionKeyEtypes.Add($item.SessionKeyEtype) } if ($null -ne $item.AccountSupportedEtypes) { [void]$entry.EventSupportedEtypes.Add($item.AccountSupportedEtypes) } if ($null -ne $item.AccountAvailableKeys) { $entry.AvailableKeysKnown = $true foreach ($k in $item.AccountAvailableKeys) { [void]$entry.AvailableKeys.Add($k) } } if ($item.IpAddress) { [void]$entry.ClientAddresses.Add($item.IpAddress) } } } Write-Verbose "Built observations for $($observation.Count) principal(s)" $targetSupportsAes = $target.SupportsAes $emitted = 0 foreach ($key in $observation.Keys) { $obs = $observation[$key] $findings = [System.Collections.Generic.List[object]]::new() $confidenceNotes = [System.Collections.Generic.List[string]]::new() $adPrincipal = $null if (-not $Offline) { [void]$byAccount.TryGetValue($obs.Key, [ref]$adPrincipal) } $isService = $obs.Roles.Contains('Service') $isClient = $obs.Roles.Contains('Client') # Which clients of this service cannot do AES, by name. The correlation the whole # module is built to make. $clientsWithoutAes = [System.Collections.Generic.List[string]]::new() $clientsUnknown = [System.Collections.Generic.List[string]]::new() # Every client lands in exactly one of three buckets: confirmed AES-capable (no # list), confirmed incapable, or unknown. The earlier shape tested AdvertisedAny # first and then AdvertisedAes, which silently dropped clients whose capability # was recorded but indeterminate - they appeared in neither list, so the report # neither warned about them nor counted them as unmeasured. foreach ($clientName in $obs.Clients) { $cap = $null $known = $clientCapability.TryGetValue($clientName, [ref]$cap) if ($known -and $cap.AdvertisedAes -eq $true) { continue } if ($known -and $cap.AdvertisedAes -eq $false) { $clientsWithoutAes.Add($clientName) } else { $clientsUnknown.Add($clientName) } } # ---- Schema confidence ------------------------------------------------------- if (-not $obs.SawV2) { $confidenceNotes.Add('All events for this principal used the pre-KB5021131 schema. ' + 'Available-key and client-advertisement evidence was unavailable, so findings ' + 'rest on issued ticket encryption types alone.') } if ($obs.SawV2 -and $obs.SawLegacySchema) { $confidenceNotes.Add('Events came from controllers running different audit schema ' + 'versions. Coverage of the detailed fields is partial.') } if ($Offline) { $confidenceNotes.Add('Assessed offline. Directory configuration was not consulted; ' + 'userAccountControl overrides such as USE_DES_KEY_ONLY cannot be detected.') } # ---- KRB011: already failing ------------------------------------------------- if ($obs.EtypeFailures.Count -gt 0) { $statusNames = @($obs.EtypeFailures.StatusName | Sort-Object -Unique) $findings.Add((New-KrbRiskFinding -Code 'KRB011' -Severity 'Critical' ` -Title 'Encryption-type-related authentication failures are already occurring' ` -Detail ("$($obs.EtypeFailures.Count) of $($obs.RequestCount) requests failed with " + "$($statusNames -join ', '). This is not a prediction - it is breakage " + 'already happening, most likely being absorbed by client retries or a ' + 'fallback path. Hardening will remove the fallback.') ` -RecommendedAction 'Investigate before making any further encryption type change.' ` -Evidence @{ FailureCount = $obs.EtypeFailures.Count RequestCount = $obs.RequestCount StatusNames = $statusNames SampleTimes = @($obs.EtypeFailures | Select-Object -First 5 -ExpandProperty TimeCreated) })) } # ---- Service-role findings --------------------------------------------------- if ($isService) { $observedTickets = @($obs.TicketEtypes) $survivingTickets = @($observedTickets | Where-Object { & $survivesTarget $_ }) # 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' ` -Detail ('The KDC reported this principal''s available keys as ' + "$(@($obs.AvailableKeys) -join ', ') with no AES key present. " + 'Kerberos keys are derived when the password is set, so applying an ' + 'AES-only configuration gives the KDC nothing to encrypt with and ' + 'authentication fails with KDC_ERR_NULL_KEY. Changing the attribute ' + 'is not sufficient - the password must be reset first.') ` -RecommendedAction ('Reset the account password to derive AES keys, confirm ' + 'AES appears in AvailableKeys on subsequent events, and only then ' + 'change msDS-SupportedEncryptionTypes.') ` -Evidence @{ AvailableKeys = @($obs.AvailableKeys) RequestCount = $obs.RequestCount PasswordAgeDays = if ($adPrincipal) { $adPrincipal.PasswordAgeDays } else { $null } })) } } # KRB001 - every ticket this service has ever been issued uses an etype the # target removes. if ($observedTickets.Count -gt 0 -and $survivingTickets.Count -eq 0) { $names = @(foreach ($e in $observedTickets) { (ConvertFrom-KrbEtype -TicketEtype $e).DisplayName }) $findings.Add((New-KrbRiskFinding -Code 'KRB001' -Severity 'Critical' ` -Title 'Every observed service ticket used an encryption type the change removes' ` -Detail ("All $($obs.RequestCount) service ticket requests in the collection " + "window were issued under $($names -join ', '), none of which survive the " + "target of $($target.EffectiveHex). This service has no demonstrated " + 'ability to operate under the proposed configuration.') ` -RecommendedAction ('Verify AES key material exists, then move the account to ' + 'RC4+AES (0x1C) first and confirm AES tickets appear before removing RC4.') ` -Evidence @{ ObservedEtypes = $names RequestCount = $obs.RequestCount FirstSeen = $obs.FirstSeen LastSeen = $obs.LastSeen TargetEtypes = $target.CipherNames })) } elseif ($observedTickets.Count -gt 0 -and $survivingTickets.Count -lt $observedTickets.Count) { $lost = @($observedTickets | Where-Object { -not (& $survivesTarget $_) }) $lostNames = @(foreach ($e in $lost) { (ConvertFrom-KrbEtype -TicketEtype $e).DisplayName }) $keptNames = @(foreach ($e in $survivingTickets) { (ConvertFrom-KrbEtype -TicketEtype $e).DisplayName }) $findings.Add((New-KrbRiskFinding -Code 'KRB010' -Severity 'Medium' ` -Title 'Service uses a mix of surviving and removed encryption types' ` -Detail ("Tickets were issued under both $($keptNames -join ', '), which " + "survive the change, and $($lostNames -join ', '), which do not. The " + 'service can clearly do AES, but something in its client population or ' + 'its own negotiation is still selecting the weaker type.') ` -RecommendedAction ('Identify which clients drove the removed types before ' + 'proceeding; they are listed in ClientsWithoutAesSupport where advertisement ' + 'evidence exists.') ` -Evidence @{ Surviving = $keptNames Removed = $lostNames RequestCount = $obs.RequestCount })) } # KRB005 - the named client list. Reported on the SERVICE because that is the # object whose owner has to be told. if ($clientsWithoutAes.Count -gt 0) { # State the evidence rather than only the conclusion. A reader who has to # tell an application owner their appliance is about to break deserves the # count behind the claim, and a count survives a mechanism nobody has # characterised yet - a bare boolean does not. $noAesTotal = 0 $ignoredTotal = 0 foreach ($name in $clientsWithoutAes) { $capEntry = $null if ($clientCapability.TryGetValue($name, [ref]$capEntry)) { $noAesTotal += $capEntry.NoAesAdvertisements $ignoredTotal += $capEntry.IgnoredTgtRenewals } } $evidenceLine = "$noAesTotal qualifying request(s) across these clients " + 'advertised no AES and none advertised any.' if ($ignoredTotal -gt 0) { $evidenceLine += " A further $ignoredTotal TGT renewal or forwarded-TGT " + 'request was excluded, because those carry the encryption types their ' + 'caller asked for rather than the ones the client supports.' } $findings.Add((New-KrbRiskFinding -Code 'KRB005' -Severity 'Critical' ` -Title "$($clientsWithoutAes.Count) client(s) of this service advertise no AES support" ` -Detail ('These client accounts were observed requesting tickets for this ' + 'service, and their own TGT requests advertised no AES encryption type at ' + 'all. They are not merely receiving RC4 - they are incapable of anything ' + "else, and will fail outright once the KDC stops offering it. $evidenceLine") ` -RecommendedAction ('Patch or replace the listed clients before hardening. If ' + 'any is an appliance or a third-party product, its vendor needs a support ' + 'statement, and that is usually the longest lead time in the project.') ` -Evidence @{ Clients = @($clientsWithoutAes) ClientCount = $clientsWithoutAes.Count TotalClients = $obs.Clients.Count NoAesAdvertisements = $noAesTotal ExcludedTgtServiceEvents = $ignoredTotal })) } if ($clientsUnknown.Count -gt 0 -and $obs.Clients.Count -gt 0) { $confidenceNotes.Add("$($clientsUnknown.Count) of $($obs.Clients.Count) clients had " + 'no TGT request in the collection window, so their encryption type capability ' + 'is unknown rather than confirmed.') } } # ---- Client-role findings ---------------------------------------------------- if ($isClient) { $cap = $null if ($clientCapability.TryGetValue($obs.Key, [ref]$cap) -and $cap.AdvertisedAny -and $cap.AdvertisedAes -eq $false) { $findings.Add((New-KrbRiskFinding -Code 'KRB005' -Severity 'Critical' ` -Title 'This client advertises no AES encryption type' ` -Detail ('Across every TGT request in the window, this account offered only ' + "$(@($cap.AdvertisedNames) -join ', '). A KDC that no longer offers RC4 " + 'has nothing in common with it and authentication fails at the first step.') ` -RecommendedAction ('Identify the operating system or Kerberos implementation ' + 'behind the listed addresses and update it. This cannot be fixed from the ' + 'directory side.') ` -Evidence @{ AdvertisedNames = @($cap.AdvertisedNames) Addresses = @($cap.Addresses) LastSeen = $cap.LastSeen })) } $preAuth = @($obs.PreAuthEtypes) $preAuthLost = @($preAuth | Where-Object { -not (& $survivesTarget $_) }) if ($preAuth.Count -gt 0 -and $preAuthLost.Count -eq $preAuth.Count) { $names = @(foreach ($e in $preAuth) { (ConvertFrom-KrbEtype -TicketEtype $e).DisplayName }) $findings.Add((New-KrbRiskFinding -Code 'KRB008' -Severity 'High' ` -Title 'Pre-authentication has only ever used an encryption type the change removes' ` -Detail ("Every pre-authentication exchange used $($names -join ', '). " + 'Pre-authentication is the first cryptographic step of a logon, so this ' + 'account fails before it obtains a TGT, not at some later service.') ` -RecommendedAction 'Confirm the client platform supports AES pre-authentication.' ` -Evidence @{ PreAuthEtypes = $names; RequestCount = $obs.RequestCount })) } } # ---- Configuration findings, directory-backed -------------------------------- if ($adPrincipal) { if ($adPrincipal.AccountControl.UseDesKeyOnly) { $findings.Add((New-KrbRiskFinding -Code 'KRB004' -Severity 'Critical' ` -Title 'USE_DES_KEY_ONLY is set in userAccountControl' ` -Detail ('This bit confines the account to single DES and overrides ' + 'msDS-SupportedEncryptionTypes entirely, so the encryption type attribute ' + 'on this account is not being honoured. DES has been refused by default ' + 'since Windows Server 2008 R2.') ` -RecommendedAction ('Clear USE_DES_KEY_ONLY and reset the password. Clearing ' + 'the bit alone leaves the account with no non-DES key material.') ` -Evidence @{ UserAccountControl = $adPrincipal.AccountControl.Value })) } $configured = $adPrincipal.EncryptionTypes if ($configured.SupportsRc4 -and -not $configured.SupportsAes -and -not $configured.IsUnset -and $configured.Value -ne 0) { $findings.Add((New-KrbRiskFinding -Code 'KRB003' -Severity 'High' ` -Title 'msDS-SupportedEncryptionTypes is explicitly set to exclude AES' ` -Detail ("The attribute is set to $($configured.Hex), which somebody chose " + 'deliberately. Whatever compatibility reason produced that value needs to ' + 'be understood before it is reversed - it is frequently a note in a ' + 'runbook nobody has read since.') ` -RecommendedAction 'Find the original justification, then widen to 0x1C and verify.' ` -Evidence @{ ConfiguredValue = $configured.Hex CipherNames = $configured.CipherNames })) } if ($adPrincipal.AccountControl.TrustedForDelegation -or $adPrincipal.AccountControl.TrustedToAuthForDelegation) { $findings.Add((New-KrbRiskFinding -Code 'KRB013' -Severity 'Medium' ` -Title 'Delegation is enabled, which widens the blast radius' ` -Detail ('This account is trusted for delegation, so an encryption type ' + 'failure here does not stop at this service - it propagates to every ' + 'backend the service reaches on a user''s behalf. Its true impact is ' + 'larger than its own client count suggests.') ` -RecommendedAction ("Treat this principal as a higher priority " + 'than its score alone implies.') ` -Evidence @{ TrustedForDelegation = $adPrincipal.AccountControl.TrustedForDelegation TrustedToAuthForDelegation = $adPrincipal.AccountControl.TrustedToAuthForDelegation })) } # KRB006 - the inference used when available-key evidence is absent. Kept # separate from KRB002 and rated lower precisely because it is an inference. if (-not $obs.AvailableKeysKnown -and $targetSupportsAes -and $obs.TicketEtypes.Count -gt 0 -and -not (@($obs.TicketEtypes) | Where-Object { $_ -in 17, 18, 19, 20 })) { $findings.Add((New-KrbRiskFinding -Code 'KRB006' -Severity 'High' ` -Title 'No AES ticket has been observed and key material cannot be confirmed' ` -Detail ('No AES ticket was issued for this principal in the window, and the ' + 'controllers are emitting an event schema too old to report which keys ' + 'exist. The account may hold AES keys and simply never have negotiated ' + "them, or may hold none at all. Its password was last set " + "$($adPrincipal.PasswordAgeDays) day(s) ago.") ` -RecommendedAction ('Install the November 2022 or later cumulative update on the ' + 'domain controllers to obtain version 2 audit events, or reset the ' + 'password to guarantee AES keys exist.') ` -Evidence @{ PasswordAgeDays = $adPrincipal.PasswordAgeDays PasswordLastSet = $adPrincipal.PasswordLastSet ObservedEtypes = @($obs.TicketEtypes) })) } } # ---- DES anywhere ------------------------------------------------------------ $desSeen = @(@($obs.TicketEtypes) + @($obs.SessionKeyEtypes) + @($obs.PreAuthEtypes) | Where-Object { $_ -in 1, 2, 3, 5, 7, 16 }) if ($desSeen.Count -gt 0) { $findings.Add((New-KrbRiskFinding -Code 'KRB007' -Severity 'Critical' ` -Title 'Single DES encryption was observed in use' ` -Detail ('DES has been cryptographically broken for two decades and disabled by ' + 'default since Windows Server 2008 R2. Its presence here means the domain is ' + 'carrying an explicit allowance that any hardening will remove.') ` -RecommendedAction 'Treat as an immediate security finding, independent of the RC4 project.' ` -Evidence @{ ObservedEtypes = @($desSeen | Sort-Object -Unique) })) } # ---- Nothing found: healthy, or merely unexamined? --------------------------- # # These two outcomes look identical in a naive engine and mean opposite things. # A principal assessed against version 2 events and directory configuration, with # nothing wrong, is evidence of safety. A principal seen only through the legacy # schema, with no directory record, is the absence of evidence - the fields that # would have revealed a problem were never written. Reporting the second as "no # impact predicted" is how an assessment gives false assurance about exactly the # principals it understood least. if ($findings.Count -eq 0) { $hasUsableEvidence = $obs.SawV2 -or $adPrincipal if ($hasUsableEvidence) { $findings.Add((New-KrbRiskFinding -Code 'KRB009' -Severity 'Info' ` -Title 'No impact predicted from the proposed change' ` -Detail ("All $($obs.RequestCount) observed request(s) used encryption types that " + 'survive the target configuration, and no configuration override was found.') ` -Evidence @{ RequestCount = $obs.RequestCount ObservedEtypes = @($obs.TicketEtypes) AvailableKeys = if ($obs.AvailableKeysKnown) { @($obs.AvailableKeys) } else { $null } })) } else { $findings.Add((New-KrbRiskFinding -Code 'KRB015' -Severity 'Low' ` -Title 'Insufficient evidence to assess this principal' ` -Detail ("This principal was seen $($obs.RequestCount) time(s), but only through " + 'the pre-KB5021131 audit schema, and it could not be matched to a directory ' + 'object. Neither the keys it holds nor the encryption types it can negotiate ' + 'were recorded anywhere. Nothing was found wrong with it, and nothing would ' + 'have been found wrong with it had it been broken.') ` -RecommendedAction ('Update the domain controllers to obtain version 2 audit ' + 'events, or re-run with directory access, before treating this principal as safe.') ` -Evidence @{ RequestCount = $obs.RequestCount ObservedEtypes = @($obs.TicketEtypes) Sources = @($obs.Sources) })) } } $summary = Measure-KrbRiskLevel -Finding $findings.ToArray() -ClientCount $obs.Clients.Count if (-not $IncludeHealthy -and $summary.Level -in 'Info', 'None') { continue } $emitted++ New-KrbRiskObject -PrincipalName $obs.Key -Finding $findings.ToArray() ` -Summary $summary -Role @($obs.Roles) -Observation $obs ` -Principal $adPrincipal -TargetEncryptionTypes $target ` -ClientsWithoutAesSupport @($clientsWithoutAes) ` -ClientsWithUnknownSupport @($clientsUnknown) ` -ConfidenceNote @($confidenceNotes) -CorrelationId $correlationId } # ---- Principals with dangerous configuration and no traffic in the window -------- # # The engine above is observational, and an observational engine is blind to an # account that simply did not authenticate during the collection. That blindness is # dangerous in exactly the wrong direction: a DES-only account used by a quarterly # batch job produces no events in a thirty-day window and would be reported as # absent rather than as the blocker it is. Only configurations that are unsafe on # their face are reported this way, so this pass adds a handful of rows to a domain # of thousands rather than an inventory of everything that was quiet. if (-not $Offline -and $byAccount.Count -gt 0) { foreach ($name in $byAccount.Keys) { if ($observation.ContainsKey($name)) { continue } $item = $byAccount[$name] if ($item.Enabled -eq $false) { continue } $configured = $item.EncryptionTypes $unobservedFindings = [System.Collections.Generic.List[object]]::new() if ($item.AccountControl.UseDesKeyOnly) { $unobservedFindings.Add((New-KrbRiskFinding -Code 'KRB004' -Severity 'Critical' ` -Title 'USE_DES_KEY_ONLY is set on an account with no traffic in the window' ` -Detail ('The account is confined to single DES by userAccountControl and did ' + 'not authenticate during the collection window. Absence of traffic is not ' + 'evidence of disuse - a quarterly or annual job produces none in thirty days.') ` -RecommendedAction ('Establish what uses this account before hardening. Clear ' + 'USE_DES_KEY_ONLY and reset the password.') ` -Evidence @{ UserAccountControl = $item.AccountControl.Value LastLogonTimestamp = $item.LastLogonTimestamp })) } elseif ($configured.SupportsDes -and -not $configured.SupportsAes -and -not $configured.SupportsRc4 -and -not $configured.IsUnset) { $unobservedFindings.Add((New-KrbRiskFinding -Code 'KRB007' -Severity 'Critical' ` -Title 'Account is configured for DES only and has no traffic in the window' ` -Detail ("msDS-SupportedEncryptionTypes is $($configured.Hex), which permits " + 'only single DES. DES has been refused by default since Windows Server ' + '2008 R2, so this account is either already failing or is being carried by ' + 'an explicit domain-wide DES allowance that hardening removes.') ` -RecommendedAction 'Identify the consumer, move to AES, and reset the password.' ` -Evidence @{ ConfiguredValue = $configured.Hex CipherNames = $configured.CipherNames LastLogonTimestamp = $item.LastLogonTimestamp })) } elseif (-not $configured.IsUnset -and $configured.Value -ne 0 -and @($configured.TicketEtypes).Count -eq 0) { # The account is configured for no usable ticket encryption type at all. # Reached most readily by setting only the RFC 8009 SHA-2 bits, which # Windows does not honour - a Server 2025 KDC returned # KDC_ERR_ETYPE_NOTSUPP for exactly this configuration - but any value # whose bits authorise nothing lands here. # # This branch was missing until a regression test written from that lab # result went looking for it. The three conditions above it test for DES, # for USE_DES_KEY_ONLY and for RC4-without-AES, and an account that # supports nothing matches none of them, so it produced no row at all. $unobservedFindings.Add((New-KrbRiskFinding -Code 'KRB007' -Severity 'Critical' ` -Title 'Account is configured for no usable encryption type' ` -Detail ("msDS-SupportedEncryptionTypes is $($configured.Hex), which " + 'authorises no ticket encryption type the KDC will issue. Bits 0x40 ' + 'and 0x80 are the usual cause: MS-KILE documents them as the RFC 8009 ' + 'SHA-2 types, but Windows does not honour them, so an account carrying ' + 'only those bits cannot obtain a service ticket at all.') ` -RecommendedAction ('Add AES128 (0x08) and AES256 (0x10) - the SHA1 types - ' + 'which are what Windows actually issues.') ` -Evidence @{ ConfiguredValue = $configured.Hex CipherNames = $configured.CipherNames CarriesUnhonouredSha2Bits = $configured.CarriesUnhonouredSha2Bits LastLogonTimestamp = $item.LastLogonTimestamp })) } elseif ($configured.SupportsRc4 -and -not $configured.SupportsAes -and -not $configured.IsUnset -and $configured.Value -ne 0) { $unobservedFindings.Add((New-KrbRiskFinding -Code 'KRB012' -Severity 'Medium' ` -Title 'Account is explicitly configured without AES and has no traffic in the window' ` -Detail ("msDS-SupportedEncryptionTypes is $($configured.Hex), set deliberately " + 'to exclude AES, and no authentication was observed. There is no evidence ' + 'either way about whether hardening breaks it - which is a different ' + 'statement from evidence that it is safe.') ` -RecommendedAction ('Extend the collection window, or check lastLogonTimestamp ' + 'and the owning application''s schedule, before assuming this account is dormant.') ` -Evidence @{ ConfiguredValue = $configured.Hex LastLogonTimestamp = $item.LastLogonTimestamp PasswordAgeDays = $item.PasswordAgeDays })) } if ($unobservedFindings.Count -eq 0) { continue } $unobservedSummary = Measure-KrbRiskLevel -Finding $unobservedFindings.ToArray() $emitted++ New-KrbRiskObject -PrincipalName $item.SamAccountName ` -Finding $unobservedFindings.ToArray() -Summary $unobservedSummary ` -Role @('Unobserved') -Principal $item ` -ConfiguredEncryptionTypes $configured -TargetEncryptionTypes $target ` -ConfidenceNote @( 'No authentication by this principal was observed in the collection ' + 'window. The finding rests entirely on directory configuration.' ) -CorrelationId $correlationId } } # ---- Domain-scope findings, emitted alongside the per-principal ones ------------- if ($DomainContext) { foreach ($trust in @($DomainContext.TrustsWithRc4Only)) { # Evidence is assembled before the finding is built rather than inside the # -Evidence argument. An inline script block there parses badly enough to # break the whole module load, and this reads better anyway. $trustEvidence = @{ Trust = $trust.Name Direction = "$($trust.Direction)" TrustType = "$($trust.TrustType)" Value = $trust.EncryptionTypes.EffectiveHex } # Observed referral traffic over this trust, where the collection contained # any. This is what turns KRB014 from a statement about configuration into an # observation: the etypes recorded here are what the KDC actually issued for # cross-realm requests, measured rather than inferred from the attribute. $observedReferral = $null foreach ($key in @($trust.Name, $trust.Target)) { if ($key -and $referralEvidence.TryGetValue($key, [ref]$observedReferral)) { break } } if ($observedReferral) { $trustEvidence['ObservedReferrals'] = $observedReferral.Count $trustEvidence['ObservedReferralFailures'] = $observedReferral.Failures $trustEvidence['ObservedReferralEtypes'] = @( foreach ($e in $observedReferral.Etypes) { (ConvertFrom-KrbEtype -TicketEtype $e).DisplayName }) } $trustFindings = @(New-KrbRiskFinding -Code 'KRB014' -Severity 'High' ` -Title 'Trust does not permit AES for cross-realm authentication' ` -Detail ("The trust to $($trust.Target) has msDS-SupportedEncryptionTypes of " + "$($trust.EncryptionTypes.EffectiveHex), which carries no AES bit. Every " + 'cross-realm ticket over this trust is pinned to RC4 regardless of what the ' + 'accounts on either side support. This is the usual reason a domain that has ' + '"finished" removing RC4 keeps issuing it.') ` -RecommendedAction ('Set AES support on the trustedDomain object on both sides. ' + 'This requires the other realm''s cooperation and is typically the longest ' + 'lead item in a hardening project.') ` -Evidence $trustEvidence) $trustSummary = Measure-KrbRiskLevel -Finding $trustFindings $emitted++ # KRB014 is added to the breaking set for this row only. A trust that permits # no AES does break cross-realm authentication, but the code is not in the # default breaking list because on a per-principal row it would be meaningless. New-KrbRiskObject -PrincipalName $trust.Name -Finding $trustFindings ` -Summary $trustSummary -Role @('Trust') ` -ConfiguredEncryptionTypes $trust.EncryptionTypes ` -TargetEncryptionTypes $target ` -BreakingCode @('KRB014') ` -ConfidenceNote @( 'Derived from directory configuration; no event evidence applies to trusts.' ) -CorrelationId $correlationId } } Write-Verbose ("Completed $($MyInvocation.MyCommand.Name) - emitted $emitted risk object(s) " + "from $($observation.Count) observed principal(s) - CorrelationId: $correlationId") } } |