/ fla
Analiza nieudanych prób logowania (zdarzenie 4625) z dziennika Windows.
Wykrywa brute force, credential stuffing, ataki RDP, zablokowane konta i źródła spoza sieci.
Tylko odczyt. Zakres: $hback (godziny wstecz) albo $hwin - gotowy $hwin skrypt podpowie sam przy wpisach na czerwono.
Uruchomienie w PowerShell
$hback = 72; irm https://dev.bitback.pl/fla | iex
Kopiuj
Wyróżniony prefiks ustawia parametry - możesz zmienić wartość albo usunąć go i uruchomić na ustawieniach domyślnych.
39 kB · 921 linii · zaktualizowano 2026-07-30
●
Poniżej pełne, niezmienione źródło. Przeczytaj przed uruchomieniem.
# === # Analiza nieudanych prób logowania (zdarzenie 4625) z dziennika Windows. # Wykrywa brute force, credential stuffing, ataki RDP, zablokowane konta i źródła spoza sieci. # Tylko odczyt. Zakres: $hback (godziny wstecz) albo $hwin - gotowy $hwin skrypt podpowie sam przy wpisach na czerwono. # === # bitback-prefix: $hback = 72; # Analyze-FailedLogins.ps1 # Failed login attempts analyzer for Windows Security Event Log # Usage: irm https://dev.bitback.pl/fla | iex # Time window - set a variable BEFORE running, $hwin wins when both are given: # $hback = 72; irm https://dev.bitback.pl/fla | iex # $hwin = '2026-07-22T14:03:11..2026-07-23T13:48:55'; irm https://dev.bitback.pl/fla | iex # Requires: Run as Administrator (Security log access) #Requires -Version 5.1 # ============================================================ # CONFIG # ============================================================ # Two ways to pick the scanned window. Both come from the caller's scope # (irm | iex runs in it), so nothing here may overwrite them unconditionally. # $hback - whole hours back from now (default 24) # $hwin - exact range 'yyyy-MM-ddTHH:mm:ss..yyyy-MM-ddTHH:mm:ss'; takes precedence # over $hback. Nie trzeba go skladac recznie - skrypt drukuje gotowy # zakres pod kazdym wpisem oznaczonym na czerwono, wystarczy go wkleic. $Now = Get-Date $HoursExplicit = $false $WinExplicit = $false $WindowLabel = '' $TimeFmt = 'yyyy-MM-ddTHH:mm:ss' if ($null -ne $hwin -and "$hwin".Trim() -ne '') { $rawWin = "$hwin".Trim().Trim("'", '"') $winParts = $rawWin -split '\.\.' $ci = [Globalization.CultureInfo]::InvariantCulture $winFrom = [datetime]::MinValue $winTo = [datetime]::MinValue if ($winParts.Count -eq 2 -and [datetime]::TryParseExact($winParts[0].Trim(), $TimeFmt, $ci, [Globalization.DateTimeStyles]::None, [ref]$winFrom) -and [datetime]::TryParseExact($winParts[1].Trim(), $TimeFmt, $ci, [Globalization.DateTimeStyles]::None, [ref]$winTo)) { if ($winTo -lt $winFrom) { $swap = $winFrom; $winFrom = $winTo; $winTo = $swap } $StartTime = $winFrom # End inclusive to the full second: the printed range is truncated to seconds, # so an event at .900 must stay inside the window it was suggested from. $EndTime = $winTo.AddSeconds(1).AddTicks(-1) $WinExplicit = $true $WindowLabel = "$($winFrom.ToString($TimeFmt))..$($winTo.ToString($TimeFmt))" } else { Write-Host '' Write-Host " Ignoring `$hwin = '$hwin' - expected 'yyyy-MM-ddTHH:mm:ss..yyyy-MM-ddTHH:mm:ss'." -ForegroundColor Yellow } } # Godziny trzymamy w zmiennej wlasnej, a $hback z sesji zostaje nietkniete. Skrypt biegnie # w zakresie wywolujacego, wiec nadpisanie $hback zostawaloby w konsoli technika i zmienialo # domyslne okno kolejnego uruchomienia - takze skryptu logins, ktory czyta te sama zmienna. $HoursBack = 24 if (-not $WinExplicit) { if ($null -ne $hback -and "$hback".Trim() -ne '') { $parsedH = 0 if ([int]::TryParse("$hback".Trim(), [ref]$parsedH) -and $parsedH -ge 1 -and $parsedH -le 8760) { $HoursBack = $parsedH $HoursExplicit = $true } else { Write-Host '' Write-Host " Ignoring `$hback = '$hback' - expected whole hours in range 1-8760. Falling back to 24h." -ForegroundColor Yellow } } $StartTime = $Now.AddHours(-$HoursBack) $EndTime = $Now } # Used wherever the window has to be named or measured, regardless of how it was set. $WindowText = if ($WinExplicit) { $WindowLabel } else { "last ${HoursBack}h" } $WindowHours = ($EndTime - $StartTime).TotalHours # ============================================================ # LOOKUPS # ============================================================ $SubStatusMap = @{ '0xC0000064' = 'account does not exist' '0xC000006A' = 'wrong password' '0xC0000234' = 'account locked out' '0xC0000072' = 'account disabled' '0xC000006D' = 'generic logon failure' '0xC0000071' = 'password expired' '0xC000006F' = 'outside allowed hours' '0xC0000070' = 'unauthorized workstation' '0xC0000193' = 'account expired' '0xC0000224' = 'password must change' } # Typ 2 nie dowodzi klawiatury, wiec skrot mowi tylko o lokalnosci proby. $LogonTypeShort = @{ 2 = 'Lokal' 3 = 'SMB' 4 = 'Batch' 5 = 'Svc' 7 = 'Unlock' 8 = 'NetClr' 9 = 'RunAs' 10 = 'RDP' 11 = 'Cache' } $PrivilegedAccounts = @( 'administrator', 'admin', 'root', 'sa', 'guest', 'test', 'user', 'backup', 'krbtgt', 'defaultaccount' ) # ============================================================ # HELPER FUNCTIONS # ============================================================ function Format-Hex { param([object]$Value) if ($null -eq $Value) { return '0x00000000' } if ($Value -is [string]) { if ($Value -match '^0x') { return $Value.ToUpper().Replace('0X','0x') } return '0x00000000' } # Dziennik oddaje SubStatus jako Int32 z ustawionym bitem znaku: 0xC000006A przychodzi # jako -1073741718 (zmierzone na zdarzeniu 4625, PowerShell 5.1). Maska na Int64 # obsluguje te postac oraz wariant bez znaku, bo LogonType z tego samego zdarzenia # przychodzi juz jako UInt32 - typy pol nie sa jednolite i lepiej nie zakladac ktorego. try { $u = [uint32]([int64]$Value -band 4294967295L) return '0x{0:X8}' -f $u } catch { return '0x00000000' } } function Test-PrivateIP { param([string]$IP) if ([string]::IsNullOrWhiteSpace($IP) -or $IP -eq '-') { return $true } try { $parsed = [System.Net.IPAddress]::Parse($IP) if ($parsed.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) { if ($parsed.IsIPv6LinkLocal) { return $true } if ($parsed.IsIPv6SiteLocal) { return $true } if ([System.Net.IPAddress]::IsLoopback($parsed)) { return $true } $firstByte = $parsed.GetAddressBytes()[0] if ($firstByte -eq 0xFC -or $firstByte -eq 0xFD) { return $true } return $false } $bytes = $parsed.GetAddressBytes() if ($bytes[0] -eq 10) { return $true } if ($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) { return $true } if ($bytes[0] -eq 192 -and $bytes[1] -eq 168) { return $true } if ($bytes[0] -eq 127) { return $true } if ($bytes[0] -eq 169 -and $bytes[1] -eq 254) { return $true } return $false } catch { return $true } } function Resolve-HostnameQuick { param([string]$IP) if ([string]::IsNullOrWhiteSpace($IP) -or $IP -eq '-' -or $IP -eq '127.0.0.1') { return '' } if ($IP -notmatch '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') { return '' } try { $job = Start-Job -ScriptBlock { param($i) nbtstat -a $i 2>&1 } -ArgumentList $IP $result = $job | Wait-Job -Timeout 3 | Receive-Job 2>$null Remove-Job $job -Force -ErrorAction SilentlyContinue if ($result) { $lines = $result -split "`n" | Where-Object { $_ -match '<00>\s+UNIQUE' } if ($lines) { $name = ($lines[0] -split '\s+')[0].Trim() if ($name -and $name -ne '') { return $name } } } } catch { } return '' } function Get-SourceLabel { param([string]$IP, [hashtable]$ResolvedCache) if ([string]::IsNullOrWhiteSpace($IP) -or $IP -eq '-') { return '(local)' } if ($IP -eq '127.0.0.1') { return 'localhost' } $isPrivate = Test-PrivateIP $IP $label = $IP if ($isPrivate -and $IP -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') { if ($ResolvedCache.ContainsKey($IP)) { $hostname = $ResolvedCache[$IP] } else { $hostname = Resolve-HostnameQuick $IP $ResolvedCache[$IP] = $hostname } if ($hostname) { $label = "$IP ($hostname)" } } if (-not $isPrivate) { $label += ' [EXTERNAL!]' } return $label } function Format-Duration { param([timespan]$Duration) if ($Duration.TotalHours -ge 1) { return "{0:0}h {1}min" -f [Math]::Floor($Duration.TotalHours), $Duration.Minutes } elseif ($Duration.TotalMinutes -ge 1) { return "{0}min" -f [Math]::Ceiling($Duration.TotalMinutes) } else { return "{0}sec" -f [Math]::Ceiling($Duration.TotalSeconds) } } function Format-IntervalShort { param([double]$Seconds) if ($Seconds -ge 3600) { return "{0:0}h" -f ($Seconds / 3600) } if ($Seconds -ge 60) { return "{0:0}min" -f ($Seconds / 60) } return "{0:0}sec" -f $Seconds } # ============================================================ # DATA COLLECTION # ============================================================ function Get-FailedLogonEvents { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host '' Write-Host ' !! ERROR: Administrator privileges required !!' -ForegroundColor Red Write-Host ' Run PowerShell as Administrator.' -ForegroundColor Yellow Write-Host '' return $null } try { $filterXml = @" <QueryList> <Query Id="0" Path="Security"> <Select Path="Security"> *[System[(EventID=4625) and TimeCreated[@SystemTime>='$($StartTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"))' and @SystemTime<='$($EndTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"))']]] </Select> </Query> </QueryList> "@ $events = Get-WinEvent -FilterXml $filterXml -ErrorAction Stop } catch [Exception] { if ($_.Exception.Message -match 'No events were found') { return ,@() } Write-Host " ERROR reading log: $($_.Exception.Message)" -ForegroundColor Red return $null } $results = @() foreach ($evt in $events) { $props = $evt.Properties # 4625 Properties: [5]=TargetUserName [6]=TargetDomainName [7]=Status [9]=SubStatus # [10]=LogonType [13]=WorkstationName [19]=IpAddress $targetUser = if ($props.Count -gt 5) { $props[5].Value } else { '(?)' } $targetDomain = if ($props.Count -gt 6) { $props[6].Value } else { '' } $subStatus = if ($props.Count -gt 9) { Format-Hex $props[9].Value } else { '0x00000000' } $logonType = if ($props.Count -gt 10) { [int]$props[10].Value } else { 0 } $sourceIP = if ($props.Count -gt 19) { "$($props[19].Value)" } else { '-' } $workstation = if ($props.Count -gt 13) { "$($props[13].Value)" } else { '' } $source = $sourceIP if ($source -eq '-' -or [string]::IsNullOrWhiteSpace($source)) { $source = if ($workstation) { $workstation } else { '-' } } $results += [PSCustomObject]@{ Time = $evt.TimeCreated TargetUser = if ($targetDomain -and $targetDomain -ne '-') { "$targetDomain\$targetUser" } else { $targetUser } Source = $source LogonType = $logonType SubStatus = $subStatus } } return ,$results } # ============================================================ # ANALYSIS & SCORING # ============================================================ function Analyze-Events { param([array]$Events) $totalCount = $Events.Count # LogonType jest czescia klucza grupowania: bez niego proby RDP i SMB z tego samego zrodla # na to samo konto wpadaja do jednej grupy, a punktacja bierze typ pierwszego # zdarzenia - RDP potrafi wtedy nie dostac swoich punktow i nie podbic werdyktu. $groups = $Events | Group-Object -Property Source, TargetUser, SubStatus, LogonType $analyzed = @() $maxScore = 0 # Credential stuffing detection: 4+ different accounts from same source $sourceTargets = $Events | Group-Object Source | Where-Object { ($_.Group | Select-Object -ExpandProperty TargetUser -Unique).Count -gt 3 } foreach ($g in $groups) { $sample = $g.Group[0] $count = $g.Count $score = 0 $groupReasons = @() # Bare username for privileged check $bareUser = $sample.TargetUser if ($bareUser -match '\\(.+)$') { $bareUser = $Matches[1] } $isPrivileged = $PrivilegedAccounts -contains $bareUser.ToLower() # --- Timing analysis for this group --- # First/Last liczone ZAWSZE (takze dla grupy 1-elementowej) - to one daja # dokladny zakres drukowany pod grupa RED i gotowa wartosc dla $hwin. # @() wymusza tablice; bez tego 1 element wraca jako skalar i indeksowanie klamie. $sortedTimes = @($g.Group | Sort-Object Time | Select-Object -ExpandProperty Time) $firstTime = $sortedTimes[0] $lastTime = $sortedTimes[$sortedTimes.Count - 1] $timingInfo = @{ First = $firstTime Last = $lastTime Span = $lastTime - $firstTime MinInterval = 0 MaxInterval = 0 HasIntervals = $false } if ($count -ge 2) { # Calculate intervals between consecutive events $intervals = @() for ($i = 1; $i -lt $sortedTimes.Count; $i++) { $intervals += ($sortedTimes[$i] - $sortedTimes[$i-1]).TotalSeconds } $timingInfo.MinInterval = ($intervals | Measure-Object -Minimum).Minimum $timingInfo.MaxInterval = ($intervals | Measure-Object -Maximum).Maximum $timingInfo.HasIntervals = $true } # --- SubStatus scoring --- switch ($sample.SubStatus) { '0xC0000064' { if ($isPrivileged) { $score += 2 $groupReasons += "probing privileged account '$bareUser' (doesn't exist)" } } '0xC000006A' { $score += 2 if ($isPrivileged) { $score += 2 $groupReasons += "wrong password on privileged account '$bareUser'" } else { $groupReasons += 'wrong password on existing account' } } '0xC0000234' { $score += 3 if ($isPrivileged) { $score += 2 $groupReasons += "LOCKED OUT privileged account '$bareUser'!" } else { $groupReasons += 'account locked out - brute force result' } } '0xC0000072' { if ($isPrivileged) { $score += 1 $groupReasons += "attempt on disabled privileged account '$bareUser'" } } default { $score += 1 } } # --- LogonType scoring --- if ($sample.LogonType -eq 10) { $score += 3 $groupReasons += 'RDP login attempts' } if ($sample.LogonType -eq 2 -and ($sample.Source -eq '127.0.0.1' -or $sample.Source -eq '-')) { $score -= 2 } # --- Source scoring --- if (-not (Test-PrivateIP $sample.Source)) { $score += 3 $groupReasons += 'external source!' } # --- Volume scoring --- if ($count -ge 20) { $score += 2 } # --- Credential stuffing --- $isCredStuffing = $false if ($sourceTargets | Where-Object { $_.Name -eq $sample.Source }) { $score += 2 $isCredStuffing = $true $groupReasons += 'multiple accounts from same source' } # --- Timing reason (only for significant groups) --- if ($timingInfo.HasIntervals -and $count -ge 3 -and $score -gt 2) { $minStr = Format-IntervalShort $timingInfo.MinInterval $maxStr = Format-IntervalShort $timingInfo.MaxInterval $intervalStr = if ($minStr -eq $maxStr) { "interval ~$minStr" } else { "intervals $minStr - $maxStr" } if ($score -ge 5) { # RED dostaje osobna linie z dokladnym zakresem i rozpietoscia - nie powtarzamy jej tutaj. $groupReasons += "$count attempts ($intervalStr)" } else { $spanStr = Format-Duration $timingInfo.Span $groupReasons += "$count attempts in $spanStr ($intervalStr)" } } if ($score -lt 0) { $score = 0 } $analyzed += [PSCustomObject]@{ Source = $sample.Source TargetUser = $sample.TargetUser BareUser = $bareUser Count = $count LogonType = $sample.LogonType SubStatus = $sample.SubStatus Score = $score Reasons = $groupReasons Timing = $timingInfo } if ($score -gt $maxScore) { $maxScore = $score } } # Global volume check if ($totalCount -ge 50) { $maxScore = [Math]::Max($maxScore, 5) } # Verdict if ($maxScore -le 2) { $verdict = 'GREEN'; $color = 'Green' } elseif ($maxScore -le 4) { $verdict = 'YELLOW'; $color = 'Yellow' } else { $verdict = 'RED'; $color = 'Red' } return [PSCustomObject]@{ TotalEvents = $totalCount Groups = $analyzed MaxScore = $maxScore Verdict = $verdict VerdictColor = $color } } # ============================================================ # OUTPUT # ============================================================ function Show-Header { $border = '=' * 60 Write-Host '' Write-Host $border -ForegroundColor Cyan Write-Host ' Failed Logins Analyzer' -ForegroundColor Cyan Write-Host " Scans Windows failed login attempts (Event 4625) - window: $WindowText" -ForegroundColor DarkGray Write-Host $border -ForegroundColor Cyan } function Show-WindowHint { # Podpowiedz konfiguracji: gotowe linie do wklejenia PRZED komenda irm. # Nie pokazujemy jej, gdy uzytkownik juz zawezil okno przez $hwin - wtedy nie ma czego uczyc. param($Analysis) if ($WinExplicit) { return } $tips = @() if (-not $HoursExplicit) { $tips += ' $hback = 72; irm https://dev.bitback.pl/fla | iex' } # Przyklad $hwin budowany z NAJWYZEJ punktowanej serii RED - jest od razu wykonywalny. $top = $null if ($Analysis -and $Analysis.Groups) { $top = $Analysis.Groups | Where-Object { $_.Score -ge 5 -and $_.Timing } | Sort-Object Score -Descending | Select-Object -First 1 } if ($top) { $w = "$($top.Timing.First.ToString($TimeFmt))..$($top.Timing.Last.ToString($TimeFmt))" $tips += " `$hwin = '$w'; irm https://dev.bitback.pl/fla | iex" } if ($tips.Count -eq 0) { return } Write-Host '' Write-Host ' TIP: set a variable before the command to change the scanned window:' -ForegroundColor Yellow foreach ($t in $tips) { Write-Host $t -ForegroundColor Yellow } } function Show-NextStep { param($Analysis) if (-not $Analysis -or -not $Analysis.Groups -or $Analysis.Groups.Count -eq 0) { return } Write-Host '' Write-Host ' CO DALEJ' -ForegroundColor Cyan Write-Host ' Ten skrypt mowi ILE bylo nieudanych prob i na jakie konto. NIE mowi, co je' -ForegroundColor Cyan Write-Host ' wywolalo: czlowiek przy klawiaturze, usluga ze starym haslem, urzadzenie w sieci.' -ForegroundColor Cyan Write-Host ' Zeby to ustalic: skopiuj CALA linie ">>" spod grupy, ktora Cie interesuje,' -ForegroundColor Cyan Write-Host ' i wklej ja w to samo okno PowerShella. Wynik pokaze kazda probe osobno' -ForegroundColor Cyan Write-Host ' razem z odpowiedzia, kto lub co ja wywolalo.' -ForegroundColor Cyan } # Skrocone z dziewieciu linii do dwoch. Ta sama tresc drukowala sie przy kazdym przebiegu # i zajmowala tyle miejsca, ile potrzebuja informacje diagnostyczne. function Show-Capabilities { Write-Host '' Write-Host ' Wykrywa: brute force, credential stuffing, ataki RDP, blokady kont, zrodla spoza sieci.' -ForegroundColor DarkGray Write-Host ' Werdykty: GREEN (szum) / YELLOW (sprawdz) / RED (eskaluj)' -ForegroundColor DarkGray } # GUID Logon subcategory - jezykowo niezalezny $LogonSubcategoryGuid = '{0CCE9215-69AE-11D9-BED3-505054503030}' function Test-AuditLogonEnabled { # Sprawdza FAKTYCZNE ustawienie polityki przez auditpol /r (CSV). # Format: Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting # Inclusion Setting wartosci - EN: "No Auditing"/"Success"/"Failure"/"Success and Failure" # PL: "Brak inspekcji"/"Powodzenie"/"Niepowodzenie"/"Powodzenie i Niepowodzenie" try { $csv = auditpol /get /subcategory:$LogonSubcategoryGuid /r 2>&1 | ConvertFrom-Csv -ErrorAction Stop if ($csv -is [array]) { $csv = $csv[0] } $setting = ($csv.'Inclusion Setting' -as [string]).Trim() if ($setting -match '(?i)no auditing|brak inspekcji|bez inspekcji') { return $false } if ($setting -match '(?i)success|failure|powodzenie|niepowodzenie') { return $true } return $true } catch { return $true } } function Enable-AuditLogon { # Enable Audit Logon (main goal) $output1 = auditpol /set /subcategory:$LogonSubcategoryGuid /success:enable /failure:enable 2>&1 $exit1 = $LASTEXITCODE # Also enable Audit Policy Change so 4719 events get recorded going forward. # Without this the script can't tell when audit was enabled (cause of current issue). $policyChangeGuid = '{0CCE922F-69AE-11D9-BED3-505054503030}' $output2 = auditpol /set /subcategory:$policyChangeGuid /success:enable 2>&1 $verified = Test-AuditLogonEnabled return [PSCustomObject]@{ Success = ($exit1 -eq 0 -and $verified) Verified = $verified Output = (($output1 + $output2) -join "`n").Trim() } } function Get-RecentAuditLogonActivation { # Looks for Event 4719 (System audit policy was changed) for Logon subcategory. # Returns DateTime of last audit Logon change (when enabled) or $null. try { $changes = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4719; StartTime=(Get-Date).AddDays(-7)} -MaxEvents 50 -ErrorAction Stop foreach ($evt in $changes) { if ($evt.Message -match [Regex]::Escape($LogonSubcategoryGuid)) { return $evt.TimeCreated } } } catch { } return $null } function Test-NoUserLogonsIn7Days { # Heuristic fallback when 4719 is not available. # Query 4624 from last 7 days, check if any user-level LogonType (2/3/7/10/11) exists. # Returns $true if NO user logins found (audit likely freshly enabled or machine unused). $sevenDaysAgo = (Get-Date).AddDays(-7).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") $filterXml = @" <QueryList> <Query Id="0" Path="Security"> <Select Path="Security"> *[System[(EventID=4624) and TimeCreated[@SystemTime>='$sevenDaysAgo']]] </Select> </Query> </QueryList> "@ try { $events = @(Get-WinEvent -FilterXml $filterXml -ErrorAction Stop -MaxEvents 500) $userLogonTypes = @(2, 3, 7, 10, 11) foreach ($evt in $events) { $lt = [int]$evt.Properties[8].Value if ($lt -in $userLogonTypes) { return $false } } return $true } catch { return $true } } function Format-TimeAgo { param([datetime]$When) $delta = (Get-Date) - $When if ($delta.TotalMinutes -lt 1) { return 'less than a minute ago' } if ($delta.TotalMinutes -lt 60) { return "$([Math]::Round($delta.TotalMinutes)) minutes ago" } if ($delta.TotalHours -lt 24) { return "$([Math]::Round($delta.TotalHours, 1))h ago" } return "$([Math]::Round($delta.TotalDays, 1)) days ago" } function Show-AuditDisabledOffer { Write-Host '' Write-Host ' PROBLEM: Audit Logon is likely DISABLED on this computer.' -ForegroundColor Yellow Write-Host ' Windows is not recording logins to Security log - no 4625 does not mean "no attacks",' -ForegroundColor Yellow Write-Host ' it means "Windows does not know if there are any". This is NOT GREEN, this is no data.' -ForegroundColor Yellow Write-Host '' Write-Host ' Enabling takes one command (admin required, you already have it):' -ForegroundColor DarkGray Write-Host " auditpol /set /subcategory:$LogonSubcategoryGuid /success:enable /failure:enable" -ForegroundColor DarkGray Write-Host '' $answer = Read-Host ' Enable now? [Y/N]' if ($answer -match '^[Yy]') { Write-Host '' Write-Host ' Enabling Audit Logon...' -ForegroundColor Cyan $r = Enable-AuditLogon if ($r.Success) { Write-Host ' OK. Audit Logon enabled and verified.' -ForegroundColor Green Write-Host ' From now on Windows will record logins (4624) and failed (4625).' -ForegroundColor Green Write-Host ' Run the script again later to check for attack attempts.' -ForegroundColor Green } elseif (-not $r.Verified) { Write-Host ' Command executed, but audit is STILL disabled after verification.' -ForegroundColor Red Write-Host ' Likely GPO override (Group Policy overrides local audit policy).' -ForegroundColor Red Write-Host ' Check: gpedit.msc -> Computer Config -> Windows Settings -> Security Settings' -ForegroundColor Red Write-Host ' -> Advanced Audit Policy -> Logon/Logoff -> Audit Logon' -ForegroundColor Red Write-Host ' Or domain admin must change the domain policy.' -ForegroundColor Red } else { Write-Host ' Failed to enable:' -ForegroundColor Red Write-Host " $($r.Output)" -ForegroundColor Red Write-Host ' Run the command above manually.' -ForegroundColor Red } } else { Write-Host ' OK. You can enable later manually with the command above.' -ForegroundColor DarkGray } } function Show-Results { param($Analysis, [array]$Events, [hashtable]$ResolvedCache) $border = '=' * 60 Write-Host '' Write-Host " $env:COMPUTERNAME | $($Now.ToString('yyyy-MM-dd HH:mm')) | $WindowText | $($Analysis.TotalEvents) events 4625" -ForegroundColor Cyan if ($Analysis.TotalEvents -eq 0) { Write-Host '' if (-not (Test-AuditLogonEnabled)) { Show-AuditDisabledOffer } else { # Audit is on but 0 events 4625. Check if audit was recently enabled - if so, # GREEN is misleading (we don't know about earlier attacks). # Two signals: (1) Event 4719 precise, (2) heuristic: no user 4624 in 7 days. $recentActivation = Get-RecentAuditLogonActivation $hoursSinceAudit = if ($recentActivation) { ((Get-Date) - $recentActivation).TotalHours } else { 999 } if ($recentActivation -and $hoursSinceAudit -lt $WindowHours) { # 4719 found, recently within analysis window $ago = Format-TimeAgo $recentActivation Write-Host " Audit Logon enabled $ago ($($recentActivation.ToString('yyyy-MM-dd HH:mm:ss')))." -ForegroundColor Cyan Write-Host " Script analyzes a $([Math]::Round($WindowHours, 1))h window but audit has data from $([Math]::Round($hoursSinceAudit, 1))h ago." -ForegroundColor Cyan Write-Host ' No 4625 events SINCE audit was enabled - no failed logins so far.' -ForegroundColor Green Write-Host ' Attacks before audit enable - Windows does not know, cannot check retroactively.' -ForegroundColor DarkGray Show-Capabilities } elseif (-not $recentActivation -and (Test-NoUserLogonsIn7Days)) { # 4719 not available (Audit Policy Change was off), heuristic: no user 4624 in 7 days Write-Host ' Audit Logon is working, but there are NO user logins (4624) in the last 7 days.' -ForegroundColor Cyan Write-Host ' Audit was likely enabled recently (exact moment unrecorded - 4719 not written' -ForegroundColor Cyan Write-Host ' because Audit Policy Change was also disabled).' -ForegroundColor DarkGray Write-Host ' No 4625 events found - no failed logins so far, but data window is short.' -ForegroundColor Green Write-Host " Run the script again in $([Math]::Round($WindowHours, 1))h to get a reliable verdict for the full window." -ForegroundColor DarkGray Show-Capabilities } else { Write-Host ' [GREEN] No failed login attempts - all clear.' -ForegroundColor Green Show-Capabilities } } Show-WindowHint $Analysis Write-Host '' Write-Host $border -ForegroundColor Cyan Write-Host ' Analyze-FailedLogins v2.9' -ForegroundColor Cyan Write-Host $border -ForegroundColor Cyan Write-Host '' return } Write-Host '' # Sort by score descending $sorted = $Analysis.Groups | Sort-Object Score -Descending # Komenda budowana jest z SUROWYCH pol grupy, nie z etykiet ekranowych. Etykieta # zrodla dokleja nazwe hosta i znacznik [EXTERNAL!], a etykieta typu skraca go do # "Kbd" - zaden z tych ciagow nie zadziala po drugiej stronie jako filtr. # Koniec okna jest przesuniety o 15 minut za ostatnia probe, bo udane logowanie po # serii nieudanych pada zwykle tuz po niej, a to wlasnie ono odpowiada na pytanie, # czy ktos w koncu wszedl. # Nazwa konta i nazwa stacji w zdarzeniu 4625 to ciagi, ktore podaje probujacy sie # zalogowac - moga zawierac apostrof i dowolne inne znaki. Komenda ponizej jest # przeznaczona do wklejenia w podniesiona konsole, wiec wartosc przepuszczona bez # kontroli pozwalalaby zamknac apostrof i dopisac wlasne polecenie. Znaki spoza # bezpiecznego zbioru zastepujemy pytajnikiem: komenda zostaje czytelna, a taka # wartosc i tak nie dopasowalaby sie do niczego w dzienniku. function Protect-HandoffValue { param([string]$v) $czyste = $v -replace '[^\w\.\-\\@]', '?' return $czyste.Replace("'", "''") } # Kazda zmienna ustawiana jawnie, takze nieuzywana - logins czyta je z konsoli wolajacego. function Get-HandoffCommand { param($g) $czesci = @() if ($g.Timing) { $od = $g.Timing.First.ToString($TimeFmt) $do = $g.Timing.Last.AddMinutes(15).ToString($TimeFmt) $czesci += "`$hwin='$od..$do'" $czesci += '$hback=$null' } else { $czesci += '$hwin=$null' $czesci += '$hback=168' } if ($g.TargetUser -and $g.TargetUser -ne '-') { $czesci += "`$user='$(Protect-HandoffValue $g.TargetUser)'" } else { $czesci += '$user=$null' } if ($g.Source -and $g.Source -ne '-') { $czesci += "`$source='$(Protect-HandoffValue $g.Source)'" } else { $czesci += '$source=$null' } if ($g.LogonType) { $czesci += "`$ltype=$([int]$g.LogonType)" } else { $czesci += '$ltype=$null' } if ($g.SubStatus -and $g.SubStatus -match '^0x[0-9A-Fa-f]{8}$' -and $g.SubStatus -ne '0x00000000') { $czesci += "`$status='$($g.SubStatus)'" } else { $czesci += '$status=$null' } # Udane logowanie po serii (4624) musi wejsc w wynik - stad okno +15 min i $false tutaj. $czesci += '$failedonly=$false' $czesci += '$group=$false' $czesci += '$count=50' $czesci += '$detail=$true' return ($czesci -join '; ') + '; irm https://dev.bitback.pl/logins | iex' } $HandoffLimit = 6 $handoffShown = 0 $handoffSkipped = 0 foreach ($g in $sorted) { $sourceLabel = Get-SourceLabel $g.Source $ResolvedCache $ssDesc = if ($SubStatusMap.ContainsKey($g.SubStatus)) { $SubStatusMap[$g.SubStatus] } else { $g.SubStatus } $ltShort = if ($LogonTypeShort.ContainsKey($g.LogonType)) { $LogonTypeShort[$g.LogonType] } else { "T$($g.LogonType)" } $groupColor = if ($g.Score -le 2) { 'Gray' } elseif ($g.Score -le 4) { 'Yellow' } else { 'Red' } # Main oneliner: count + type + source -> target + reason $line = " {0}x {1} {2} -> {3} ({4})" -f $g.Count, $ltShort, $sourceLabel, $g.TargetUser, $ssDesc Write-Host $line -ForegroundColor $groupColor # RED: dokladny zakres serii co do sekundy, pierwsza linia pod grupa. # Ten sam ciag wkleja sie jako $hwin, zeby zawezic skrypt do tej jednej serii. if ($g.Score -ge 5 -and $g.Timing) { $rangeStr = "$($g.Timing.First.ToString($TimeFmt))..$($g.Timing.Last.ToString($TimeFmt))" if ($g.Count -ge 2) { Write-Host " -> $rangeStr ($(Format-Duration $g.Timing.Span))" -ForegroundColor $groupColor } else { Write-Host " -> $rangeStr" -ForegroundColor $groupColor } } # Arrows only for score > 2 (YELLOW/RED) if ($g.Score -gt 2 -and $g.Reasons.Count -gt 0) { foreach ($r in $g.Reasons) { Write-Host " -> $r" -ForegroundColor $groupColor } } # Gotowa komenda do skopiowania: przenosi te grupe do skryptu logins, ktory # pokazuje pojedyncze zdarzenia razem z procesem, ktory je wywolal. if ($handoffShown -lt $HandoffLimit) { Write-Host " >> $(Get-HandoffCommand $g)" -ForegroundColor DarkCyan $handoffShown++ } else { $handoffSkipped++ } } if ($handoffSkipped -gt 0) { Write-Host '' Write-Host " ($handoffSkipped grup bez gotowej komendy - pokazano ja dla $HandoffLimit najwyzej punktowanych." -ForegroundColor DarkGray Write-Host ' Zawez okno przez $hwin albo $hback, zeby grup bylo mniej.)' -ForegroundColor DarkGray } # Time distribution (compact) Write-Host '' $hourGroups = $Events | Group-Object { $_.Time.ToString('yyyy-MM-dd HH:00') } | Sort-Object Name foreach ($hg in $hourGroups) { $bar = '#' * [Math]::Min($hg.Count, 50) Write-Host (" {0} {1,4} {2}" -f $hg.Name, $hg.Count, $bar) -ForegroundColor DarkGray } # Verdict - dynamic, based on actual findings Write-Host '' Write-Host $border -ForegroundColor $Analysis.VerdictColor Write-Host " [$($Analysis.Verdict)]" -NoNewline -ForegroundColor $Analysis.VerdictColor switch ($Analysis.Verdict) { 'GREEN' { Write-Host ' Brak oznak ataku z zewnatrz (obce adresy, pulpit zdalny, blokady kont, wiele kont naraz).' -ForegroundColor Green if ($Analysis.TotalEvents -gt 0) { Write-Host ' To NIE znaczy, ze zjawisko jest wyjasnione - przyczyny prob ten skrypt nie widzi.' -ForegroundColor Yellow } Show-Capabilities } 'YELLOW' { Write-Host '' -ForegroundColor Yellow # Build contextual advice $yellowGroups = $Analysis.Groups | Where-Object { $_.Score -gt 2 -and $_.Score -le 4 } foreach ($yg in $yellowGroups) { $ssDesc = if ($SubStatusMap.ContainsKey($yg.SubStatus)) { $SubStatusMap[$yg.SubStatus] } else { $yg.SubStatus } Write-Host " - $($yg.Source) -> $($yg.TargetUser): $ssDesc ($($yg.Count)x)" -ForegroundColor Yellow } Write-Host ' Check if these sources are known devices. If not - escalate.' -ForegroundColor Yellow } 'RED' { Write-Host '' -ForegroundColor Red # Collect specifics from high-scoring groups $redGroups = $Analysis.Groups | Where-Object { $_.Score -ge 5 } $hasWrongPassword = $redGroups | Where-Object { $_.SubStatus -eq '0xC000006A' } $hasLockout = $redGroups | Where-Object { $_.SubStatus -eq '0xC0000234' } $hasExternal = $redGroups | Where-Object { -not (Test-PrivateIP $_.Source) } $hasRDP = $redGroups | Where-Object { $_.LogonType -eq 10 } $hasCredStuffing = ($Analysis.Groups | Where-Object { $_.Reasons -match 'multiple accounts' }).Count -gt 0 $attackedExistingAccounts = $redGroups | Where-Object { $_.SubStatus -eq '0xC000006A' } | Select-Object -ExpandProperty TargetUser -Unique $externalSources = $redGroups | Where-Object { -not (Test-PrivateIP $_.Source) } | Select-Object -ExpandProperty Source -Unique # WHY it's red Write-Host ' Why:' -ForegroundColor Red if ($hasCredStuffing) { Write-Host ' - Multiple accounts targeted from same source (credential stuffing pattern)' -ForegroundColor Red } if ($hasWrongPassword) { Write-Host " - Wrong password attempts on existing accounts: $($attackedExistingAccounts -join ', ')" -ForegroundColor Red } if ($hasLockout) { $lockedAccounts = $hasLockout | Select-Object -ExpandProperty TargetUser -Unique Write-Host " - Accounts locked out: $($lockedAccounts -join ', ')" -ForegroundColor Red } if ($hasRDP) { Write-Host ' - RDP login attempts detected (high-risk attack vector)' -ForegroundColor Red } if ($hasExternal) { Write-Host " - External source(s): $($externalSources -join ', ')" -ForegroundColor Red } # WHAT TO DO - only relevant actions Write-Host '' -ForegroundColor Red Write-Host ' Action:' -ForegroundColor Red if ($hasExternal) { Write-Host " - Block external IP(s) on firewall: $($externalSources -join ', ')" -ForegroundColor Red } if ($hasWrongPassword -and $attackedExistingAccounts) { Write-Host " - Change passwords on targeted accounts: $($attackedExistingAccounts -join ', ')" -ForegroundColor Red } if ($hasLockout) { Write-Host ' - Review locked accounts - unlock only after confirming source is blocked' -ForegroundColor Red } if ($hasRDP) { Write-Host ' - Check if RDP is exposed to internet - restrict via firewall/VPN' -ForegroundColor Red } } } Show-NextStep $Analysis Show-WindowHint $Analysis Write-Host '' Write-Host $border -ForegroundColor Cyan Write-Host ' Analyze-FailedLogins v2.9' -ForegroundColor Cyan Write-Host $border -ForegroundColor Cyan Write-Host '' } # ============================================================ # MAIN # ============================================================ $resolvedCache = @{} Show-Header Write-Host '' Write-Host " Reading $WindowText ..." -ForegroundColor Cyan $events = Get-FailedLogonEvents if ($null -eq $events) { Write-Host '' Write-Host ' Cannot continue - see error above.' -ForegroundColor Red Write-Host '' return } if ($events.Count -eq 0) { $emptyAnalysis = [PSCustomObject]@{ TotalEvents = 0; Groups = @(); MaxScore = 0 Verdict = 'GREEN'; VerdictColor = 'Green' } Show-Results $emptyAnalysis @() $resolvedCache return } Write-Host " Found $($events.Count) events. Analyzing..." -ForegroundColor Cyan $analysis = Analyze-Events $events Show-Results $analysis $events $resolvedCache