From 80b2337d614868b1b95d399501184adc5ce9b28e Mon Sep 17 00:00:00 2001 From: PTah Date: Fri, 5 Jun 2026 11:07:21 +1000 Subject: [PATCH] fix(winrm): Exchange strict mode to suppress false 91/4624 alerts On Exchange role: require user in WinRM Event 91 EventData; correlate 4624 only when LogonProcess is WinRM. Prevents Outlook/LT3 false positives. Version 2.0.23-SAC. Co-authored-by: Cursor --- Deploy-LoginMonitor.ps1 | 7 +- Docs/deploy-rdp-login-monitor.md | 3 +- Login_Monitor.ps1 | 110 +++++++++++++++++++++++++++-- README.md | 1 + login_monitor.settings.example.ps1 | 1 + version.txt | 2 +- 6 files changed, 116 insertions(+), 8 deletions(-) diff --git a/Deploy-LoginMonitor.ps1 b/Deploy-LoginMonitor.ps1 index 84fb675..bf50d20 100644 --- a/Deploy-LoginMonitor.ps1 +++ b/Deploy-LoginMonitor.ps1 @@ -409,6 +409,10 @@ function Get-RdpMonitorExchangeNoiseSettingDefinitions { @{ Pattern = '(?m)^\s*(\#\s*)?\$WinRmIgnoreMachineAccounts\s*=' Line = '$WinRmIgnoreMachineAccounts = 1' + }, + @{ + Pattern = '(?m)^\s*(\#\s*)?\$WinRmExchangeStrictMode\s*=' + Line = '$WinRmExchangeStrictMode = 1' } ) } @@ -514,7 +518,8 @@ function Repair-RdpMonitorSettingsWinRmLinesIfInvalid { 'WinRmCorrelateSecurity4624', 'WinRm4624CorrelationWindowSeconds', 'WinRmIgnoreLocalSource', - 'WinRmIgnoreMachineAccounts' + 'WinRmIgnoreMachineAccounts', + 'WinRmExchangeStrictMode' ) $newContent = $c $fixed = $false diff --git a/Docs/deploy-rdp-login-monitor.md b/Docs/deploy-rdp-login-monitor.md index 36073ad..c85f264 100644 --- a/Docs/deploy-rdp-login-monitor.md +++ b/Docs/deploy-rdp-login-monitor.md @@ -76,8 +76,9 @@ flowchart TD - `${Ignore4624-LT3-EmptyIP-Event} = $true` - `$WinRmIgnoreLocalSource = 1` - `$WinRmIgnoreMachineAccounts = 1` +- `$WinRmExchangeStrictMode = 1` -Подавление шума WinRM (HealthMailbox, loopback) и лишних **4624 LT3**. +Подавление шума WinRM (HealthMailbox, loopback), ложных WinRM 91↔4624 (Outlook/LT3) и лишних **4624 LT3**. **Мониторинг очередей транспорта и правил пересылки** — другой скрипт, **не** через GPO RDP-монитора: diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1 index 713d3b1..68a6183 100644 --- a/Login_Monitor.ps1 +++ b/Login_Monitor.ps1 @@ -89,7 +89,7 @@ $script:SkipLogDetailLimit = 15 # строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). # Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только # исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). -$ScriptVersion = "2.0.22-SAC" +$ScriptVersion = "2.0.23-SAC" # Логи (все под InstallRoot) $LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" @@ -149,6 +149,8 @@ $WinRm4624CorrelationWindowSeconds = 15 # Исключить шум Exchange/локальный WinRM: HealthMailbox*, учётки *$, ::1, fe80:, 127.0.0.1 $WinRmIgnoreLocalSource = 1 $WinRmIgnoreMachineAccounts = 1 +# На сервере Exchange: не алертить WinRM 91 без user в EventData/Properties; 4624 только LogonProcess WinRM. +$WinRmExchangeStrictMode = 1 # Admin share (Security 5140): C$, ADMIN$ — рабочие станции и серверы; нужен audit File Share. $EnableAdminShareMonitoring = 1 @@ -1631,11 +1633,85 @@ function Get-WinRm91EventInfo { return $eventData } +function Test-RdpMonitorExchangeServerRole { + if (-not [string]::IsNullOrWhiteSpace($env:ExchangeInstallPath)) { + if (Test-Path -LiteralPath $env:ExchangeInstallPath) { return $true } + } + foreach ($regPath in @( + 'HKLM:\SOFTWARE\Microsoft\ExchangeServer\v15\Setup', + 'HKLM:\SOFTWARE\Microsoft\ExchangeServer\v14\Setup' + )) { + try { + if ($null -ne (Get-ItemProperty -LiteralPath $regPath -ErrorAction Stop)) { return $true } + } catch { } + } + foreach ($root in @( + 'C:\Program Files\Microsoft\Exchange Server\V15', + 'C:\Program Files\Microsoft\Exchange Server\V14' + )) { + if (Test-Path -LiteralPath (Join-Path $root 'bin\RemoteExchange.ps1')) { return $true } + } + try { + $svc = Get-Service -Name 'MSExchangeIS', 'MSExchangeTransport' -ErrorAction SilentlyContinue | + Where-Object { $_.Status -eq 'Running' } | + Select-Object -First 1 + if ($null -ne $svc) { return $true } + } catch { } + return $false +} + +function Test-WinRmExchangeStrictActive { + if (-not (Test-RdpMonitorExchangeServerRole)) { return $false } + return Test-MonitorFeatureEnabled -Value $WinRmExchangeStrictMode +} + +function Test-WinRm91UserPresentInEventRecord { + param($Event) + try { + $map = Get-EventDataMap -Event $Event + $user = Get-FirstNonEmptyMapValue -DataMap $map -Keys @( + 'user', 'User', 'UserName', 'AccountName', 'SubjectUserName' + ) + if (-not [string]::IsNullOrWhiteSpace($user) -and $user -ne '-') { + return $true + } + if ($Event.Properties.Count -gt 0) { + $propUser = [string]$Event.Properties[0].Value + if (-not [string]::IsNullOrWhiteSpace($propUser) -and + $propUser -notmatch '(?i)https?://|ResourceUri|clientIP:') { + return $true + } + } + } catch { } + return $false +} + +function Get-WinRmExchangeStrictSkipReason { + param( + [bool]$UserPresentInWinRmEvent, + [string]$Username, + [string]$SourceIP, + [string]$ResourceUri + ) + if (-not $UserPresentInWinRmEvent) { + return 'exchange-winrm-no-user-in-event' + } + $uriBad = ([string]::IsNullOrWhiteSpace($ResourceUri) -or $ResourceUri -eq '-' -or $ResourceUri -like '*%1*') + if ($uriBad -and ([string]::IsNullOrWhiteSpace($SourceIP) -or $SourceIP -eq '-')) { + return 'exchange-winrm-unresolved-uri-no-ip' + } + if (Test-RdpMonitorUsernameExcludedByPattern -Username $Username) { + return 'excluded-user-pattern' + } + return '' +} + function Find-CorrelatedNetworkLogon4624 { param( [datetime]$AroundTime, [string]$UsernameHint = '', - [int]$WindowSeconds = 15 + [int]$WindowSeconds = 15, + [switch]$RequireWinRmLogonProcess ) $start = $AroundTime.AddSeconds(-1 * [Math]::Abs($WindowSeconds)) $end = $AroundTime.AddSeconds([Math]::Abs($WindowSeconds)) @@ -1653,6 +1729,13 @@ function Find-CorrelatedNetworkLogon4624 { $info = Get-LoginEventInfo -Event $ev if ($info.LogonType -ne 3) { continue } if ($info.SourceIP -eq '-' -or [string]::IsNullOrWhiteSpace($info.SourceIP)) { continue } + if ($RequireWinRmLogonProcess) { + $logonProcess = [string]$info.ProcessName + if ([string]::IsNullOrWhiteSpace($logonProcess) -or $logonProcess -eq '-' -or + $logonProcess -notmatch '(?i)WinRM') { + continue + } + } if (-not [string]::IsNullOrWhiteSpace($UsernameHint) -and $UsernameHint -ne '-') { if (-not (Test-RdpMonitorUsernameMatchesToken -Username $info.Username -Token $UsernameHint)) { continue @@ -3978,6 +4061,9 @@ function Start-LoginMonitor { if (Test-MonitorFeatureEnabled -Value $EnableWinRmInboundMonitoring) { if (Test-WinRmLogAvailable) { Write-Log "WinRM inbound (Enter-PSSession): включён (Operational IDs: $($WinRmInboundShellEventIds -join ', '); correlate 4624=$WinRmCorrelateSecurity4624)." + if (Test-WinRmExchangeStrictActive) { + Write-Log "WinRM Exchange strict: user обязателен в Event 91; корреляция 4624 только LogonProcess WinRM." + } } else { Write-Log "WinRM inbound: включён в настройках, но журнал WinRM Operational недоступен." } @@ -4378,16 +4464,30 @@ function Start-LoginMonitor { if ($winRmEvents) { foreach ($event in $winRmEvents) { if ($event.TimeCreated -le $lastWinRmCheckTime) { continue } + $exchangeWinRmStrict = Test-WinRmExchangeStrictActive + $userInWinRmEvent = Test-WinRm91UserPresentInEventRecord -Event $event $wr = Get-WinRm91EventInfo -Event $event if (Test-MonitorFeatureEnabled -Value $WinRmCorrelateSecurity4624) { $corr = Find-CorrelatedNetworkLogon4624 -AroundTime $wr.TimeCreated ` - -UsernameHint $wr.User -WindowSeconds $WinRm4624CorrelationWindowSeconds + -UsernameHint $wr.User -WindowSeconds $WinRm4624CorrelationWindowSeconds ` + -RequireWinRmLogonProcess:$exchangeWinRmStrict if ($null -ne $corr) { - $wr.SourceIP = $corr.SourceIP - if ($wr.User -eq '-' -and $corr.Username -ne '-') { $wr.User = $corr.Username } + if ($wr.SourceIP -eq '-') { $wr.SourceIP = $corr.SourceIP } + if ($wr.User -eq '-' -and $corr.Username -ne '-' -and -not $exchangeWinRmStrict) { + $wr.User = $corr.Username + } $wr.LogonType = $corr.LogonType } } + if ($exchangeWinRmStrict) { + $exchangeSkip = Get-WinRmExchangeStrictSkipReason ` + -UserPresentInWinRmEvent:$userInWinRmEvent ` + -Username $wr.User -SourceIP $wr.SourceIP -ResourceUri $wr.ResourceUri + if (-not [string]::IsNullOrWhiteSpace($exchangeSkip)) { + Write-Log "Skip WinRM $($event.Id): User=$($wr.User) IP=$($wr.SourceIP) — reason=$exchangeSkip" + continue + } + } $winRmIgnoreReason = Get-WinRmIgnoreReason -Username $wr.User -SourceIP $wr.SourceIP if (-not [string]::IsNullOrWhiteSpace($winRmIgnoreReason)) { Write-Log "Skip WinRM $($event.Id): User=$($wr.User) IP=$($wr.SourceIP) — reason=$winRmIgnoreReason" diff --git a/README.md b/README.md index 3a87748..de78167 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Get-Content "C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log" -Tail 60 - **WinRM / Enter-PSSession (2.0.4+):** улучшен парсинг события `Microsoft-Windows-WinRM/Operational` (`ID=91`) — корректно извлекаются `user` и `clientIP` из EventData/Message fallback, событие отправляется в SAC как `winrm.session.started`. - **Deploy WinRM self-heal (2.0.3+):** `Deploy-LoginMonitor.ps1` проверяет/дописывает обязательный блок WinRM inbound в `login_monitor.settings.ps1`, диагностирует доступность канала `Microsoft-Windows-WinRM/Operational` и пытается включить его через `wevtutil`. - **Диагностика skip WinRM (2.0.5+):** в логах пишется точная причина `Skip WinRM 91` (`empty-user`, `excluded-user-pattern`, `machine-account`, `local-or-linklocal-ip`, `ignore-list-match`), что упрощает troubleshooting. +- **WinRM Exchange strict (2.0.23+):** на сервере с ролью Exchange — WinRM **91** без user в EventData/Properties не алертится; корреляция Security **4624** только при `LogonProcess WinRM` (отсекает ложные связки с Outlook/почтовым LT3). Причины skip: `exchange-winrm-no-user-in-event`, `exchange-winrm-unresolved-uri-no-ip`. Deploy дописывает `$WinRmExchangeStrictMode = 1`. ## 1) Подготовка diff --git a/login_monitor.settings.example.ps1 b/login_monitor.settings.example.ps1 index f7303fc..d745ec5 100644 --- a/login_monitor.settings.example.ps1 +++ b/login_monitor.settings.example.ps1 @@ -54,6 +54,7 @@ $GetInventory = $true # $EnableAdminShareMonitoring = 1 # Security 5140 C$/ADMIN$ (audit File Share) # $WinRmIgnoreLocalSource = 1 # ::1, 127.0.0.1, fe80 (шум Exchange/локальный WinRM) # $WinRmIgnoreMachineAccounts = 1 # учётки, оканчивающиеся на $ +# $WinRmExchangeStrictMode = 1 # Exchange: user в Event 91 обязателен; 4624 только LogonProcess WinRM # HealthMailbox* уже в ExcludedUserPatterns скрипта # Проверка: powershell -File Login_Monitor.ps1 -CheckSac diff --git a/version.txt b/version.txt index cc061fa..91a10ae 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.0.22-SAC +2.0.23-SAC