Detection/Get-CredentialAccessEvents.ps1

function Get-CredentialAccessEvents {
    <#
    .SYNOPSIS
        Detects runtime credential-access activity from Windows event logs
    .DESCRIPTION
        Analyzes Windows Security (and optionally Sysmon) event logs for credential-access
        techniques: Kerberoasting (RC4 service ticket bursts), anomalous TGT request volumes,
        Kerberos pre-authentication failure spikes (brute force / password spray), a best-effort
        golden-ticket heuristic (Kerberos logons with no matching TGT request in the window),
        and optionally processes with open access handles to lsass.exe (4663 or Sysmon 10).
        Heuristic detections are labeled as such and should be validated manually.
    .PARAMETER Hours
        Number of hours to look back in event logs (default: 24)
    .PARAMETER IncludeLSASSAccess
        Also enumerate processes with open access handles to lsass.exe. Uses Security event
        4663 (requires Object Access auditing with a SACL on lsass.exe) and Sysmon event ID 10
        when the Sysmon Operational log is present
    .EXAMPLE
        Get-CredentialAccessEvents
        Get-CredentialAccessEvents -Hours 48 -IncludeLSASSAccess
    .OUTPUTS
        PSCustomObject with Events, Summary, and Recommendations
    #>


    [CmdletBinding()]
    param(
        [Parameter()]
        [int]$Hours = 24,

        [Parameter()]
        [switch]$IncludeLSASSAccess
    )

    begin {
        Write-Host "Analyzing credential-access events (last $Hours hours)..." -ForegroundColor Cyan

        $results = [PSCustomObject]@{
            ScanDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
            TimeRange = "$Hours hours"
            Events = @()
            Summary = @{
                RC4ServiceTicketRequests = 0
                KerberoastBursts = 0
                TGTAnomalies = 0
                KerberosFailureSpikes = 0
                GoldenTicketHeuristics = 0
                LSASSAccessEvents = 0
                SuspiciousLSASSAccess = 0
            }
            Recommendations = @()
        }

        $startTime = (Get-Date).AddHours(-$Hours)

        # Granted-access masks that are normal for DLL loads / telemetry against lsass.exe
        # (masks containing PROCESS_VM_READ 0x0010 never reach this list - the bit test
        # below classifies them as suspicious first)
        $normalAccessMasks = @('0x1000', '0x1400')
        # PROCESS_VM_READ (0x0010) or broad rights (PROCESS_ALL_ACCESS style) are suspicious
        $suspiciousAccessMasks = @('0x1010', '0x101a', '0x103a', '0x143a', '0x1f0fff', '0x1fffff')

        $getEventValue = {
            param($EventData, [string]$Name)
            if ($null -eq $EventData) { return $null }
            $node = $EventData | Where-Object { $_.Name -eq $Name }
            if ($null -ne $node) { return $node.InnerText }
            return $null
        }
    }

    process {
        # 1. Kerberoasting: RC4-encrypted service ticket requests (Event ID 4769, TicketEncryptionType 0x17)
        $rc4ByAccount = @{}
        try {
            Write-Verbose "Analyzing service ticket requests (4769)..."
            $ticketEvents = Get-WinEvent -FilterHashtable @{
                LogName = 'Security'
                ID = 4769
                StartTime = $startTime
            } -ErrorAction SilentlyContinue

            foreach ($event in @($ticketEvents)) {
                if ($null -eq $event) { continue }
                $eventData = ([xml]$event.ToXml()).Event.EventData.Data

                $targetAccount = & $getEventValue $eventData 'TargetUserName'
                $serviceName = & $getEventValue $eventData 'ServiceName'
                $encryptionType = & $getEventValue $eventData 'TicketEncryptionType'
                $sourceIP = & $getEventValue $eventData 'IpAddress'

                if ($encryptionType -ne '0x17') { continue }
                if (-not $targetAccount -or $targetAccount -match '\$$') { continue }
                if (-not $serviceName -or $serviceName -match '\$$' -or $serviceName -eq 'krbtgt') { continue }

                $results.Summary.RC4ServiceTicketRequests++

                if (-not $rc4ByAccount.ContainsKey($targetAccount)) {
                    $rc4ByAccount[$targetAccount] = [PSCustomObject]@{
                        Account = $targetAccount
                        Count = 0
                        Services = @()
                        SourceIPs = @()
                        FirstSeen = $event.TimeCreated
                        LastSeen = $event.TimeCreated
                    }
                }
                $rc4ByAccount[$targetAccount].Count++
                if ($serviceName -and $rc4ByAccount[$targetAccount].Services -notcontains $serviceName) {
                    $rc4ByAccount[$targetAccount].Services += $serviceName
                }
                if ($sourceIP -and $sourceIP -ne '-' -and $rc4ByAccount[$targetAccount].SourceIPs -notcontains $sourceIP) {
                    $rc4ByAccount[$targetAccount].SourceIPs += $sourceIP
                }
                if ($null -ne $event.TimeCreated) {
                    if ($null -eq $rc4ByAccount[$targetAccount].FirstSeen -or $event.TimeCreated -lt $rc4ByAccount[$targetAccount].FirstSeen) { $rc4ByAccount[$targetAccount].FirstSeen = $event.TimeCreated }
                    if ($null -eq $rc4ByAccount[$targetAccount].LastSeen -or $event.TimeCreated -gt $rc4ByAccount[$targetAccount].LastSeen) { $rc4ByAccount[$targetAccount].LastSeen = $event.TimeCreated }
                }
            }

            foreach ($accountName in $rc4ByAccount.Keys) {
                $rc4Info = $rc4ByAccount[$accountName]
                if ($rc4Info.Count -gt 5) {
                    $results.Summary.KerberoastBursts++
                    $results.Events += [PSCustomObject]@{
                        TimeCreated = $rc4Info.LastSeen
                        Category = 'KerberoastBurst'
                        Severity = 'High'
                        Account = $rc4Info.Account
                        Source = ($rc4Info.SourceIPs -join ', ')
                        Details = "$($rc4Info.Count) RC4-encrypted service ticket requests in window for $($rc4Info.Services.Count) distinct service(s): $($rc4Info.Services -join ', ')"
                        DetectionMethod = 'Threshold'
                        Confidence = 'High'
                    }
                }
                else {
                    $results.Events += [PSCustomObject]@{
                        TimeCreated = $rc4Info.LastSeen
                        Category = 'RC4ServiceTicketRequest'
                        Severity = 'Low'
                        Account = $rc4Info.Account
                        Source = ($rc4Info.SourceIPs -join ', ')
                        Details = "$($rc4Info.Count) RC4-encrypted service ticket request(s) for: $($rc4Info.Services -join ', ') (informational - may be legacy clients)"
                        DetectionMethod = 'Signature'
                        Confidence = 'Low'
                    }
                }
            }
        }
        catch {
            Write-Warning "Service ticket (4769) analysis not available: $_"
            $results.Recommendations += 'Service ticket auditing (4769) was not available - verify the Security log is accessible and Kerberos service ticket auditing is enabled'
        }

        # 2. Anomalous TGT request volumes (Event ID 4768) - per-account counts also feed the golden-ticket heuristic
        $tgtCountByAccount = @{}
        try {
            Write-Verbose "Analyzing TGT requests (4768)..."
            $tgtEvents = Get-WinEvent -FilterHashtable @{
                LogName = 'Security'
                ID = 4768
                StartTime = $startTime
            } -ErrorAction SilentlyContinue

            foreach ($event in @($tgtEvents)) {
                if ($null -eq $event) { continue }
                $eventData = ([xml]$event.ToXml()).Event.EventData.Data

                $targetAccount = & $getEventValue $eventData 'TargetUserName'
                if (-not $targetAccount -or $targetAccount -match '\$$') { continue }

                if (-not $tgtCountByAccount.ContainsKey($targetAccount)) {
                    $tgtCountByAccount[$targetAccount] = [PSCustomObject]@{
                        Account = $targetAccount
                        Count = 0
                        SourceIPs = @()
                        LastSeen = $event.TimeCreated
                    }
                }
                $tgtCountByAccount[$targetAccount].Count++
                $sourceIP = & $getEventValue $eventData 'IpAddress'
                if ($sourceIP -and $sourceIP -ne '-' -and $tgtCountByAccount[$targetAccount].SourceIPs -notcontains $sourceIP) {
                    $tgtCountByAccount[$targetAccount].SourceIPs += $sourceIP
                }
                if ($null -ne $event.TimeCreated -and ($null -eq $tgtCountByAccount[$targetAccount].LastSeen -or $event.TimeCreated -gt $tgtCountByAccount[$targetAccount].LastSeen)) {
                    $tgtCountByAccount[$targetAccount].LastSeen = $event.TimeCreated
                }
            }

            foreach ($accountName in $tgtCountByAccount.Keys) {
                $tgtInfo = $tgtCountByAccount[$accountName]
                if ($tgtInfo.Count -gt 20) {
                    $results.Summary.TGTAnomalies++
                    $results.Events += [PSCustomObject]@{
                        TimeCreated = $tgtInfo.LastSeen
                        Category = 'TGTAnomaly'
                        Severity = 'Medium'
                        Account = $tgtInfo.Account
                        Source = ($tgtInfo.SourceIPs -join ', ')
                        Details = "$($tgtInfo.Count) TGT requests in a $Hours-hour window (threshold: 20) - unusually high volume"
                        DetectionMethod = 'Threshold'
                        Confidence = 'Medium'
                    }
                }
            }
        }
        catch {
            Write-Warning "TGT request (4768) analysis not available: $_"
            $results.Recommendations += 'TGT auditing (4768) was not available - verify the Security log is accessible and Kerberos authentication service auditing is enabled'
        }

        # 3. Kerberos pre-authentication failure spikes (Event ID 4771) - brute force / password spray
        try {
            Write-Verbose "Analyzing Kerberos pre-authentication failures (4771)..."
            $krbFailEvents = Get-WinEvent -FilterHashtable @{
                LogName = 'Security'
                ID = 4771
                StartTime = $startTime
            } -ErrorAction SilentlyContinue

            $failByAccount = @{}
            foreach ($event in @($krbFailEvents)) {
                if ($null -eq $event) { continue }
                $eventData = ([xml]$event.ToXml()).Event.EventData.Data

                $targetAccount = & $getEventValue $eventData 'TargetUserName'
                if (-not $targetAccount -or $targetAccount -match '\$$') { continue }

                if (-not $failByAccount.ContainsKey($targetAccount)) {
                    $failByAccount[$targetAccount] = [PSCustomObject]@{
                        Account = $targetAccount
                        Count = 0
                        SourceIPs = @()
                        FailureCodes = @()
                        LastSeen = $event.TimeCreated
                    }
                }
                $failByAccount[$targetAccount].Count++
                $sourceIP = & $getEventValue $eventData 'IpAddress'
                $failureCode = & $getEventValue $eventData 'FailureCode'
                if ($sourceIP -and $sourceIP -ne '-' -and $failByAccount[$targetAccount].SourceIPs -notcontains $sourceIP) {
                    $failByAccount[$targetAccount].SourceIPs += $sourceIP
                }
                if ($failureCode -and $failByAccount[$targetAccount].FailureCodes -notcontains $failureCode) {
                    $failByAccount[$targetAccount].FailureCodes += $failureCode
                }
                if ($null -ne $event.TimeCreated -and ($null -eq $failByAccount[$targetAccount].LastSeen -or $event.TimeCreated -gt $failByAccount[$targetAccount].LastSeen)) {
                    $failByAccount[$targetAccount].LastSeen = $event.TimeCreated
                }
            }

            foreach ($accountName in $failByAccount.Keys) {
                $failInfo = $failByAccount[$accountName]
                if ($failInfo.Count -ge 5) {
                    $severity = if ($failInfo.Count -ge 10) { 'High' } else { 'Medium' }
                    $results.Summary.KerberosFailureSpikes++
                    $results.Events += [PSCustomObject]@{
                        TimeCreated = $failInfo.LastSeen
                        Category = 'KerberosFailureSpike'
                        Severity = $severity
                        Account = $failInfo.Account
                        Source = ($failInfo.SourceIPs -join ', ')
                        Details = "$($failInfo.Count) Kerberos pre-authentication failures in window (failure codes: $($failInfo.FailureCodes -join ', ')) - possible brute force or password spray"
                        DetectionMethod = 'Threshold'
                        Confidence = 'Medium'
                    }
                }
            }
        }
        catch {
            Write-Warning "Kerberos failure (4771) analysis not available: $_"
            $results.Recommendations += 'Kerberos failure auditing (4771) was not available - verify the Security log is accessible and credential validation auditing is enabled'
        }

        # 4. Golden-ticket heuristic: Kerberos logons (4624) for accounts with no matching 4768 TGT request in the window
        try {
            Write-Verbose "Correlating logons (4624) against TGT requests (golden-ticket heuristic)..."
            $logonEvents = Get-WinEvent -FilterHashtable @{
                LogName = 'Security'
                ID = 4624
                StartTime = $startTime
            } -ErrorAction SilentlyContinue

            $flaggedAccounts = @{}
            foreach ($event in @($logonEvents)) {
                if ($null -eq $event) { continue }
                $eventData = ([xml]$event.ToXml()).Event.EventData.Data

                $targetAccount = & $getEventValue $eventData 'TargetUserName'
                $logonType = & $getEventValue $eventData 'LogonType'
                $authPackage = & $getEventValue $eventData 'AuthenticationPackageName'
                $sourceIP = & $getEventValue $eventData 'IpAddress'
                $workstation = & $getEventValue $eventData 'WorkstationName'

                if ($logonType -ne '3' -or $authPackage -ne 'Kerberos') { continue }
                if (-not $targetAccount -or $targetAccount -match '\$$' -or $targetAccount -match '^(SYSTEM|ANONYMOUS LOGON)$') { continue }
                if ($tgtCountByAccount.ContainsKey($targetAccount)) { continue }
                if ($flaggedAccounts.ContainsKey($targetAccount)) { continue }

                $flaggedAccounts[$targetAccount] = $true
                $results.Summary.GoldenTicketHeuristics++
                $results.Events += [PSCustomObject]@{
                    TimeCreated = $event.TimeCreated
                    Category = 'GoldenTicketHeuristic'
                    Severity = 'Medium'
                    Account = $targetAccount
                    Source = if ($sourceIP -and $sourceIP -ne '-') { $sourceIP } else { $workstation }
                    Details = 'Kerberos network logon with no matching TGT request (4768) for this account in the analysis window - HEURISTIC: may also result from log sampling gaps, cross-domain logons, or tickets issued before the window'
                    DetectionMethod = 'Heuristic'
                    Confidence = 'Medium'
                }
            }
        }
        catch {
            Write-Warning "Golden-ticket heuristic correlation not available: $_"
            $results.Recommendations += 'Logon correlation (4624/4768) was not available - verify the Security log is accessible'
        }

        # 5. LSASS access handle enumeration (optional): Security 4663 and/or Sysmon 10
        if ($IncludeLSASSAccess) {
            $lsassFound = $false

            try {
                Write-Verbose "Checking Sysmon Operational log availability..."
                $sysmonAvailable = $false
                try {
                    $null = Get-WinEvent -ListLog 'Microsoft-Windows-Sysmon/Operational' -ErrorAction Stop
                    $sysmonAvailable = $true
                }
                catch {
                    $sysmonAvailable = $false
                }

                Write-Verbose "Enumerating LSASS access events (4663)..."
                $handleEvents = Get-WinEvent -FilterHashtable @{
                    LogName = 'Security'
                    ID = 4663
                    StartTime = $startTime
                } -ErrorAction SilentlyContinue

                foreach ($event in @($handleEvents)) {
                    if ($null -eq $event) { continue }
                    $eventData = ([xml]$event.ToXml()).Event.EventData.Data

                    $objectName = & $getEventValue $eventData 'ObjectName'
                    if (-not $objectName -or $objectName -notmatch 'lsass\.exe$') { continue }

                    $lsassFound = $true
                    $processName = & $getEventValue $eventData 'ProcessName'
                    $subjectAccount = & $getEventValue $eventData 'SubjectUserName'
                    $accessMask = & $getEventValue $eventData 'AccessMask'

                    $classification = 'Review'
                    $severity = 'Medium'
                    $maskValue = $null
                    if ($accessMask) {
                        try { $maskValue = [Convert]::ToInt64($accessMask, 16) } catch { $maskValue = $null }
                    }
                    if ($null -ne $maskValue) {
                        if (($maskValue -band 0x10) -ne 0 -or $suspiciousAccessMasks -contains $accessMask.ToLower()) {
                            $classification = 'Suspicious'
                            $severity = 'High'
                        }
                        elseif ($normalAccessMasks -contains $accessMask.ToLower()) {
                            $classification = 'Normal'
                            $severity = 'Info'
                        }
                    }

                    if ($classification -eq 'Normal') { continue }

                    $results.Summary.LSASSAccessEvents++
                    if ($classification -eq 'Suspicious') { $results.Summary.SuspiciousLSASSAccess++ }
                    $results.Events += [PSCustomObject]@{
                        TimeCreated = $event.TimeCreated
                        Category = 'LSASSAccess'
                        Severity = $severity
                        Account = $subjectAccount
                        Source = $processName
                        Details = "Process accessed lsass.exe with access mask $accessMask ($classification) - PROCESS_VM_READ (0x10) or broad rights may indicate credential dumping"
                        DetectionMethod = 'Signature'
                        Confidence = if ($classification -eq 'Suspicious') { 'High' } else { 'Low' }
                    }
                }

                if ($sysmonAvailable) {
                    $sysmonEvents = Get-WinEvent -FilterHashtable @{
                        LogName = 'Microsoft-Windows-Sysmon/Operational'
                        ID = 10
                        StartTime = $startTime
                    } -ErrorAction SilentlyContinue

                    foreach ($event in @($sysmonEvents)) {
                        if ($null -eq $event) { continue }
                        $eventData = ([xml]$event.ToXml()).Event.EventData.Data

                        $targetImage = & $getEventValue $eventData 'TargetImage'
                        if (-not $targetImage -or $targetImage -notmatch 'lsass\.exe$') { continue }

                        $lsassFound = $true
                        $sourceImage = & $getEventValue $eventData 'SourceImage'
                        $grantedAccess = & $getEventValue $eventData 'GrantedAccess'

                        $classification = 'Review'
                        $severity = 'Medium'
                        $maskValue = $null
                        if ($grantedAccess) {
                            try { $maskValue = [Convert]::ToInt64($grantedAccess, 16) } catch { $maskValue = $null }
                        }
                        if ($null -ne $maskValue) {
                            if (($maskValue -band 0x10) -ne 0 -or $suspiciousAccessMasks -contains $grantedAccess.ToLower()) {
                                $classification = 'Suspicious'
                                $severity = 'High'
                            }
                            elseif ($normalAccessMasks -contains $grantedAccess.ToLower()) {
                                $classification = 'Normal'
                                $severity = 'Info'
                            }
                        }

                        if ($classification -eq 'Normal') { continue }

                        $results.Summary.LSASSAccessEvents++
                        if ($classification -eq 'Suspicious') { $results.Summary.SuspiciousLSASSAccess++ }
                        $results.Events += [PSCustomObject]@{
                            TimeCreated = $event.TimeCreated
                            Category = 'LSASSAccess'
                            Severity = $severity
                            Account = ''
                            Source = $sourceImage
                            Details = "Sysmon ProcessAccess: $sourceImage opened lsass.exe with granted access $grantedAccess ($classification) - PROCESS_VM_READ (0x10) or broad rights may indicate credential dumping"
                            DetectionMethod = 'Signature'
                            Confidence = if ($classification -eq 'Suspicious') { 'High' } else { 'Low' }
                        }
                    }
                }

                if (-not $lsassFound) {
                    Write-Verbose "No LSASS access events found"
                    $results.Recommendations += 'No lsass.exe access-handle events found - enable Object Access auditing with a SACL on lsass.exe (Security 4663) or deploy Sysmon with ProcessAccess (ID 10) logging for LSASS access visibility'
                }
            }
            catch {
                Write-Warning "LSASS access analysis not available: $_"
                $results.Recommendations += 'LSASS access monitoring not available - enable Object Access auditing on lsass.exe or install Sysmon with ProcessAccess events'
            }
        }

        # Recommendations based on findings
        if ($results.Summary.KerberoastBursts -gt 0) {
            $results.Recommendations += 'Investigate accounts with RC4 service-ticket bursts (possible Kerberoasting); prefer AES for service accounts, use gMSA or long random service account passwords, and rotate exposed credentials'
        }
        if ($results.Summary.KerberosFailureSpikes -gt 0) {
            $results.Recommendations += 'Investigate Kerberos pre-authentication failure spikes for brute force or password spray; enforce lockout policies and MFA'
        }
        if ($results.Summary.TGTAnomalies -gt 0) {
            $results.Recommendations += 'Review accounts with unusually high TGT request volumes; correlate with expected application behavior before treating as malicious'
        }
        if ($results.Summary.GoldenTicketHeuristics -gt 0) {
            $results.Recommendations += 'Golden-ticket heuristic hits require manual validation - verify log coverage for 4768 on all DCs before concluding ticket forgery (heuristic, Medium confidence)'
        }
        if ($results.Summary.SuspiciousLSASSAccess -gt 0) {
            $results.Recommendations += 'Suspicious lsass.exe access detected - isolate the host, capture memory for analysis, and reset credentials that may have been exposed'
        }
    }

    end {
        Write-Host "`nCredential-Access Analysis Complete!" -ForegroundColor Green
        Write-Host "`n=== Summary ===" -ForegroundColor Cyan
        Write-Host "RC4 service ticket requests: $($results.Summary.RC4ServiceTicketRequests)" -ForegroundColor Yellow
        Write-Host "Kerberoast bursts (>5 RC4 tickets per account): $($results.Summary.KerberoastBursts)" -ForegroundColor $(if ($results.Summary.KerberoastBursts -gt 0) { 'Red' } else { 'Green' })
        Write-Host "TGT request anomalies: $($results.Summary.TGTAnomalies)" -ForegroundColor $(if ($results.Summary.TGTAnomalies -gt 0) { 'Yellow' } else { 'Green' })
        Write-Host "Kerberos failure spikes: $($results.Summary.KerberosFailureSpikes)" -ForegroundColor $(if ($results.Summary.KerberosFailureSpikes -gt 0) { 'Red' } else { 'Green' })
        Write-Host "Golden-ticket heuristic hits: $($results.Summary.GoldenTicketHeuristics)" -ForegroundColor $(if ($results.Summary.GoldenTicketHeuristics -gt 0) { 'Yellow' } else { 'Green' })

        if ($IncludeLSASSAccess) {
            Write-Host "LSASS access events flagged: $($results.Summary.LSASSAccessEvents)" -ForegroundColor Yellow
            Write-Host "Suspicious LSASS access: $($results.Summary.SuspiciousLSASSAccess)" -ForegroundColor $(if ($results.Summary.SuspiciousLSASSAccess -gt 0) { 'Red' } else { 'Green' })
        }

        if (@($results.Recommendations).Count -gt 0) {
            Write-Host "`n=== Recommendations ===" -ForegroundColor Cyan
            foreach ($recommendation in $results.Recommendations) {
                Write-Host "- $recommendation" -ForegroundColor Yellow
            }
        }

        return $results
    }
}