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.

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