diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1 index af4c489..10fcadd 100644 --- a/Login_Monitor.ps1 +++ b/Login_Monitor.ps1 @@ -2,7 +2,8 @@ .SYNOPSIS Мониторинг логинов/попыток входа с уведомлениями в Telegram .DESCRIPTION - Отслеживает события входа в систему (Security 4624/4625) и события RD Gateway (302/303), + Отслеживает события входа в систему (Security 4624/4625), агрегированные оповещения при всплеске 4625, + события RD Gateway (302/303), на заданном КД — блокировки учётных записей (Security 4740) с IP из логов IIS ActiveSync, отправляет уведомления в Telegram, делает ротацию логов, heartbeat в файл и ежедневный отчет. .NOTES @@ -71,7 +72,7 @@ $script:MonitorSingletonLockStream = $null # строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). # Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только # исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). -$ScriptVersion = "1.5.9" +$ScriptVersion = "1.5.10" # Логи (все под InstallRoot) $LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" @@ -162,6 +163,18 @@ $ExchangeIisLogTailLines = 5000 # Окно поиска IP в IIS: только строки за N минут до события 4740 (локальное время сервера IIS). $ExchangeIisLogMinutesBeforeLockout = 30 +# Агрегация неудачных входов Security 4625 (вариант C: два порога). Автоблокировка IP не выполняется. +$FailedLogonRateLimitEnabled = $true +$FailedLogonRateLimitSuppressIndividualWhileBurst = $true +# Уровень 1: подбор одной учётной записи с одного источника (IP + пользователь). +$FailedLogonRateLimitUserIpWindowSeconds = 60 +$FailedLogonRateLimitUserIpThreshold = 5 +$FailedLogonRateLimitUserIpCooldownSeconds = 300 +# Уровень 2: много неудачных попыток с одного IP (password spraying / перебор логинов). +$FailedLogonRateLimitIpWindowSeconds = 60 +$FailedLogonRateLimitIpThreshold = 12 +$FailedLogonRateLimitIpCooldownSeconds = 300 + # Очередь оповещений: telegram, email (или tg, mail). Пусто = авто: настроенные каналы, порядок telegram → email. $NotifyOrder = "tg" $MailSmtpHost = "" @@ -1184,6 +1197,9 @@ function Send-Heartbeat { } } catch { } } + if ($FailedLogonRateLimitEnabled) { + $message += "`r`n🛡️ Агрегация 4625: уровень 1 — $FailedLogonRateLimitUserIpThreshold за $FailedLogonRateLimitUserIpWindowSeconds с (IP+пользователь); уровень 2 — $FailedLogonRateLimitIpThreshold за $FailedLogonRateLimitIpWindowSeconds с (только IP). Cooldown ${FailedLogonRateLimitUserIpCooldownSeconds}/${FailedLogonRateLimitIpCooldownSeconds} с. Автобан: нет." + } if (Test-Lockout4740MonitoringActive) { $message += "`r`n🔒 Блокировки AD: на этом КД отслеживается Security 4740 (блокировка учётной записи)." if (-not [string]::IsNullOrWhiteSpace($ExchangeIisLogPath)) { @@ -1707,6 +1723,250 @@ function Format-LoginEvent { return $message } +$script:FailedLogonBuckets = @{} + +function Get-FailedLogonSourceKeyPart { + param( + [string]$SourceIP, + [string]$ComputerName + ) + + $ip = if ($null -ne $SourceIP) { $SourceIP.Trim() } else { '' } + if (-not [string]::IsNullOrWhiteSpace($ip) -and $ip -ne '-' -and $ip -ne '::1' -and $ip -ne '127.0.0.1' -and $ip -notlike 'fe80:*') { + return "ip:$ip" + } + + $wks = if ($null -ne $ComputerName) { $ComputerName.Trim() } else { '' } + if (-not [string]::IsNullOrWhiteSpace($wks) -and $wks -ne '-') { + return "wks:$wks" + } + + return 'unknown' +} + +function Get-FailedLogonNormalizedUsername { + param([string]$Username) + + $u = if ($null -ne $Username) { $Username.Trim() } else { '' } + if ([string]::IsNullOrWhiteSpace($u) -or $u -eq '-') { return '(не указан)' } + return $u +} + +function Get-FailedLogonSourceDisplayLabel { + param([string]$SourceKeyPart) + + if ($SourceKeyPart -like 'ip:*') { return $SourceKeyPart.Substring(3) } + if ($SourceKeyPart -like 'wks:*') { return ('рабочая станция ' + $SourceKeyPart.Substring(4)) } + return 'неизвестный источник' +} + +function Update-FailedLogonRateLimitBucket { + param( + [string]$BucketKey, + [int]$WindowSeconds, + [string]$Username, + [int]$LogonType, + [datetime]$TimeCreated + ) + + $cutoff = (Get-Date).AddSeconds(-$WindowSeconds) + + if (-not $script:FailedLogonBuckets.ContainsKey($BucketKey)) { + $script:FailedLogonBuckets[$BucketKey] = [pscustomobject]@{ + Attempts = [System.Collections.ArrayList]@() + LastBurstAlertUtc = $null + } + } + + $bucket = $script:FailedLogonBuckets[$BucketKey] + $null = $bucket.Attempts.Add([pscustomobject]@{ + Time = $TimeCreated + Username = (Get-FailedLogonNormalizedUsername -Username $Username) + LogonType = $LogonType + }) + + $fresh = [System.Collections.ArrayList]@() + foreach ($a in $bucket.Attempts) { + if ($a.Time -ge $cutoff) { $null = $fresh.Add($a) } + } + $bucket.Attempts = $fresh + + if ($fresh.Count -eq 0) { + $bucket.LastBurstAlertUtc = $null + } + + return $bucket +} + +function Format-FailedLogonBurstMessage { + param( + [ValidateSet('UserIp', 'Ip')] + [string]$TierKind, + [string]$SourceKeyPart, + [string]$FocusUsername, + [string]$SecurityLogComputerName, + [System.Collections.ArrayList]$Attempts, + [int]$Threshold, + [int]$WindowSeconds + ) + + $sourceLabel = Get-FailedLogonSourceDisplayLabel -SourceKeyPart $SourceKeyPart + $hLog = ConvertTo-TelegramHtml $(if ([string]::IsNullOrWhiteSpace($SecurityLogComputerName)) { $env:COMPUTERNAME } else { $SecurityLogComputerName }) + $hSource = ConvertTo-TelegramHtml $sourceLabel + $count = $Attempts.Count + $times = @($Attempts | ForEach-Object { $_.Time } | Sort-Object) + $first = $times[0] + $last = $times[-1] + + $byUser = @{} + foreach ($a in $Attempts) { + if (-not $byUser.ContainsKey($a.Username)) { $byUser[$a.Username] = 0 } + $byUser[$a.Username]++ + } + $userLines = @($byUser.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { + '{0} ({1})' -f (ConvertTo-TelegramHtml $_.Key), $_.Value + }) + + $ltSet = @($Attempts | ForEach-Object { $_.LogonType } | Sort-Object -Unique) + $ltNames = @($ltSet | ForEach-Object { '{0} ({1})' -f (ConvertTo-TelegramHtml (Get-LogonTypeName -LogonType $_)), $_ }) + $ltText = if ($ltNames.Count -gt 0) { ($ltNames -join ', ') } else { '-' } + + if ($TierKind -eq 'UserIp') { + $title = '🚨 МАССОВЫЕ НЕУДАЧНЫЕ ВХОДЫ (4625) — учётная запись' + $tierLine = 'Уровень: IP + пользователь (подбор одного логина)' + $hUser = ConvertTo-TelegramHtml $FocusUsername + $focusLine = "👤 Учётная запись: $hUser`r`n" + } else { + $title = '🚨 МАССОВЫЕ НЕУДАЧНЫЕ ВХОДЫ (4625) — источник IP' + $tierLine = 'Уровень: только IP (несколько учётных записей / spraying)' + $focusLine = '' + } + + $message = "$title`r`n" + $message += "$tierLine`r`n" + $message += "🏢 Сервер (журнал Security): $hLog`r`n" + $message += "🎯 Источник: $hSource`r`n" + $message += $focusLine + $message += "📊 За последние $WindowSeconds с: $count попыток (порог $Threshold)`r`n" + $message += "🕐 В окне: $(ConvertTo-TelegramHtml ($first.ToString('dd.MM.yyyy HH:mm:ss'))) — $(ConvertTo-TelegramHtml ($last.ToString('dd.MM.yyyy HH:mm:ss')))`r`n" + $message += "👤 В попытках: $($userLines -join '; ')`r`n" + $message += "🔑 Типы входа: $ltText`r`n" + $message += "⚠️ Возможный брутфорс. Одиночные 4625 в окне подавлены. Автоблокировка IP не выполняется.`r`n" + $message += "🔢 Event ID: 4625 (агрегат)" + + return $message +} + +function Get-FailedLogonBurstAlertIfNeeded { + param( + [ValidateSet('UserIp', 'Ip')] + [string]$TierKind, + [string]$BucketKey, + [string]$SourceKeyPart, + [string]$FocusUsername, + [string]$SecurityLogComputerName, + [int]$WindowSeconds, + [int]$Threshold, + [int]$CooldownSeconds + ) + + $bucket = $script:FailedLogonBuckets[$BucketKey] + if ($null -eq $bucket) { + return $null + } + + $count = $bucket.Attempts.Count + if ($count -lt $Threshold) { + return $null + } + + $cooldownOk = $true + if ($null -ne $bucket.LastBurstAlertUtc) { + $elapsed = ((Get-Date).ToUniversalTime() - $bucket.LastBurstAlertUtc.ToUniversalTime()).TotalSeconds + $cooldownOk = ($elapsed -ge $CooldownSeconds) + } + + if (-not $cooldownOk) { + return $null + } + + $bucket.LastBurstAlertUtc = (Get-Date).ToUniversalTime() + $msg = Format-FailedLogonBurstMessage -TierKind $TierKind -SourceKeyPart $SourceKeyPart ` + -FocusUsername $FocusUsername -SecurityLogComputerName $SecurityLogComputerName ` + -Attempts $bucket.Attempts -Threshold $Threshold -WindowSeconds $WindowSeconds + + return [pscustomobject]@{ + Tier = $TierKind + Message = $msg + Count = $count + BucketKey = $BucketKey + } +} + +function Get-FailedLogonRateLimitDecision4625 { + param( + [string]$SourceIP, + [string]$ComputerName, + [string]$Username, + [int]$LogonType, + [datetime]$TimeCreated, + [string]$SecurityLogComputerName + ) + + if (-not $FailedLogonRateLimitEnabled) { + return [pscustomobject]@{ + SendIndividual = $true + BurstAlerts = @() + UserIpCount = 0 + IpCount = 0 + } + } + + $sourcePart = Get-FailedLogonSourceKeyPart -SourceIP $SourceIP -ComputerName $ComputerName + $normUser = Get-FailedLogonNormalizedUsername -Username $Username + $userIpKey = "tier1:$sourcePart|$normUser" + $ipKey = "tier2:$sourcePart" + + $userBucket = Update-FailedLogonRateLimitBucket -BucketKey $userIpKey ` + -WindowSeconds $FailedLogonRateLimitUserIpWindowSeconds ` + -Username $Username -LogonType $LogonType -TimeCreated $TimeCreated + + $ipBucket = Update-FailedLogonRateLimitBucket -BucketKey $ipKey ` + -WindowSeconds $FailedLogonRateLimitIpWindowSeconds ` + -Username $Username -LogonType $LogonType -TimeCreated $TimeCreated + + $burstAlerts = [System.Collections.ArrayList]@() + + $burstUser = Get-FailedLogonBurstAlertIfNeeded -TierKind 'UserIp' -BucketKey $userIpKey ` + -SourceKeyPart $sourcePart -FocusUsername $normUser -SecurityLogComputerName $SecurityLogComputerName ` + -WindowSeconds $FailedLogonRateLimitUserIpWindowSeconds ` + -Threshold $FailedLogonRateLimitUserIpThreshold ` + -CooldownSeconds $FailedLogonRateLimitUserIpCooldownSeconds + if ($null -ne $burstUser) { $null = $burstAlerts.Add($burstUser) } + + $burstIp = Get-FailedLogonBurstAlertIfNeeded -TierKind 'Ip' -BucketKey $ipKey ` + -SourceKeyPart $sourcePart -FocusUsername $normUser -SecurityLogComputerName $SecurityLogComputerName ` + -WindowSeconds $FailedLogonRateLimitIpWindowSeconds ` + -Threshold $FailedLogonRateLimitIpThreshold ` + -CooldownSeconds $FailedLogonRateLimitIpCooldownSeconds + if ($null -ne $burstIp) { $null = $burstAlerts.Add($burstIp) } + + $inBurst = ($userBucket.Attempts.Count -ge $FailedLogonRateLimitUserIpThreshold) ` + -or ($ipBucket.Attempts.Count -ge $FailedLogonRateLimitIpThreshold) + + $sendIndividual = $true + if ($FailedLogonRateLimitSuppressIndividualWhileBurst -and $inBurst) { + $sendIndividual = $false + } + + return [pscustomobject]@{ + SendIndividual = $sendIndividual + BurstAlerts = @($burstAlerts) + UserIpCount = $userBucket.Attempts.Count + IpCount = $ipBucket.Attempts.Count + } +} + function Test-RDGatewayLog { try { $logExists = Get-WinEvent -ListLog $RDGatewayLogName -ErrorAction SilentlyContinue @@ -2108,6 +2368,9 @@ function Start-LoginMonitor { } Write-Log "========================================" Write-Log "Каналы уведомлений: $(Get-NotifyChainHuman)" + if ($FailedLogonRateLimitEnabled) { + Write-Log "Агрегация 4625: tier1 $FailedLogonRateLimitUserIpThreshold/$FailedLogonRateLimitUserIpWindowSeconds с (IP+user), tier2 $FailedLogonRateLimitIpThreshold/$FailedLogonRateLimitIpWindowSeconds с (IP); suppressIndividual=$FailedLogonRateLimitSuppressIndividualWhileBurst" + } $lockout4740Enabled = Test-Lockout4740MonitoringActive if ($lockout4740Enabled) { @@ -2190,19 +2453,51 @@ function Start-LoginMonitor { } if (-not $shouldIgnore) { - $formattedMessage = Format-LoginEvent -EventID $event.Id ` - -Username $eventInfo.Username ` - -ComputerName $eventInfo.ComputerName ` - -SourceIP $eventInfo.SourceIP ` - -ProcessName $eventInfo.ProcessName ` - -TimeCreated $eventInfo.TimeCreated ` - -LogonType $eventInfo.LogonType ` - -LogonTypeName $logonTypeName ` - -SecurityLogComputerName $event.MachineName + if ($event.Id -eq 4625 -and $FailedLogonRateLimitEnabled) { + $rl = Get-FailedLogonRateLimitDecision4625 -SourceIP $eventInfo.SourceIP ` + -ComputerName $eventInfo.ComputerName -Username $eventInfo.Username ` + -LogonType $eventInfo.LogonType -TimeCreated $eventInfo.TimeCreated ` + -SecurityLogComputerName $event.MachineName - Write-Log "Notify: ID=$($event.Id) User=$($eventInfo.Username) LT=$($eventInfo.LogonType) IP=$($eventInfo.SourceIP)" - Send-MonitorNotification -Message $formattedMessage ` - -EmailSubject "RDP Login Monitor: вход (ID $($event.Id))" | Out-Null + if ($rl.SendIndividual) { + $formattedMessage = Format-LoginEvent -EventID $event.Id ` + -Username $eventInfo.Username ` + -ComputerName $eventInfo.ComputerName ` + -SourceIP $eventInfo.SourceIP ` + -ProcessName $eventInfo.ProcessName ` + -TimeCreated $eventInfo.TimeCreated ` + -LogonType $eventInfo.LogonType ` + -LogonTypeName $logonTypeName ` + -SecurityLogComputerName $event.MachineName + + Write-Log "Notify: ID=4625 User=$($eventInfo.Username) LT=$($eventInfo.LogonType) IP=$($eventInfo.SourceIP) (tier1=$($rl.UserIpCount) tier2=$($rl.IpCount))" + Send-MonitorNotification -Message $formattedMessage ` + -EmailSubject "RDP Login Monitor: неудачный вход (4625)" | Out-Null + } else { + Write-Log "Notify suppressed 4625: User=$($eventInfo.Username) IP=$($eventInfo.SourceIP) tier1=$($rl.UserIpCount)/$FailedLogonRateLimitUserIpThreshold tier2=$($rl.IpCount)/$FailedLogonRateLimitIpThreshold" + } + + foreach ($burst in $rl.BurstAlerts) { + $tierLabel = if ($burst.Tier -eq 'UserIp') { 'IP+user' } else { 'IP' } + Write-Log "Notify burst 4625 ($tierLabel): count=$($burst.Count) key=$($burst.BucketKey)" + Send-MonitorNotification -Message $burst.Message ` + -EmailSubject "RDP Login Monitor: брутфорс 4625 ($tierLabel)" | Out-Null + } + } else { + $formattedMessage = Format-LoginEvent -EventID $event.Id ` + -Username $eventInfo.Username ` + -ComputerName $eventInfo.ComputerName ` + -SourceIP $eventInfo.SourceIP ` + -ProcessName $eventInfo.ProcessName ` + -TimeCreated $eventInfo.TimeCreated ` + -LogonType $eventInfo.LogonType ` + -LogonTypeName $logonTypeName ` + -SecurityLogComputerName $event.MachineName + + Write-Log "Notify: ID=$($event.Id) User=$($eventInfo.Username) LT=$($eventInfo.LogonType) IP=$($eventInfo.SourceIP)" + Send-MonitorNotification -Message $formattedMessage ` + -EmailSubject "RDP Login Monitor: вход (ID $($event.Id))" | Out-Null + } } } $lastCheckTime = ($events | Measure-Object -Property TimeCreated -Maximum | Select-Object -ExpandProperty Maximum).AddSeconds(1) diff --git a/README.md b/README.md index e6d379d..838efa0 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ PowerShell-набор для мониторинга входов в Windows с ## Актуальная схема (рекомендуется) - Базовый путь установки: **`C:\ProgramData\RDP-login-monitor\`**. -- Основной скрипт: **`Login_Monitor.ps1`** — журнал Security **`4624`/`4625`** (логика зависит от типа ОС: рабочая станция или сервер/КД), при наличии журнала — **Remote Connection Manager `1149`** (часто актуально для РС с RDP), при роли **RD Gateway** — **`302`/`303`**, на **КД, где запущен монитор** (имя совпадает с **`$LockoutMonitorDomainController`**) — **`4740`** (блокировка УЗ + IP из IIS ActiveSync), **ежедневный отчёт** (активные сессии через `quser`), **heartbeat**, **ротация логов**, уведомления в Telegram и/или Email. +- Основной скрипт: **`Login_Monitor.ps1`** — журнал Security **`4624`/`4625`** (логика зависит от типа ОС: рабочая станция или сервер/КД), при всплеске **`4625`** — **агрегированные оповещения** (два порога: IP+пользователь и только IP), при наличии журнала — **Remote Connection Manager `1149`** (часто актуально для РС с RDP), при роли **RD Gateway** — **`302`/`303`**, на **КД, где запущен монитор** (имя совпадает с **`$LockoutMonitorDomainController`**) — **`4740`** (блокировка УЗ + IP из IIS ActiveSync), **ежедневный отчёт** (активные сессии через `quser`), **heartbeat**, **ротация логов**, уведомления в Telegram и/или Email. - Установка задач: запуск **`Login_Monitor.ps1 -InstallTasks`** создаёт: - `RDP-Login-Monitor` (основной монитор), - `RDP-Login-Monitor-Watchdog` (контроль процесса каждые 5 минут). @@ -19,6 +19,7 @@ PowerShell-набор для мониторинга входов в Windows с - **Кодировка логов**: `login_monitor.log` / `watchdog.log` пишутся как **UTF-8 с BOM** (и при необходимости BOM добавляется к уже существующему файлу), чтобы в **FAR/старых просмотрщиках** не было ситуации “в консоли нормально, а в файле РЈРІРµ…” из‑за неверной авто-кодировки. - **`auditpol` на русской Windows**: настройка/проверка аудита опирается на категорию **`Вход/выход`** и подкатегории **`Вход в систему` / `Выход из системы`** (ожидается строка **`Успех и сбой`**). Это устраняет ошибки вида `0x00000057` из‑за несуществующего на RU ОС имени `Logon`. - **Стабильность**: `auditpol` вызывается по полному пути `%SystemRoot%\System32\auditpol.exe` (без зависимости от PATH), stdout+stderr объединяются через `ProcessStartInfo`. +- **Агрегация 4625 (брутфорс)**: при включённом `$FailedLogonRateLimitEnabled` — уровень 1: **5** неудачных попыток за **60** с с одного источника для **одной** учётной записи (IP+user); уровень 2: **12** попыток за **60** с с одного IP (несколько логинов). Пока порог не достигнут — поштучные 4625; при всплеске — сводные алерты, одиночные подавляются. Параметры в начале `Login_Monitor.ps1`. Автоблокировка IP не выполняется. ## 1) Подготовка diff --git a/README_eng.md b/README_eng.md index fb7a23f..6f21136 100644 --- a/README_eng.md +++ b/README_eng.md @@ -5,7 +5,7 @@ PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMT ## Recommended layout - Installation root: **`C:\ProgramData\RDP-login-monitor\`**. -- Main script: **`Login_Monitor.ps1`** — Security log **`4624`/`4625`** (behavior depends on OS type: workstation vs server/domain controller), optional **Remote Connection Manager `1149`** when the log is available (often useful for RDP-enabled workstations), **RD Gateway** events **`302`/`303`** when the gateway role/log is present, on the **DC where the monitor runs** (hostname matches **`$LockoutMonitorDomainController`**) — **`4740`** (account lockout + IPs from IIS ActiveSync), **daily report** (active sessions via `quser`), **heartbeat**, **log rotation**, alerts via Telegram and/or Email. +- Main script: **`Login_Monitor.ps1`** — Security log **`4624`/`4625`** (behavior depends on OS type: workstation vs server/domain controller), **aggregated `4625` burst alerts** (two tiers: IP+user and IP-only), optional **Remote Connection Manager `1149`** when the log is available (often useful for RDP-enabled workstations), **RD Gateway** events **`302`/`303`** when the gateway role/log is present, on the **DC where the monitor runs** (hostname matches **`$LockoutMonitorDomainController`**) — **`4740`** (account lockout + IPs from IIS ActiveSync), **daily report** (active sessions via `quser`), **heartbeat**, **log rotation**, alerts via Telegram and/or Email. - Scheduled tasks: run **`Login_Monitor.ps1 -InstallTasks`** to register: - `RDP-Login-Monitor` (main monitor), - `RDP-Login-Monitor-Watchdog` (process health check every 5 minutes). @@ -19,6 +19,7 @@ PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMT - **Log encoding**: `login_monitor.log` / `watchdog.log` are written as **UTF-8 with BOM** (BOM is applied to existing files if missing) so viewers like **FAR Manager** do not mis-detect encoding. - **`auditpol` on Russian Windows**: auditing checks use the **`Вход/выход`** category and **`Вход в систему` / `Выход из системы`** subcategories (expect **`Успех и сбой`**), avoiding errors such as `0x00000057` when English names like `Logon` are absent on a localized OS. - **Stability**: `auditpol` is invoked via full path `%SystemRoot%\System32\auditpol.exe` (no PATH dependency); stdout and stderr are merged via `ProcessStartInfo`. +- **`4625` burst alerts**: when `$FailedLogonRateLimitEnabled` is true — tier 1: **5** failures in **60** s per **IP+user**; tier 2: **12** in **60** s per **IP** (spray). Below thresholds, individual `4625` alerts are sent; during a burst, aggregated alerts replace per-event noise. No automatic IP blocking. Tune at the top of `Login_Monitor.ps1`. ## 1) Preparation diff --git a/version.txt b/version.txt index 2b26b8d..f0ed379 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.5.9 +1.5.10