feat: aggregated 4625 burst alerts (two-tier rate limit, v1.5.10)

- Tier 1: 5 failures per 60s per IP+user
- Tier 2: 12 failures per 60s per IP (spraying)
- Suppress per-event 4625 during burst; no auto-ban
This commit is contained in:
PTah
2026-05-22 14:13:06 +10:00
parent a324841b7d
commit 40c557f041
4 changed files with 314 additions and 17 deletions
+297 -2
View File
@@ -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🛡️ <b>Агрегация 4625:</b> уровень 1 — $FailedLogonRateLimitUserIpThreshold за $FailedLogonRateLimitUserIpWindowSeconds с (IP+пользователь); уровень 2 — $FailedLogonRateLimitIpThreshold за $FailedLogonRateLimitIpWindowSeconds с (только IP). Cooldown ${FailedLogonRateLimitUserIpCooldownSeconds}/${FailedLogonRateLimitIpCooldownSeconds} с. Автобан: нет."
}
if (Test-Lockout4740MonitoringActive) {
$message += "`r`n🔒 <b>Блокировки AD:</b> на этом КД отслеживается Security <b>4740</b> (блокировка учётной записи)."
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 = 'Уровень: <b>IP + пользователь</b> (подбор одного логина)'
$hUser = ConvertTo-TelegramHtml $FocusUsername
$focusLine = "👤 Учётная запись: $hUser`r`n"
} else {
$title = '🚨 МАССОВЫЕ НЕУДАЧНЫЕ ВХОДЫ (4625) — источник IP'
$tierLine = 'Уровень: <b>только IP</b> (несколько учётных записей / spraying)'
$focusLine = ''
}
$message = "<b>$title</b>`r`n"
$message += "$tierLine`r`n"
$message += "🏢 Сервер (журнал Security): $hLog`r`n"
$message += "🎯 Источник: $hSource`r`n"
$message += $focusLine
$message += "📊 За последние $WindowSeconds с: <b>$count</b> попыток (порог $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 += "<i>⚠️ Возможный брутфорс. Одиночные 4625 в окне подавлены. Автоблокировка IP не выполняется.</i>`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,6 +2453,37 @@ function Start-LoginMonitor {
}
if (-not $shouldIgnore) {
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
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 `
@@ -2205,6 +2499,7 @@ function Start-LoginMonitor {
-EmailSubject "RDP Login Monitor: вход (ID $($event.Id))" | Out-Null
}
}
}
$lastCheckTime = ($events | Measure-Object -Property TimeCreated -Maximum | Select-Object -ExpandProperty Maximum).AddSeconds(1)
}
+2 -1
View File
@@ -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) Подготовка
+2 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
1.5.9
1.5.10