BitBack ← wszystkie skrypty

/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

Wyróżniony prefiks ustawia parametry - możesz zmienić wartość albo usunąć go i uruchomić na ustawieniach domyślnych.

43.1 kB · 979 linii · zaktualizowano 2026-10-13
● Poniżej pełne, niezmienione źródło. Przeczytaj przed uruchomieniem.
  1. # ===
  2. # Analiza nieudanych prób logowania (zdarzenie 4625) z dziennika Windows.
  3. # Wykrywa brute force, credential stuffing, ataki RDP, zablokowane konta i źródła spoza sieci.
  4. # Tylko odczyt. Zakres: $hback (godziny wstecz) albo $hwin - gotowy $hwin skrypt podpowie sam przy wpisach na czerwono.
  5. # ===
  6. # bitback-prefix: $hback = 72;
  7. # Analyze-FailedLogins.ps1
  8. # Failed login attempts analyzer for Windows Security Event Log
  9. # Usage: irm https://dev.bitback.pl/fla | iex
  10. # Time window - set a variable BEFORE running, $hwin wins when both are given:
  11. # $hback = 72; irm https://dev.bitback.pl/fla | iex
  12. # $hwin = '2026-07-22T14:03:11..2026-07-23T13:48:55'; irm https://dev.bitback.pl/fla | iex
  13. # Automation (RMM, Acronis job as SYSTEM) - never ask, never change audit policy:
  14. # $noprompt = $true; irm https://dev.bitback.pl/fla | iex
  15. # Requires: Run as Administrator (Security log access)
  16.  
  17. #Requires -Version 5.1
  18.  
  19. # ============================================================
  20. # CONFIG
  21. # ============================================================
  22. # Two ways to pick the scanned window. Both come from the caller's scope
  23. # (irm | iex runs in it), so nothing here may overwrite them unconditionally.
  24. # $hback - whole hours back from now (default 24)
  25. # $hwin - exact range 'yyyy-MM-ddTHH:mm:ss..yyyy-MM-ddTHH:mm:ss'; takes precedence
  26. # over $hback. Nie trzeba go skladac recznie - skrypt drukuje gotowy
  27. # zakres pod kazdym wpisem oznaczonym na czerwono, wystarczy go wkleic.
  28. $Now = Get-Date
  29. $HoursExplicit = $false
  30. $WinExplicit = $false
  31. $WindowLabel = ''
  32. $TimeFmt = 'yyyy-MM-ddTHH:mm:ss'
  33.  
  34. if ($null -ne $hwin -and "$hwin".Trim() -ne '') {
  35. $rawWin = "$hwin".Trim().Trim("'", '"')
  36. $winParts = $rawWin -split '\.\.'
  37. $ci = [Globalization.CultureInfo]::InvariantCulture
  38. $winFrom = [datetime]::MinValue
  39. $winTo = [datetime]::MinValue
  40. if ($winParts.Count -eq 2 -and
  41. [datetime]::TryParseExact($winParts[0].Trim(), $TimeFmt, $ci, [Globalization.DateTimeStyles]::None, [ref]$winFrom) -and
  42. [datetime]::TryParseExact($winParts[1].Trim(), $TimeFmt, $ci, [Globalization.DateTimeStyles]::None, [ref]$winTo)) {
  43. if ($winTo -lt $winFrom) { $swap = $winFrom; $winFrom = $winTo; $winTo = $swap }
  44. $StartTime = $winFrom
  45. # End inclusive to the full second: the printed range is truncated to seconds,
  46. # so an event at .900 must stay inside the window it was suggested from.
  47. $EndTime = $winTo.AddSeconds(1).AddTicks(-1)
  48. $WinExplicit = $true
  49. $WindowLabel = "$($winFrom.ToString($TimeFmt))..$($winTo.ToString($TimeFmt))"
  50. } else {
  51. Write-Host ''
  52. Write-Host " Ignoring `$hwin = '$hwin' - expected 'yyyy-MM-ddTHH:mm:ss..yyyy-MM-ddTHH:mm:ss'." -ForegroundColor Yellow
  53. }
  54. }
  55.  
  56. # Godziny trzymamy w zmiennej wlasnej, a $hback z sesji zostaje nietkniete. Skrypt biegnie
  57. # w zakresie wywolujacego, wiec nadpisanie $hback zostawaloby w konsoli technika i zmienialo
  58. # domyslne okno kolejnego uruchomienia - takze skryptu logins, ktory czyta te sama zmienna.
  59. $HoursBack = 24
  60. if (-not $WinExplicit) {
  61. if ($null -ne $hback -and "$hback".Trim() -ne '') {
  62. $parsedH = 0
  63. if ([int]::TryParse("$hback".Trim(), [ref]$parsedH) -and $parsedH -ge 1 -and $parsedH -le 8760) {
  64. $HoursBack = $parsedH
  65. $HoursExplicit = $true
  66. } else {
  67. Write-Host ''
  68. Write-Host " Ignoring `$hback = '$hback' - expected whole hours in range 1-8760. Falling back to 24h." -ForegroundColor Yellow
  69. }
  70. }
  71. $StartTime = $Now.AddHours(-$HoursBack)
  72. $EndTime = $Now
  73. }
  74.  
  75. # Used wherever the window has to be named or measured, regardless of how it was set.
  76. $WindowText = if ($WinExplicit) { $WindowLabel } else { "last ${HoursBack}h" }
  77. $WindowHours = ($EndTime - $StartTime).TotalHours
  78.  
  79. # Czy wolno zapytac o wlaczenie audytu. To jedyne miejsce, w ktorym fla moze zmienic stan
  80. # maszyny, wiec przy kazdej watpliwosci NIE pytamy - koszt pomylki w te strone to tylko
  81. # komenda do recznego wpisania, a w druga strone zawieszone zadanie albo zmiana polityki
  82. # audytu u klienta bez decyzji. Pewna droga dla automatu jest jawne $noprompt = $true.
  83. # Nazwa wewnetrzna inna niz $noprompt, bo PowerShell nie rozroznia wielkosci liter
  84. # w nazwach zmiennych i nadpisalby wartosc z sesji wolajacego.
  85. # Osobna zmienna, a nie $interactive jak w tv: oba skrypty biegna w tej samej konsoli
  86. # technika, wiec wspolna nazwa przenosilaby tryb z jednego skryptu do drugiego.
  87. #
  88. # Kazdy sygnal moze pytanie tylko WYLACZYC. Nie zgadujemy z konta (SYSTEM): zdalna konsola
  89. # Acronisa tez dziala jako SYSTEM, a operator siedzi przy niej i moze odpowiedziec - na tym
  90. # polegl tv (W#020). UserInteractive = True nie dowodzi konsoli (zmierzone przy tv), ale
  91. # False wyklucza ja na pewno. Funkcja stoi tu, a nie w sekcji funkcji, bo iex wykonuje
  92. # skrypt od gory i wolanie przed definicja konczyloby sie bledem.
  93. function Test-CanPrompt {
  94. param($NoPromptRequested, [bool]$UserInteractive, [bool]$InputRedirected, [string[]]$CommandLine)
  95. # $true zamienia sie w tekst 'True'; przyjmujemy tez to, co technik wpisze z reki.
  96. if (@('true', '1', 'tak', 'yes', 't', 'y') -contains "$NoPromptRequested".Trim().ToLower()) { return $false }
  97. if (-not $UserInteractive) { return $false }
  98. if ($InputRedirected) { return $false }
  99. # -NonInteractive przyjmuje skroty (-noni, -nonin...), tez z ukosnikiem.
  100. if (@($CommandLine | Where-Object { $_ -match '^[-/]noni' }).Count -gt 0) { return $false }
  101. return $true
  102. }
  103. $redirected = $false
  104. try { $redirected = [Console]::IsInputRedirected } catch { $redirected = $false }
  105. $PromptAllowed = Test-CanPrompt -NoPromptRequested $noprompt `
  106. -UserInteractive ([Environment]::UserInteractive) `
  107. -InputRedirected $redirected `
  108. -CommandLine ([Environment]::GetCommandLineArgs())
  109.  
  110. # ============================================================
  111. # LOOKUPS
  112. # ============================================================
  113. $SubStatusMap = @{
  114. '0xC0000064' = 'account does not exist'
  115. '0xC000006A' = 'wrong password'
  116. '0xC0000234' = 'account locked out'
  117. '0xC0000072' = 'account disabled'
  118. '0xC000006D' = 'generic logon failure'
  119. '0xC0000071' = 'password expired'
  120. '0xC000006F' = 'outside allowed hours'
  121. '0xC0000070' = 'unauthorized workstation'
  122. '0xC0000193' = 'account expired'
  123. '0xC0000224' = 'password must change'
  124. }
  125.  
  126. # Typ 2 nie dowodzi klawiatury, wiec skrot mowi tylko o lokalnosci proby.
  127. $LogonTypeShort = @{
  128. 2 = 'Lokal'
  129. 3 = 'SMB'
  130. 4 = 'Batch'
  131. 5 = 'Svc'
  132. 7 = 'Unlock'
  133. 8 = 'NetClr'
  134. 9 = 'RunAs'
  135. 10 = 'RDP'
  136. 11 = 'Cache'
  137. }
  138.  
  139. $PrivilegedAccounts = @(
  140. 'administrator', 'admin', 'root', 'sa',
  141. 'guest', 'test', 'user', 'backup',
  142. 'krbtgt', 'defaultaccount'
  143. )
  144.  
  145. # ============================================================
  146. # HELPER FUNCTIONS
  147. # ============================================================
  148.  
  149. function Format-Hex {
  150. param([object]$Value)
  151. if ($null -eq $Value) { return '0x00000000' }
  152. if ($Value -is [string]) {
  153. if ($Value -match '^0x') { return $Value.ToUpper().Replace('0X','0x') }
  154. return '0x00000000'
  155. }
  156. # Dziennik oddaje SubStatus jako Int32 z ustawionym bitem znaku: 0xC000006A przychodzi
  157. # jako -1073741718 (zmierzone na zdarzeniu 4625, PowerShell 5.1). Maska na Int64
  158. # obsluguje te postac oraz wariant bez znaku, bo LogonType z tego samego zdarzenia
  159. # przychodzi juz jako UInt32 - typy pol nie sa jednolite i lepiej nie zakladac ktorego.
  160. try {
  161. $u = [uint32]([int64]$Value -band 4294967295L)
  162. return '0x{0:X8}' -f $u
  163. } catch {
  164. return '0x00000000'
  165. }
  166. }
  167.  
  168. function Test-PrivateIP {
  169. param([string]$IP)
  170. if ([string]::IsNullOrWhiteSpace($IP) -or $IP -eq '-') { return $true }
  171. try {
  172. $parsed = [System.Net.IPAddress]::Parse($IP)
  173. if ($parsed.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) {
  174. if ($parsed.IsIPv6LinkLocal) { return $true }
  175. if ($parsed.IsIPv6SiteLocal) { return $true }
  176. if ([System.Net.IPAddress]::IsLoopback($parsed)) { return $true }
  177. $firstByte = $parsed.GetAddressBytes()[0]
  178. if ($firstByte -eq 0xFC -or $firstByte -eq 0xFD) { return $true }
  179. return $false
  180. }
  181. $bytes = $parsed.GetAddressBytes()
  182. if ($bytes[0] -eq 10) { return $true }
  183. if ($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) { return $true }
  184. if ($bytes[0] -eq 192 -and $bytes[1] -eq 168) { return $true }
  185. if ($bytes[0] -eq 127) { return $true }
  186. if ($bytes[0] -eq 169 -and $bytes[1] -eq 254) { return $true }
  187. return $false
  188. } catch {
  189. return $true
  190. }
  191. }
  192.  
  193. function Resolve-HostnameQuick {
  194. param([string]$IP)
  195. if ([string]::IsNullOrWhiteSpace($IP) -or $IP -eq '-' -or $IP -eq '127.0.0.1') { return '' }
  196. if ($IP -notmatch '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') { return '' }
  197. try {
  198. $job = Start-Job -ScriptBlock { param($i) nbtstat -a $i 2>&1 } -ArgumentList $IP
  199. $result = $job | Wait-Job -Timeout 3 | Receive-Job 2>$null
  200. Remove-Job $job -Force -ErrorAction SilentlyContinue
  201. if ($result) {
  202. $lines = $result -split "`n" | Where-Object { $_ -match '<00>\s+UNIQUE' }
  203. if ($lines) {
  204. $name = ($lines[0] -split '\s+')[0].Trim()
  205. if ($name -and $name -ne '') { return $name }
  206. }
  207. }
  208. } catch { }
  209. return ''
  210. }
  211.  
  212. function Get-SourceLabel {
  213. param([string]$IP, [hashtable]$ResolvedCache)
  214. if ([string]::IsNullOrWhiteSpace($IP) -or $IP -eq '-') { return '(local)' }
  215. if ($IP -eq '127.0.0.1') { return 'localhost' }
  216.  
  217. $isPrivate = Test-PrivateIP $IP
  218. $label = $IP
  219.  
  220. if ($isPrivate -and $IP -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') {
  221. if ($ResolvedCache.ContainsKey($IP)) {
  222. $hostname = $ResolvedCache[$IP]
  223. } else {
  224. $hostname = Resolve-HostnameQuick $IP
  225. $ResolvedCache[$IP] = $hostname
  226. }
  227. if ($hostname) { $label = "$IP ($hostname)" }
  228. }
  229.  
  230. if (-not $isPrivate) { $label += ' [EXTERNAL!]' }
  231. return $label
  232. }
  233.  
  234. function Format-Duration {
  235. param([timespan]$Duration)
  236. if ($Duration.TotalHours -ge 1) {
  237. return "{0:0}h {1}min" -f [Math]::Floor($Duration.TotalHours), $Duration.Minutes
  238. } elseif ($Duration.TotalMinutes -ge 1) {
  239. return "{0}min" -f [Math]::Ceiling($Duration.TotalMinutes)
  240. } else {
  241. return "{0}sec" -f [Math]::Ceiling($Duration.TotalSeconds)
  242. }
  243. }
  244.  
  245. function Format-IntervalShort {
  246. param([double]$Seconds)
  247. if ($Seconds -ge 3600) { return "{0:0}h" -f ($Seconds / 3600) }
  248. if ($Seconds -ge 60) { return "{0:0}min" -f ($Seconds / 60) }
  249. return "{0:0}sec" -f $Seconds
  250. }
  251.  
  252. # ============================================================
  253. # DATA COLLECTION
  254. # ============================================================
  255.  
  256. function Get-FailedLogonEvents {
  257. $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
  258. $principal = New-Object Security.Principal.WindowsPrincipal($identity)
  259. if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
  260. Write-Host ''
  261. Write-Host ' !! ERROR: Administrator privileges required !!' -ForegroundColor Red
  262. Write-Host ' Run PowerShell as Administrator.' -ForegroundColor Yellow
  263. Write-Host ''
  264. return $null
  265. }
  266.  
  267. try {
  268. $filterXml = @"
  269. <QueryList>
  270. <Query Id="0" Path="Security">
  271. <Select Path="Security">
  272. *[System[(EventID=4625) and TimeCreated[@SystemTime&gt;='$($StartTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"))' and @SystemTime&lt;='$($EndTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"))']]]
  273. </Select>
  274. </Query>
  275. </QueryList>
  276. "@
  277. $events = Get-WinEvent -FilterXml $filterXml -ErrorAction Stop
  278. } catch [Exception] {
  279. if ($_.Exception.Message -match 'No events were found') {
  280. return ,@()
  281. }
  282. Write-Host " ERROR reading log: $($_.Exception.Message)" -ForegroundColor Red
  283. return $null
  284. }
  285.  
  286. $results = @()
  287. foreach ($evt in $events) {
  288. $props = $evt.Properties
  289. # 4625 Properties: [5]=TargetUserName [6]=TargetDomainName [7]=Status [9]=SubStatus
  290. # [10]=LogonType [13]=WorkstationName [19]=IpAddress
  291.  
  292. $targetUser = if ($props.Count -gt 5) { $props[5].Value } else { '(?)' }
  293. $targetDomain = if ($props.Count -gt 6) { $props[6].Value } else { '' }
  294. $subStatus = if ($props.Count -gt 9) { Format-Hex $props[9].Value } else { '0x00000000' }
  295. $logonType = if ($props.Count -gt 10) { [int]$props[10].Value } else { 0 }
  296. $sourceIP = if ($props.Count -gt 19) { "$($props[19].Value)" } else { '-' }
  297. $workstation = if ($props.Count -gt 13) { "$($props[13].Value)" } else { '' }
  298.  
  299. $source = $sourceIP
  300. if ($source -eq '-' -or [string]::IsNullOrWhiteSpace($source)) {
  301. $source = if ($workstation) { $workstation } else { '-' }
  302. }
  303.  
  304. $results += [PSCustomObject]@{
  305. Time = $evt.TimeCreated
  306. TargetUser = if ($targetDomain -and $targetDomain -ne '-') { "$targetDomain\$targetUser" } else { $targetUser }
  307. Source = $source
  308. LogonType = $logonType
  309. SubStatus = $subStatus
  310. }
  311. }
  312. return ,$results
  313. }
  314.  
  315. # ============================================================
  316. # ANALYSIS & SCORING
  317. # ============================================================
  318.  
  319. function Analyze-Events {
  320. param([array]$Events)
  321.  
  322. $totalCount = $Events.Count
  323. # LogonType jest czescia klucza grupowania: bez niego proby RDP i SMB z tego samego zrodla
  324. # na to samo konto wpadaja do jednej grupy, a punktacja bierze typ pierwszego
  325. # zdarzenia - RDP potrafi wtedy nie dostac swoich punktow i nie podbic werdyktu.
  326. $groups = $Events | Group-Object -Property Source, TargetUser, SubStatus, LogonType
  327.  
  328. $analyzed = @()
  329. $maxScore = 0
  330.  
  331. # Credential stuffing detection: 4+ different accounts from same source
  332. $sourceTargets = $Events | Group-Object Source | Where-Object {
  333. ($_.Group | Select-Object -ExpandProperty TargetUser -Unique).Count -gt 3
  334. }
  335.  
  336. foreach ($g in $groups) {
  337. $sample = $g.Group[0]
  338. $count = $g.Count
  339. $score = 0
  340. $groupReasons = @()
  341.  
  342. # Bare username for privileged check
  343. $bareUser = $sample.TargetUser
  344. if ($bareUser -match '\\(.+)$') { $bareUser = $Matches[1] }
  345. $isPrivileged = $PrivilegedAccounts -contains $bareUser.ToLower()
  346.  
  347. # --- Timing analysis for this group ---
  348. # First/Last liczone ZAWSZE (takze dla grupy 1-elementowej) - to one daja
  349. # dokladny zakres drukowany pod grupa RED i gotowa wartosc dla $hwin.
  350. # @() wymusza tablice; bez tego 1 element wraca jako skalar i indeksowanie klamie.
  351. $sortedTimes = @($g.Group | Sort-Object Time | Select-Object -ExpandProperty Time)
  352. $firstTime = $sortedTimes[0]
  353. $lastTime = $sortedTimes[$sortedTimes.Count - 1]
  354. $timingInfo = @{
  355. First = $firstTime
  356. Last = $lastTime
  357. Span = $lastTime - $firstTime
  358. MinInterval = 0
  359. MaxInterval = 0
  360. HasIntervals = $false
  361. }
  362. if ($count -ge 2) {
  363. # Calculate intervals between consecutive events
  364. $intervals = @()
  365. for ($i = 1; $i -lt $sortedTimes.Count; $i++) {
  366. $intervals += ($sortedTimes[$i] - $sortedTimes[$i-1]).TotalSeconds
  367. }
  368. $timingInfo.MinInterval = ($intervals | Measure-Object -Minimum).Minimum
  369. $timingInfo.MaxInterval = ($intervals | Measure-Object -Maximum).Maximum
  370. $timingInfo.HasIntervals = $true
  371. }
  372.  
  373. # --- SubStatus scoring ---
  374. switch ($sample.SubStatus) {
  375. '0xC0000064' {
  376. if ($isPrivileged) {
  377. $score += 2
  378. $groupReasons += "probing privileged account '$bareUser' (doesn't exist)"
  379. }
  380. }
  381. '0xC000006A' {
  382. $score += 2
  383. if ($isPrivileged) {
  384. $score += 2
  385. $groupReasons += "wrong password on privileged account '$bareUser'"
  386. } else {
  387. $groupReasons += 'wrong password on existing account'
  388. }
  389. }
  390. '0xC0000234' {
  391. $score += 3
  392. if ($isPrivileged) {
  393. $score += 2
  394. $groupReasons += "LOCKED OUT privileged account '$bareUser'!"
  395. } else {
  396. $groupReasons += 'account locked out - brute force result'
  397. }
  398. }
  399. '0xC0000072' {
  400. if ($isPrivileged) {
  401. $score += 1
  402. $groupReasons += "attempt on disabled privileged account '$bareUser'"
  403. }
  404. }
  405. default {
  406. $score += 1
  407. }
  408. }
  409.  
  410. # --- LogonType scoring ---
  411. if ($sample.LogonType -eq 10) {
  412. $score += 3
  413. $groupReasons += 'RDP login attempts'
  414. }
  415. if ($sample.LogonType -eq 2 -and ($sample.Source -eq '127.0.0.1' -or $sample.Source -eq '-')) {
  416. $score -= 2
  417. }
  418.  
  419. # --- Source scoring ---
  420. if (-not (Test-PrivateIP $sample.Source)) {
  421. $score += 3
  422. $groupReasons += 'external source!'
  423. }
  424.  
  425. # --- Volume scoring ---
  426. if ($count -ge 20) {
  427. $score += 2
  428. }
  429.  
  430. # --- Credential stuffing ---
  431. $isCredStuffing = $false
  432. if ($sourceTargets | Where-Object { $_.Name -eq $sample.Source }) {
  433. $score += 2
  434. $isCredStuffing = $true
  435. $groupReasons += 'multiple accounts from same source'
  436. }
  437.  
  438. # --- Timing reason (only for significant groups) ---
  439. if ($timingInfo.HasIntervals -and $count -ge 3 -and $score -gt 2) {
  440. $minStr = Format-IntervalShort $timingInfo.MinInterval
  441. $maxStr = Format-IntervalShort $timingInfo.MaxInterval
  442. $intervalStr = if ($minStr -eq $maxStr) { "interval ~$minStr" } else { "intervals $minStr - $maxStr" }
  443. if ($score -ge 5) {
  444. # RED dostaje osobna linie z dokladnym zakresem i rozpietoscia - nie powtarzamy jej tutaj.
  445. $groupReasons += "$count attempts ($intervalStr)"
  446. } else {
  447. $spanStr = Format-Duration $timingInfo.Span
  448. $groupReasons += "$count attempts in $spanStr ($intervalStr)"
  449. }
  450. }
  451.  
  452. if ($score -lt 0) { $score = 0 }
  453.  
  454. $analyzed += [PSCustomObject]@{
  455. Source = $sample.Source
  456. TargetUser = $sample.TargetUser
  457. BareUser = $bareUser
  458. Count = $count
  459. LogonType = $sample.LogonType
  460. SubStatus = $sample.SubStatus
  461. Score = $score
  462. Reasons = $groupReasons
  463. Timing = $timingInfo
  464. }
  465.  
  466. if ($score -gt $maxScore) { $maxScore = $score }
  467. }
  468.  
  469. # Global volume check
  470. if ($totalCount -ge 50) {
  471. $maxScore = [Math]::Max($maxScore, 5)
  472. }
  473.  
  474. # Verdict
  475. if ($maxScore -le 2) {
  476. $verdict = 'GREEN'; $color = 'Green'
  477. } elseif ($maxScore -le 4) {
  478. $verdict = 'YELLOW'; $color = 'Yellow'
  479. } else {
  480. $verdict = 'RED'; $color = 'Red'
  481. }
  482.  
  483. return [PSCustomObject]@{
  484. TotalEvents = $totalCount
  485. Groups = $analyzed
  486. MaxScore = $maxScore
  487. Verdict = $verdict
  488. VerdictColor = $color
  489. }
  490. }
  491.  
  492. # ============================================================
  493. # OUTPUT
  494. # ============================================================
  495.  
  496. function Show-Header {
  497. $border = '=' * 60
  498. Write-Host ''
  499. Write-Host $border -ForegroundColor Cyan
  500. Write-Host ' Failed Logins Analyzer' -ForegroundColor Cyan
  501. Write-Host " Scans Windows failed login attempts (Event 4625) - window: $WindowText" -ForegroundColor DarkGray
  502. Write-Host $border -ForegroundColor Cyan
  503. }
  504.  
  505. function Show-WindowHint {
  506. # Podpowiedz konfiguracji: gotowe linie do wklejenia PRZED komenda irm.
  507. # Nie pokazujemy jej, gdy uzytkownik juz zawezil okno przez $hwin - wtedy nie ma czego uczyc.
  508. param($Analysis)
  509. if ($WinExplicit) { return }
  510.  
  511. $tips = @()
  512. if (-not $HoursExplicit) {
  513. $tips += ' $hback = 72; irm https://dev.bitback.pl/fla | iex'
  514. }
  515. # Przyklad $hwin budowany z NAJWYZEJ punktowanej serii RED - jest od razu wykonywalny.
  516. $top = $null
  517. if ($Analysis -and $Analysis.Groups) {
  518. $top = $Analysis.Groups |
  519. Where-Object { $_.Score -ge 5 -and $_.Timing } |
  520. Sort-Object Score -Descending | Select-Object -First 1
  521. }
  522. if ($top) {
  523. $w = "$($top.Timing.First.ToString($TimeFmt))..$($top.Timing.Last.ToString($TimeFmt))"
  524. $tips += " `$hwin = '$w'; irm https://dev.bitback.pl/fla | iex"
  525. }
  526. if ($tips.Count -eq 0) { return }
  527.  
  528. Write-Host ''
  529. Write-Host ' TIP: set a variable before the command to change the scanned window:' -ForegroundColor Yellow
  530. foreach ($t in $tips) { Write-Host $t -ForegroundColor Yellow }
  531. }
  532.  
  533. function Show-NextStep {
  534. param($Analysis)
  535. if (-not $Analysis -or -not $Analysis.Groups -or $Analysis.Groups.Count -eq 0) { return }
  536.  
  537. Write-Host ''
  538. Write-Host ' CO DALEJ' -ForegroundColor Cyan
  539. Write-Host ' Ten skrypt mowi ILE bylo nieudanych prob i na jakie konto. NIE mowi, co je' -ForegroundColor Cyan
  540. Write-Host ' wywolalo: czlowiek przy klawiaturze, usluga ze starym haslem, urzadzenie w sieci.' -ForegroundColor Cyan
  541. Write-Host ' Zeby to ustalic: skopiuj CALA linie ">>" spod grupy, ktora Cie interesuje,' -ForegroundColor Cyan
  542. Write-Host ' i wklej ja w to samo okno PowerShella. Wynik pokaze kazda probe osobno' -ForegroundColor Cyan
  543. Write-Host ' razem z odpowiedzia, kto lub co ja wywolalo.' -ForegroundColor Cyan
  544. }
  545.  
  546. # Skrocone z dziewieciu linii do dwoch. Ta sama tresc drukowala sie przy kazdym przebiegu
  547. # i zajmowala tyle miejsca, ile potrzebuja informacje diagnostyczne.
  548. function Show-Capabilities {
  549. Write-Host ''
  550. Write-Host ' Wykrywa: brute force, credential stuffing, ataki RDP, blokady kont, zrodla spoza sieci.' -ForegroundColor DarkGray
  551. Write-Host ' Werdykty: GREEN (szum) / YELLOW (sprawdz) / RED (eskaluj)' -ForegroundColor DarkGray
  552. }
  553.  
  554. # GUID Logon subcategory - jezykowo niezalezny
  555. $LogonSubcategoryGuid = '{0CCE9215-69AE-11D9-BED3-505054503030}'
  556.  
  557. function Test-AuditLogonEnabled {
  558. # Sprawdza FAKTYCZNE ustawienie polityki przez auditpol /r (CSV).
  559. # Format: Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting
  560. # Inclusion Setting wartosci - EN: "No Auditing"/"Success"/"Failure"/"Success and Failure"
  561. # PL: "Brak inspekcji"/"Powodzenie"/"Niepowodzenie"/"Powodzenie i Niepowodzenie"
  562. try {
  563. $csv = auditpol /get /subcategory:$LogonSubcategoryGuid /r 2>&1 | ConvertFrom-Csv -ErrorAction Stop
  564. if ($csv -is [array]) { $csv = $csv[0] }
  565. $setting = ($csv.'Inclusion Setting' -as [string]).Trim()
  566. if ($setting -match '(?i)no auditing|brak inspekcji|bez inspekcji') { return $false }
  567. if ($setting -match '(?i)success|failure|powodzenie|niepowodzenie') { return $true }
  568. return $true
  569. } catch {
  570. return $true
  571. }
  572. }
  573.  
  574. function Enable-AuditLogon {
  575. # Enable Audit Logon (main goal)
  576. $output1 = auditpol /set /subcategory:$LogonSubcategoryGuid /success:enable /failure:enable 2>&1
  577. $exit1 = $LASTEXITCODE
  578. # Also enable Audit Policy Change so 4719 events get recorded going forward.
  579. # Without this the script can't tell when audit was enabled (cause of current issue).
  580. $policyChangeGuid = '{0CCE922F-69AE-11D9-BED3-505054503030}'
  581. $output2 = auditpol /set /subcategory:$policyChangeGuid /success:enable 2>&1
  582. $verified = Test-AuditLogonEnabled
  583. return [PSCustomObject]@{
  584. Success = ($exit1 -eq 0 -and $verified)
  585. Verified = $verified
  586. Output = (($output1 + $output2) -join "`n").Trim()
  587. }
  588. }
  589.  
  590. function Get-RecentAuditLogonActivation {
  591. # Looks for Event 4719 (System audit policy was changed) for Logon subcategory.
  592. # Returns DateTime of last audit Logon change (when enabled) or $null.
  593. try {
  594. $changes = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4719; StartTime=(Get-Date).AddDays(-7)} -MaxEvents 50 -ErrorAction Stop
  595. foreach ($evt in $changes) {
  596. if ($evt.Message -match [Regex]::Escape($LogonSubcategoryGuid)) {
  597. return $evt.TimeCreated
  598. }
  599. }
  600. } catch { }
  601. return $null
  602. }
  603.  
  604. function Test-NoUserLogonsIn7Days {
  605. # Heuristic fallback when 4719 is not available.
  606. # Query 4624 from last 7 days, check if any user-level LogonType (2/3/7/10/11) exists.
  607. # Returns $true if NO user logins found (audit likely freshly enabled or machine unused).
  608. $sevenDaysAgo = (Get-Date).AddDays(-7).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
  609. $filterXml = @"
  610. <QueryList>
  611. <Query Id="0" Path="Security">
  612. <Select Path="Security">
  613. *[System[(EventID=4624) and TimeCreated[@SystemTime&gt;='$sevenDaysAgo']]]
  614. </Select>
  615. </Query>
  616. </QueryList>
  617. "@
  618. try {
  619. $events = @(Get-WinEvent -FilterXml $filterXml -ErrorAction Stop -MaxEvents 500)
  620. $userLogonTypes = @(2, 3, 7, 10, 11)
  621. foreach ($evt in $events) {
  622. $lt = [int]$evt.Properties[8].Value
  623. if ($lt -in $userLogonTypes) { return $false }
  624. }
  625. return $true
  626. } catch {
  627. return $true
  628. }
  629. }
  630.  
  631. function Format-TimeAgo {
  632. param([datetime]$When)
  633. $delta = (Get-Date) - $When
  634. if ($delta.TotalMinutes -lt 1) { return 'less than a minute ago' }
  635. if ($delta.TotalMinutes -lt 60) { return "$([Math]::Round($delta.TotalMinutes)) minutes ago" }
  636. if ($delta.TotalHours -lt 24) { return "$([Math]::Round($delta.TotalHours, 1))h ago" }
  637. return "$([Math]::Round($delta.TotalDays, 1)) days ago"
  638. }
  639.  
  640. function Show-AuditDisabledOffer {
  641. Write-Host ''
  642. # Znacznik w tym samym rekordzie co zdanie - patrz komentarz przy werdykcie w Show-Results.
  643. Write-Host ' [NO-DATA] PROBLEM: Audit Logon is likely DISABLED on this computer.' -ForegroundColor Yellow
  644. Write-Host ' Windows is not recording logins to Security log - no 4625 does not mean "no attacks",' -ForegroundColor Yellow
  645. Write-Host ' it means "Windows does not know if there are any". This is NOT GREEN, this is no data.' -ForegroundColor Yellow
  646. Write-Host ''
  647. Write-Host ' Enabling takes one command (admin required, you already have it):' -ForegroundColor DarkGray
  648. Write-Host " auditpol /set /subcategory:$LogonSubcategoryGuid /success:enable /failure:enable" -ForegroundColor DarkGray
  649. Write-Host ''
  650. # Bez konsoli albo na jawne $noprompt nie pytamy i niczego nie zmieniamy (opis przy
  651. # $PromptAllowed w CONFIG). Zmiana polityki audytu u klienta to osobna decyzja.
  652. if (-not $PromptAllowed) {
  653. Write-Host ' Not asking: session without a console or $noprompt set. Nothing was changed.' -ForegroundColor DarkGray
  654. Write-Host ' To enable, run the command above in an elevated console on this computer.' -ForegroundColor DarkGray
  655. return
  656. }
  657. # Read-Host moze rzucic (np. tryb NonInteractive) albo oddac pusty tekst z zamknietego
  658. # wejscia. Oba przypadki znacza "brak zgody" - nigdy nie wlaczamy na domysl.
  659. $answer = $null
  660. try { $answer = Read-Host ' Enable now? [Y/N]' } catch { $answer = $null }
  661. if ("$answer" -match '^[Yy]') {
  662. Write-Host ''
  663. Write-Host ' Enabling Audit Logon...' -ForegroundColor Cyan
  664. $r = Enable-AuditLogon
  665. if ($r.Success) {
  666. Write-Host ' OK. Audit Logon enabled and verified.' -ForegroundColor Green
  667. Write-Host ' From now on Windows will record logins (4624) and failed (4625).' -ForegroundColor Green
  668. Write-Host ' Run the script again later to check for attack attempts.' -ForegroundColor Green
  669. } elseif (-not $r.Verified) {
  670. Write-Host ' Command executed, but audit is STILL disabled after verification.' -ForegroundColor Red
  671. Write-Host ' Likely GPO override (Group Policy overrides local audit policy).' -ForegroundColor Red
  672. Write-Host ' Check: gpedit.msc -> Computer Config -> Windows Settings -> Security Settings' -ForegroundColor Red
  673. Write-Host ' -> Advanced Audit Policy -> Logon/Logoff -> Audit Logon' -ForegroundColor Red
  674. Write-Host ' Or domain admin must change the domain policy.' -ForegroundColor Red
  675. } else {
  676. Write-Host ' Failed to enable:' -ForegroundColor Red
  677. Write-Host " $($r.Output)" -ForegroundColor Red
  678. Write-Host ' Run the command above manually.' -ForegroundColor Red
  679. }
  680. } else {
  681. Write-Host ' OK. You can enable later manually with the command above.' -ForegroundColor DarkGray
  682. }
  683. }
  684.  
  685. function Show-Results {
  686. param($Analysis, [array]$Events, [hashtable]$ResolvedCache)
  687.  
  688. $border = '=' * 60
  689.  
  690. Write-Host ''
  691. Write-Host " $env:COMPUTERNAME | $($Now.ToString('yyyy-MM-dd HH:mm')) | $WindowText | $($Analysis.TotalEvents) events 4625" -ForegroundColor Cyan
  692.  
  693. if ($Analysis.TotalEvents -eq 0) {
  694. Write-Host ''
  695. # Kazda galaz przy zerze zdarzen tez niesie JEDEN znacznik, bo nakladka zadania
  696. # Acronisa czyta werdykt wyrazeniem regularnym i brak znacznika wygladalby jak
  697. # "zero zdarzen, spokoj". GREEN tylko wtedy, gdy audyt obejmuje cale okno -
  698. # przy swiezo wlaczonym audycie jest PARTIAL, przy wylaczonym NO-DATA.
  699. if (-not (Test-AuditLogonEnabled)) {
  700. Show-AuditDisabledOffer
  701. } else {
  702. # Audit is on but 0 events 4625. Check if audit was recently enabled - if so,
  703. # GREEN is misleading (we don't know about earlier attacks).
  704. # Two signals: (1) Event 4719 precise, (2) heuristic: no user 4624 in 7 days.
  705. $recentActivation = Get-RecentAuditLogonActivation
  706. $hoursSinceAudit = if ($recentActivation) { ((Get-Date) - $recentActivation).TotalHours } else { 999 }
  707.  
  708. if ($recentActivation -and $hoursSinceAudit -lt $WindowHours) {
  709. # 4719 found, recently within analysis window
  710. $ago = Format-TimeAgo $recentActivation
  711. Write-Host " [PARTIAL] Audit Logon enabled $ago ($($recentActivation.ToString('yyyy-MM-dd HH:mm:ss')))." -ForegroundColor Cyan
  712. Write-Host " Script analyzes a $([Math]::Round($WindowHours, 1))h window but audit has data from $([Math]::Round($hoursSinceAudit, 1))h ago." -ForegroundColor Cyan
  713. Write-Host ' No 4625 events SINCE audit was enabled - no failed logins so far.' -ForegroundColor Green
  714. Write-Host ' Attacks before audit enable - Windows does not know, cannot check retroactively.' -ForegroundColor DarkGray
  715. Show-Capabilities
  716. } elseif (-not $recentActivation -and (Test-NoUserLogonsIn7Days)) {
  717. # 4719 not available (Audit Policy Change was off), heuristic: no user 4624 in 7 days
  718. Write-Host ' [PARTIAL] Audit Logon is working, but there are NO user logins (4624) in the last 7 days.' -ForegroundColor Cyan
  719. Write-Host ' Audit was likely enabled recently (exact moment unrecorded - 4719 not written' -ForegroundColor Cyan
  720. Write-Host ' because Audit Policy Change was also disabled).' -ForegroundColor DarkGray
  721. Write-Host ' No 4625 events found - no failed logins so far, but data window is short.' -ForegroundColor Green
  722. Write-Host " Run the script again in $([Math]::Round($WindowHours, 1))h to get a reliable verdict for the full window." -ForegroundColor DarkGray
  723. Show-Capabilities
  724. } else {
  725. Write-Host ' [GREEN] No failed login attempts - all clear.' -ForegroundColor Green
  726. Show-Capabilities
  727. }
  728. }
  729. Show-WindowHint $Analysis
  730. Write-Host ''
  731. Write-Host $border -ForegroundColor Cyan
  732. Write-Host ' Analyze-FailedLogins v2.12' -ForegroundColor Cyan
  733. Write-Host $border -ForegroundColor Cyan
  734. Write-Host ''
  735. return
  736. }
  737.  
  738. Write-Host ''
  739.  
  740. # Sort by score descending
  741. $sorted = $Analysis.Groups | Sort-Object Score -Descending
  742.  
  743. # Komenda budowana jest z SUROWYCH pol grupy, nie z etykiet ekranowych. Etykieta
  744. # zrodla dokleja nazwe hosta i znacznik [EXTERNAL!], a etykieta typu skraca go do
  745. # "Kbd" - zaden z tych ciagow nie zadziala po drugiej stronie jako filtr.
  746. # Koniec okna jest przesuniety o 15 minut za ostatnia probe, bo udane logowanie po
  747. # serii nieudanych pada zwykle tuz po niej, a to wlasnie ono odpowiada na pytanie,
  748. # czy ktos w koncu wszedl.
  749. # Nazwa konta i nazwa stacji w zdarzeniu 4625 to ciagi, ktore podaje probujacy sie
  750. # zalogowac - moga zawierac apostrof i dowolne inne znaki. Komenda ponizej jest
  751. # przeznaczona do wklejenia w podniesiona konsole, wiec wartosc przepuszczona bez
  752. # kontroli pozwalalaby zamknac apostrof i dopisac wlasne polecenie. Znaki spoza
  753. # bezpiecznego zbioru zastepujemy pytajnikiem: komenda zostaje czytelna, a taka
  754. # wartosc i tak nie dopasowalaby sie do niczego w dzienniku.
  755. #
  756. # Dwukropek i znak procentu naleza do zbioru bezpiecznego, bo bez nich adres IPv6
  757. # ('fe80::8509:8676:718c:a815', z sufiksem strefy '%12') wychodzil z pytajnikami i
  758. # logins - ktory porownuje zrodlo dokladnie - oddawal pustke dla polowy zdarzen SMB
  759. # w sieci lokalnej. Wewnatrz apostrofow oba znaki sa nieszkodliwe: PowerShell nie
  760. # rozwija w nich niczego, a wyjscie z ciagu daje wylacznie apostrof, ktory zostaje
  761. # podwojony ponizej.
  762. function Protect-HandoffValue {
  763. param([string]$v)
  764. $czyste = $v -replace '[^\w\.\-\\@:%]', '?'
  765. return $czyste.Replace("'", "''")
  766. }
  767.  
  768. # Kazda zmienna ustawiana jawnie, takze nieuzywana - logins czyta je z konsoli wolajacego.
  769. function Get-HandoffCommand {
  770. param($g)
  771. $czesci = @()
  772. if ($g.Timing) {
  773. $od = $g.Timing.First.ToString($TimeFmt)
  774. $do = $g.Timing.Last.AddMinutes(15).ToString($TimeFmt)
  775. $czesci += "`$hwin='$od..$do'"
  776. $czesci += '$hback=$null'
  777. } else {
  778. $czesci += '$hwin=$null'
  779. $czesci += '$hback=168'
  780. }
  781. if ($g.TargetUser -and $g.TargetUser -ne '-') {
  782. $czesci += "`$user='$(Protect-HandoffValue $g.TargetUser)'"
  783. } else {
  784. $czesci += '$user=$null'
  785. }
  786. if ($g.Source -and $g.Source -ne '-') {
  787. $czesci += "`$source='$(Protect-HandoffValue $g.Source)'"
  788. } else {
  789. $czesci += '$source=$null'
  790. }
  791. if ($g.LogonType) { $czesci += "`$ltype=$([int]$g.LogonType)" } else { $czesci += '$ltype=$null' }
  792. if ($g.SubStatus -and $g.SubStatus -match '^0x[0-9A-Fa-f]{8}$' -and $g.SubStatus -ne '0x00000000') {
  793. $czesci += "`$status='$($g.SubStatus)'"
  794. } else {
  795. $czesci += '$status=$null'
  796. }
  797. # Udane logowanie po serii (4624) musi wejsc w wynik - stad okno +15 min i $false tutaj.
  798. $czesci += '$failedonly=$false'
  799. $czesci += '$group=$false'
  800. $czesci += '$count=50'
  801. $czesci += '$detail=$true'
  802. return ($czesci -join '; ') + '; irm https://dev.bitback.pl/logins | iex'
  803. }
  804.  
  805. $HandoffLimit = 6
  806. $handoffShown = 0
  807. $handoffSkipped = 0
  808.  
  809. foreach ($g in $sorted) {
  810. $sourceLabel = Get-SourceLabel $g.Source $ResolvedCache
  811. $ssDesc = if ($SubStatusMap.ContainsKey($g.SubStatus)) { $SubStatusMap[$g.SubStatus] } else { $g.SubStatus }
  812. $ltShort = if ($LogonTypeShort.ContainsKey($g.LogonType)) { $LogonTypeShort[$g.LogonType] } else { "T$($g.LogonType)" }
  813.  
  814. $groupColor = if ($g.Score -le 2) { 'Gray' } elseif ($g.Score -le 4) { 'Yellow' } else { 'Red' }
  815.  
  816. # Main oneliner: count + type + source -> target + reason
  817. $line = " {0}x {1} {2} -> {3} ({4})" -f $g.Count, $ltShort, $sourceLabel, $g.TargetUser, $ssDesc
  818. Write-Host $line -ForegroundColor $groupColor
  819.  
  820. # RED: dokladny zakres serii co do sekundy, pierwsza linia pod grupa.
  821. # Ten sam ciag wkleja sie jako $hwin, zeby zawezic skrypt do tej jednej serii.
  822. if ($g.Score -ge 5 -and $g.Timing) {
  823. $rangeStr = "$($g.Timing.First.ToString($TimeFmt))..$($g.Timing.Last.ToString($TimeFmt))"
  824. if ($g.Count -ge 2) {
  825. Write-Host " -> $rangeStr ($(Format-Duration $g.Timing.Span))" -ForegroundColor $groupColor
  826. } else {
  827. Write-Host " -> $rangeStr" -ForegroundColor $groupColor
  828. }
  829. }
  830.  
  831. # Arrows only for score > 2 (YELLOW/RED)
  832. if ($g.Score -gt 2 -and $g.Reasons.Count -gt 0) {
  833. foreach ($r in $g.Reasons) {
  834. Write-Host " -> $r" -ForegroundColor $groupColor
  835. }
  836. }
  837.  
  838. # Gotowa komenda do skopiowania: przenosi te grupe do skryptu logins, ktory
  839. # pokazuje pojedyncze zdarzenia razem z procesem, ktory je wywolal.
  840. if ($handoffShown -lt $HandoffLimit) {
  841. Write-Host " >> $(Get-HandoffCommand $g)" -ForegroundColor DarkCyan
  842. $handoffShown++
  843. } else {
  844. $handoffSkipped++
  845. }
  846. }
  847.  
  848. if ($handoffSkipped -gt 0) {
  849. Write-Host ''
  850. Write-Host " ($handoffSkipped grup bez gotowej komendy - pokazano ja dla $HandoffLimit najwyzej punktowanych." -ForegroundColor DarkGray
  851. Write-Host ' Zawez okno przez $hwin albo $hback, zeby grup bylo mniej.)' -ForegroundColor DarkGray
  852. }
  853.  
  854. # Time distribution (compact)
  855. Write-Host ''
  856. $hourGroups = $Events | Group-Object { $_.Time.ToString('yyyy-MM-dd HH:00') } | Sort-Object Name
  857. foreach ($hg in $hourGroups) {
  858. $bar = '#' * [Math]::Min($hg.Count, 50)
  859. Write-Host (" {0} {1,4} {2}" -f $hg.Name, $hg.Count, $bar) -ForegroundColor DarkGray
  860. }
  861.  
  862. # Verdict - dynamic, based on actual findings
  863. Write-Host ''
  864. Write-Host $border -ForegroundColor $Analysis.VerdictColor
  865. # Znacznik werdyktu i jego zdanie ida JEDNYM wywolaniem Write-Host. Dawniej znacznik
  866. # stal osobno z -NoNewline: na ekranie wygladalo to tak samo, ale przy przechwyceniu
  867. # strumienia 6>&1 (zadanie Acronisa) kazde wywolanie jest osobnym rekordem, wiec
  868. # "[GREEN]" i zdanie przychodzily rozdzielone i nie dalo sie ich wyciac jedna linia.
  869.  
  870. switch ($Analysis.Verdict) {
  871. 'GREEN' {
  872. Write-Host ' [GREEN] Brak oznak ataku z zewnatrz (obce adresy, pulpit zdalny, blokady kont, wiele kont naraz).' -ForegroundColor Green
  873. if ($Analysis.TotalEvents -gt 0) {
  874. Write-Host ' To NIE znaczy, ze zjawisko jest wyjasnione - przyczyny prob ten skrypt nie widzi.' -ForegroundColor Yellow
  875. }
  876. Show-Capabilities
  877. }
  878. 'YELLOW' {
  879. Write-Host ' [YELLOW]' -ForegroundColor Yellow
  880. # Build contextual advice
  881. $yellowGroups = $Analysis.Groups | Where-Object { $_.Score -gt 2 -and $_.Score -le 4 }
  882. foreach ($yg in $yellowGroups) {
  883. $ssDesc = if ($SubStatusMap.ContainsKey($yg.SubStatus)) { $SubStatusMap[$yg.SubStatus] } else { $yg.SubStatus }
  884. Write-Host " - $($yg.Source) -> $($yg.TargetUser): $ssDesc ($($yg.Count)x)" -ForegroundColor Yellow
  885. }
  886. Write-Host ' Check if these sources are known devices. If not - escalate.' -ForegroundColor Yellow
  887. }
  888. 'RED' {
  889. Write-Host ' [RED]' -ForegroundColor Red
  890.  
  891. # Collect specifics from high-scoring groups
  892. $redGroups = $Analysis.Groups | Where-Object { $_.Score -ge 5 }
  893. $hasWrongPassword = $redGroups | Where-Object { $_.SubStatus -eq '0xC000006A' }
  894. $hasLockout = $redGroups | Where-Object { $_.SubStatus -eq '0xC0000234' }
  895. $hasExternal = $redGroups | Where-Object { -not (Test-PrivateIP $_.Source) }
  896. $hasRDP = $redGroups | Where-Object { $_.LogonType -eq 10 }
  897. $hasCredStuffing = ($Analysis.Groups | Where-Object { $_.Reasons -match 'multiple accounts' }).Count -gt 0
  898. $attackedExistingAccounts = $redGroups | Where-Object { $_.SubStatus -eq '0xC000006A' } | Select-Object -ExpandProperty TargetUser -Unique
  899. $externalSources = $redGroups | Where-Object { -not (Test-PrivateIP $_.Source) } | Select-Object -ExpandProperty Source -Unique
  900.  
  901. # WHY it's red
  902. Write-Host ' Why:' -ForegroundColor Red
  903. if ($hasCredStuffing) {
  904. Write-Host ' - Multiple accounts targeted from same source (credential stuffing pattern)' -ForegroundColor Red
  905. }
  906. if ($hasWrongPassword) {
  907. Write-Host " - Wrong password attempts on existing accounts: $($attackedExistingAccounts -join ', ')" -ForegroundColor Red
  908. }
  909. if ($hasLockout) {
  910. $lockedAccounts = $hasLockout | Select-Object -ExpandProperty TargetUser -Unique
  911. Write-Host " - Accounts locked out: $($lockedAccounts -join ', ')" -ForegroundColor Red
  912. }
  913. if ($hasRDP) {
  914. Write-Host ' - RDP login attempts detected (high-risk attack vector)' -ForegroundColor Red
  915. }
  916. if ($hasExternal) {
  917. Write-Host " - External source(s): $($externalSources -join ', ')" -ForegroundColor Red
  918. }
  919.  
  920. # WHAT TO DO - only relevant actions
  921. Write-Host '' -ForegroundColor Red
  922. Write-Host ' Action:' -ForegroundColor Red
  923. if ($hasExternal) {
  924. Write-Host " - Block external IP(s) on firewall: $($externalSources -join ', ')" -ForegroundColor Red
  925. }
  926. if ($hasWrongPassword -and $attackedExistingAccounts) {
  927. Write-Host " - Change passwords on targeted accounts: $($attackedExistingAccounts -join ', ')" -ForegroundColor Red
  928. }
  929. if ($hasLockout) {
  930. Write-Host ' - Review locked accounts - unlock only after confirming source is blocked' -ForegroundColor Red
  931. }
  932. if ($hasRDP) {
  933. Write-Host ' - Check if RDP is exposed to internet - restrict via firewall/VPN' -ForegroundColor Red
  934. }
  935. }
  936. }
  937.  
  938. Show-NextStep $Analysis
  939. Show-WindowHint $Analysis
  940. Write-Host ''
  941. Write-Host $border -ForegroundColor Cyan
  942. Write-Host ' Analyze-FailedLogins v2.12' -ForegroundColor Cyan
  943. Write-Host $border -ForegroundColor Cyan
  944. Write-Host ''
  945. }
  946.  
  947. # ============================================================
  948. # MAIN
  949. # ============================================================
  950.  
  951. $resolvedCache = @{}
  952.  
  953. Show-Header
  954.  
  955. Write-Host ''
  956. Write-Host " Reading $WindowText ..." -ForegroundColor Cyan
  957.  
  958. $events = Get-FailedLogonEvents
  959. if ($null -eq $events) {
  960. Write-Host ''
  961. Write-Host ' [ERROR] Cannot continue - see error above.' -ForegroundColor Red
  962. Write-Host ''
  963. return
  964. }
  965.  
  966. if ($events.Count -eq 0) {
  967. $emptyAnalysis = [PSCustomObject]@{
  968. TotalEvents = 0; Groups = @(); MaxScore = 0
  969. Verdict = 'GREEN'; VerdictColor = 'Green'
  970. }
  971. Show-Results $emptyAnalysis @() $resolvedCache
  972. return
  973. }
  974.  
  975. Write-Host " Found $($events.Count) events. Analyzing..." -ForegroundColor Cyan
  976.  
  977. $analysis = Analyze-Events $events
  978. Show-Results $analysis $events $resolvedCache
  979.