Public/ConvertFrom-KrbEtype.ps1
|
#Requires -Version 7.4 function ConvertFrom-KrbEtype { <# .EXTERNALHELP KrbEtypeInsight-Help.xml .SYNOPSIS Decodes Kerberos encryption type values from any of the three forms Windows emits them in #> [CmdletBinding(DefaultParameterSetName = 'TicketEtype')] [OutputType('KrbEtypeInsight.TicketEtype', ParameterSetName = 'TicketEtype')] [OutputType('KrbEtypeInsight.EtypeFlags', ParameterSetName = 'SupportedEncryptionTypes')] [OutputType('KrbEtypeInsight.AdvertizedEtypeSet', ParameterSetName = 'AdvertizedEtype')] param( [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'TicketEtype')] [AllowNull()] [AllowEmptyString()] [object]$TicketEtype, [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'SupportedEncryptionTypes')] [AllowNull()] [AllowEmptyString()] [object]$SupportedEncryptionTypes, [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'AdvertizedEtype')] [AllowNull()] [AllowEmptyString()] [string[]]$AdvertizedEtype, [Parameter(ParameterSetName = 'SupportedEncryptionTypes')] [int]$DomainDefaultEncryptionTypes = 0x27 ) begin { $catalog = Get-KrbEtypeCatalog } process { switch ($PSCmdlet.ParameterSetName) { 'TicketEtype' { $value = ConvertTo-KrbInt32 -Value $TicketEtype if ($null -eq $value) { # An absent field is reported as absent. Returning nothing here would # silently shorten a pipeline and make a 4771 disappear from a count. [PSCustomObject]@{ PSTypeName = 'KrbEtypeInsight.TicketEtype' Value = $null Hex = $null DisplayName = 'Not present' Family = 'None' Strength = 'NotApplicable' Rfc = 'n/a' RemovedByHardening = $false IsRecognized = $false } return } $known = $catalog.TicketEtype[$value] if ($known) { [PSCustomObject]@{ PSTypeName = 'KrbEtypeInsight.TicketEtype' Value = $value Hex = '0x{0:X}' -f $value DisplayName = $known.DisplayName Family = $known.Family Strength = $known.Strength Rfc = $known.Rfc RemovedByHardening = $known.RemovedByHardening IsRecognized = $true } return } # An unrecognised value is surfaced, never swallowed. A new Windows release # adding an etype must show up as an explicit unknown in the report rather # than as a silently dropped row. Write-Verbose "Encryption type $value is not in the catalog" [PSCustomObject]@{ PSTypeName = 'KrbEtypeInsight.TicketEtype' Value = $value Hex = '0x{0:X}' -f $value DisplayName = "Unknown ($value)" Family = 'Unknown' Strength = 'Unknown' Rfc = 'Unknown' RemovedByHardening = $false IsRecognized = $false } } 'SupportedEncryptionTypes' { $raw = ConvertTo-KrbInt32 -Value $SupportedEncryptionTypes # Unset and zero are different states with the same effect, and the # distinction is worth keeping: an unset attribute is an account nobody has # touched, a zero is an account somebody explicitly zeroed. Both fall through # to the domain default, so both get the same effective value. $isUnset = $null -eq $raw $usesDefault = $isUnset -or $raw -eq 0 $effective = if ($usesDefault) { $DomainDefaultEncryptionTypes } else { $raw } $cipherNames = [System.Collections.Generic.List[string]]::new() $capabilityNames = [System.Collections.Generic.List[string]]::new() # SortedSet rather than a List piped through Sort-Object -Unique. The result # is identical and this decoder runs once per principal and once per event, # where a cmdlet pipeline is the difference between an assessment finishing # in two minutes and in twenty. $ticketEtypes = [System.Collections.Generic.SortedSet[int]]::new() $sessionKeyEtypes = [System.Collections.Generic.SortedSet[int]]::new() foreach ($bit in $catalog.SortedFlagKeys) { if (($effective -band $bit) -eq 0) { continue } $entry = $catalog.FlagToTicketEtype[$bit] if ($bit -band $catalog.CipherFlagMask) { $cipherNames.Add($entry.Name) } else { $capabilityNames.Add($entry.Name) } foreach ($etype in $entry.TicketEtypes) { [void]$ticketEtypes.Add($etype) } foreach ($etype in $entry.SessionKeyEtypes) { [void]$sessionKeyEtypes.Add($etype) } } # Bits Microsoft has added since this module was written. Preserved rather # than masked away, so an assessment run against a future Windows release # reports "I do not understand bit 0x100" instead of quietly under-reporting # what an account supports. $unknownBits = $effective -band (-bnot $catalog.KnownFlagMask) # 0x20 authorises AES256 for the session key only. An account holding 0x20 # and no other AES bit will issue AES session keys inside RC4 tickets, which # looks like AES support in a session-key-only view of the data and like no # AES support in a ticket view. Both properties are exposed so the caller # cannot accidentally pick the flattering one. # SHA1 AES bits only. The RFC 8009 bits 0x40 and 0x80 are excluded because a # real Server 2025 KDC was observed refusing to issue a ticket for an account # that carried only them - see Get-KrbEtypeCatalog. Counting them as AES # support reported an unauthenticatable account as safe. $aesTicketBits = 0x08 -bor 0x10 $sha2Bits = 0x40 -bor 0x80 [PSCustomObject]@{ PSTypeName = 'KrbEtypeInsight.EtypeFlags' Value = $raw Hex = if ($isUnset) { $null } else { '0x{0:X}' -f $raw } EffectiveValue = $effective EffectiveHex = '0x{0:X}' -f $effective IsUnset = $isUnset UsesDomainDefault = $usesDefault # Masked to known bits before the cast. Casting a value carrying an # undefined bit to a [Flags] enum is a terminating PSInvalidCastException # - "due to enumeration values that are not valid" - not a best-effort # decomposition. Unmasked, the first time Microsoft adds a bit to # msDS-SupportedEncryptionTypes this function would start throwing on any # account that had it, taking the whole assessment down rather than # reporting one unfamiliar bit. UnknownBits below carries what was masked # off, so nothing is lost. Flags = [KrbEtypeFlag]($effective -band $catalog.KnownFlagMask) FlagNames = @($cipherNames) + @($capabilityNames) CipherNames = @($cipherNames) CapabilityNames = @($capabilityNames) TicketEtypes = [int[]]$ticketEtypes SessionKeyEtypes = [int[]]$sessionKeyEtypes SupportsDes = [bool]($effective -band 0x03) SupportsRc4 = [bool]($effective -band 0x04) SupportsAes = [bool]($effective -band $aesTicketBits) SupportsAesSessionKeyOnly = (($effective -band 0x20) -ne 0) -and (($effective -band $aesTicketBits) -eq 0) SupportsFast = [bool]($effective -band 0x00010000) # True when the value carries an RFC 8009 SHA-2 bit. Windows Server 2025 # build 26100 was observed NOT honouring these: an account holding only # them received KDC_ERR_ETYPE_NOTSUPP, and one holding 0x80 alongside 0x10 # was served etype 0x12 with the 0x80 ignored. The bits are named in # CipherNames so nothing is hidden, but they are excluded from SupportsAes # because on observed Windows they confer no usable ticket encryption. CarriesUnhonouredSha2Bits = [bool]($effective -band $sha2Bits) UnknownBits = $unknownBits UnknownBitsHex = if ($unknownBits) { '0x{0:X}' -f $unknownBits } else { $null } } } 'AdvertizedEtype' { # This branch runs once per collected event, so it is written for throughput # rather than for brevity: String.Split against a cached char array instead # of a regex, plain loops instead of ForEach-Object and Where-Object, and # SortedSet instead of Sort-Object -Unique. Written the idiomatic way it # measured at 1.7 ms per call, which is several minutes across a real # collection and was the single largest cost in the whole decode path. $names = [System.Collections.Generic.List[string]]::new() foreach ($chunk in $AdvertizedEtype) { if ([string]::IsNullOrWhiteSpace($chunk)) { continue } foreach ($piece in $chunk.Split($catalog.AdvertizedSeparator, [System.StringSplitOptions]::RemoveEmptyEntries)) { $trimmed = $piece.Trim() if ($trimmed) { $names.Add($trimmed) } } } $resolved = [System.Collections.Generic.SortedSet[int]]::new() $families = [System.Collections.Generic.SortedSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) $unrecognized = [System.Collections.Generic.List[string]]::new() foreach ($name in $names) { # PowerShell hashtables are case-insensitive, so no case folding is # needed on the key - and folding it would cost an allocation per name. $etype = $catalog.AdvertizedName[$name] if ($null -ne $etype) { [void]$resolved.Add($etype) $entry = $catalog.TicketEtype[$etype] if ($entry) { [void]$families.Add($entry.Family) } } else { $unrecognized.Add($name) } } [PSCustomObject]@{ PSTypeName = 'KrbEtypeInsight.AdvertizedEtypeSet' Names = [string[]]$names Etypes = [int[]]$resolved Families = [string[]]$families SupportsAes = $families.Contains('AES') SupportsRc4 = $families.Contains('RC4') SupportsDes = $families.Contains('DES') # The whole point of the module in one property. A client that advertised # at least one algorithm, all of them understood, and none of them AES, # cannot authenticate against a hardened KDC. This is direct evidence, # not an inference from what the KDC happened to choose. # # The unrecognised-names clause is what keeps it honest. Without it, a # client advertising only names this catalog has never heard of resolves # to no families at all, which is indistinguishable from "no AES" - so a # client running an algorithm newer than the module would be reported as # legacy-only and raise a Critical saying it advertises no AES support. # That is exactly the unknown-read-as-negative mistake the null handling # everywhere else in this module exists to prevent, and it is worse here # because it fires on the most modern clients in the estate. # # An advertisement containing anything unrecognised therefore yields # $false - meaning "not established", not "capable". Callers that need # the three-way distinction read SupportsAes and UnrecognizedNames. LegacyOnly = ($names.Count -gt 0) -and ($unrecognized.Count -eq 0) -and (-not $families.Contains('AES')) UnrecognizedNames = [string[]]$unrecognized } } } } } |