Public/Get-KrbEvent.ps1
|
#Requires -Version 7.4 function Get-KrbEvent { <# .EXTERNALHELP KrbEtypeInsight-Help.xml .SYNOPSIS Collects and decodes Kerberos authentication audit events from domain controllers or archived logs #> [CmdletBinding(DefaultParameterSetName = 'LiveDomain')] [OutputType('KrbEtypeInsight.Event')] param( [Parameter(ParameterSetName = 'LiveDomain')] [ValidateNotNullOrEmpty()] [string[]]$ComputerName, [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Path')] [ValidateNotNullOrEmpty()] [Alias('FullName')] [string[]]$Path, [Parameter()] [ValidateNotNullOrEmpty()] # Get-KrbEtypeRisk models only these three events. Accepting adjacent Kerberos # audit IDs here made Get-KrbEvent appear to support them even though the risk # engine silently omitted them from its assessment. [ValidateSet(4768, 4769, 4771)] [int[]]$EventId = @(4768, 4769, 4771), [Parameter()] [datetime]$StartTime = (Get-Date).AddDays(-30), [Parameter()] [datetime]$EndTime = (Get-Date), [Parameter()] [ValidateRange(0, [int]::MaxValue)] [int]$MaxEvents = 50000, [Parameter(ParameterSetName = 'LiveDomain')] [System.Management.Automation.PSCredential] [System.Management.Automation.Credential()] $Credential, [Parameter()] [switch]$IncludeFailureOnly ) begin { $correlationId = [System.Guid]::NewGuid() Write-Verbose "Starting $($MyInvocation.MyCommand.Name) - CorrelationId: $correlationId" if ($StartTime -ge $EndTime) { Write-Error "StartTime ($StartTime) must be earlier than EndTime ($EndTime)" -ErrorAction Stop return } $totalEmitted = 0 $totalRead = 0 $failuresSeen = 0 $sourcesRead = 0 $sourcesEmpty = [System.Collections.Generic.List[string]]::new() # Resolved once so the two collection paths share exactly one filter construction. # Time bounds are included here rather than applied afterwards because the log # service evaluates them before a single record crosses the network. $baseFilter = @{ Id = $EventId StartTime = $StartTime EndTime = $EndTime } # Get-WinEvent treats -MaxEvents 0 as an error rather than as "unlimited", so the # unlimited case is expressed by omitting the parameter entirely. $readerArgs = @{ ErrorAction = 'Stop' } if ($MaxEvents -gt 0) { $readerArgs['MaxEvents'] = $MaxEvents } } process { # Build the source list for this invocation. In the Path set this runs once per # pipeline item, which is what makes Get-ChildItem | Get-KrbEvent stream. $sources = switch ($PSCmdlet.ParameterSetName) { 'Path' { foreach ($item in $Path) { $resolved = Resolve-Path -Path $item -ErrorAction SilentlyContinue if (-not $resolved) { Write-Error "Archived log not found: $item" continue } foreach ($file in $resolved) { [PSCustomObject]@{ Name = Split-Path -Path $file.Path -Leaf Filter = $baseFilter + @{ Path = $file.Path } Remote = @{} } } } } 'LiveDomain' { $targets = $ComputerName if (-not $targets) { try { $targets = @(Get-ADDomainController -Filter * -ErrorAction Stop | Select-Object -ExpandProperty HostName) Write-Verbose "Discovered $($targets.Count) domain controller(s)" } catch { Write-Error ("Could not enumerate domain controllers. Supply -ComputerName " + "explicitly, or use -Path against archived logs. Error: $($_.Exception.Message)") ` -ErrorAction Stop return } } foreach ($target in $targets) { $remote = @{ ComputerName = $target } if ($Credential) { $remote['Credential'] = $Credential } [PSCustomObject]@{ Name = $target Filter = $baseFilter + @{ LogName = 'Security' } Remote = $remote } } } } foreach ($source in $sources) { $sourcesRead++ Write-Verbose "Reading $($source.Name) - CorrelationId: $correlationId" Write-Progress -Activity 'Collecting Kerberos events' -Status $source.Name ` -CurrentOperation "$totalEmitted events decoded so far" $emittedFromSource = 0 # Splatting needs a variable, not a property expression - @($source.Remote) is an # array subexpression and would pass the hashtable as a single positional # argument rather than binding ComputerName and Credential. $remoteArgs = $source.Remote try { # -ErrorAction Stop turns "no events matched" into a catchable terminating # error, which is the only way to distinguish it from a genuine empty result # and report it as the audit-policy problem it usually is. $records = Get-WinEvent -FilterHashtable $source.Filter @readerArgs @remoteArgs } catch [System.Exception] { # Get-WinEvent raises the same exception type for "no matching events" as for # real faults, so the message is the only discriminator available. if ($_.Exception.Message -match 'No events were found') { $sourcesEmpty.Add($source.Name) Write-Verbose "No matching events on $($source.Name) in the requested window" continue } $errorDetails = @{ Source = $source.Name CorrelationId = $correlationId Function = $MyInvocation.MyCommand.Name ErrorMessage = $_.Exception.Message Line = $_.InvocationInfo.ScriptLineNumber } Write-Verbose ("Collection failure detail: " + ($errorDetails | ConvertTo-Json -Compress)) # One unreachable controller must not abandon the others. The assessment is # still valid over the controllers that answered, provided the report says # which ones did not - which is why this is a non-terminating error. # # "The RPC server is unavailable" gets named, because on a domain controller # it almost never means the machine is down. Get-WinEvent -ComputerName uses # the legacy Event Log RPC protocol, which needs the dynamic RPC range as well # as port 135, and the firewall rules that open it - the Remote Event Log # Management group - are DISABLED by default on a fresh Windows install. # # Measured: a healthy, replicating Server 2025 domain controller, reachable on # 135, 445, 5985, 389 and 88, answering WinRM perfectly, returned this error # for every single event log call. Its Security log held 6,703 records the # whole time. Without naming the cause this reads as "that DC is broken", and # an assessment run with -ErrorAction SilentlyContinue simply omits the # controller and reports a confident result over a fraction of the estate. $message = "Failed to read Kerberos events from $($source.Name): $($_.Exception.Message)" if ($_.Exception.Message -match 'RPC server is unavailable') { $message += (' The host is usually reachable and this is usually the ' + 'firewall: Get-WinEvent uses Event Log RPC, whose rules are off by ' + 'default. Enable them on that controller with ' + "Enable-NetFirewallRule -DisplayGroup 'Remote Event Log Management', " + 'or collect from an archived .evtx instead.') } Write-Error $message continue } # Read and emitted are counted separately. Conflating them made a source that # returned plenty of events but no FAILURES - which under -IncludeFailureOnly is # simply a healthy controller - report as empty, and the end block then advised # the operator to go and check whether Kerberos auditing was enabled. That advice # fired hardest during post-change verification, the one moment the module's # empty-collection warning most needs to mean what it says. $readFromSource = 0 foreach ($record in $records) { $decoded = ConvertFrom-KrbEventRecord -Record $record -SourceName $source.Name if (-not $decoded) { continue } $readFromSource++ if ($decoded.IsFailure) { $failuresSeen++ } if ($IncludeFailureOnly -and -not $decoded.IsFailure) { continue } $emittedFromSource++ $totalEmitted++ $decoded } $totalRead += $readFromSource # Only a source that produced no events AT ALL is evidence of an auditing or # retention problem. if ($readFromSource -eq 0) { $sourcesEmpty.Add($source.Name) } elseif ($emittedFromSource -eq 0) { Write-Verbose ("$($source.Name) returned $readFromSource event(s), none matching " + 'the requested filter') } } } end { Write-Progress -Activity 'Collecting Kerberos events' -Completed # An empty collection is the failure mode that most often goes unnoticed, because it # looks exactly like a clean domain. Saying so explicitly is the difference between # an assessment that found nothing and an assessment that measured nothing. if ($sourcesEmpty.Count -gt 0) { $names = $sourcesEmpty -join ', ' Write-Warning ("No Kerberos events matched on: $names. Check that the " + "'Kerberos Authentication Service' and 'Kerberos Service Ticket Operations' " + "audit subcategories are enabled, and that the requested window " + "($StartTime to $EndTime) is inside the log's retention.") } # Gated on events READ, not on events emitted. A collection that read plenty and # emitted none because -IncludeFailureOnly filtered them all is a healthy domain, and # telling its operator that the assessment "will describe an empty domain rather than # a clean one" is precisely backwards - it IS a clean one, and that is the answer # they were looking for. if ($sourcesRead -gt 0 -and $totalRead -eq 0) { Write-Warning ("Read $sourcesRead source(s) and decoded no events. Any assessment " + 'built on this collection will describe an empty domain rather than a clean one.') } if ($IncludeFailureOnly -and $totalRead -gt 0 -and $totalEmitted -eq 0) { # Worth stating positively. Silence here reads as a failed collection, when it is # the result a post-change verification hopes for. Write-Verbose ("Read $totalRead event(s) across $sourcesRead source(s) and none were " + 'failures.') } # Success-only audit policy is the module's remaining blind spot, and it was found by # running this against a domain that had it. The two Kerberos subcategories can be set # to Success without Failure, which is not the default but is common - and in that # state the KDC logs no 4771 at all and no failed 4769. A post-change verification # then returns nothing and reads as "the change broke nothing", when in truth nothing # capable of recording breakage was ever switched on. # # Inferred from the collection rather than by reading audit policy, which would need # local administrator on every controller for a module that otherwise needs only # Event Log Reader. Asking for 4771 and receiving none while thousands of successes # came back is not proof, but it is the shape the misconfiguration makes. if ($EventId -contains 4771 -and $totalRead -gt 100 -and $failuresSeen -eq 0) { Write-Warning ("Collected $totalRead event(s) and not one failure, including zero " + '4771s. That is what a domain with the Kerberos audit subcategories set to ' + 'Success without Failure looks like. Confirm with: auditpol /get /subcategory:' + '"Kerberos Authentication Service" - if Failure is not enabled, this collection ' + 'cannot show breakage and must not be read as showing none.') } Write-Verbose ("Completed $($MyInvocation.MyCommand.Name) - $totalEmitted of $totalRead " + "event(s) from $sourcesRead source(s) - CorrelationId: $correlationId") } } |