Watching RDP for brute-force: when the homemade service still wins.
By Scott Li ·
Most teams should turn RDP off entirely. For the ones that can't, a small Windows Service watching Event ID 4625 still has its place — here's the design and where it fits.
We once shipped a small Windows Service (RDPMonitorService) that watched the Security Event Log for failed RDP authentications, fired email alerts to the admin, and temporarily blocked offending IPs. The service is still in production at three client sites. RDP itself has aged worse than the service did.
What the design got right
- Watching Event ID 4625 in the Security log is still the correct signal.
- A 3-attempts-in-5-minutes / 24-hour-block default was conservative and saved several clients from sustained brute-force noise.
- Running the service under a dedicated, least-privileged account, not LocalSystem.
- DPAPI for credential handling.
What we’d say first
Turn RDP off if you can. Use a bastion + SSO + Just-in-Time access instead.
We mean this. Most workloads that had RDP exposed (even via NLA, even on a non-default port) can be served by an Entra ID / Identity Center fronted bastion with session recording. The number of “RDP got brute-forced” incidents in our own client book has been zero on bastion-only estates and non-zero on RDP-exposed estates.
When the homemade service is still right
Three real cases:
- Industrial / lab environments that have a Windows host on a tightly-firewalled VLAN, a single allow-listed admin IP, and operators who must use RDP from a specific physical workstation. Bastion is overkill; a homemade monitor is right-sized.
- Air-gapped or near-air-gapped estates where you cannot bring in a bastion product, but you can run a small in-house service with explicit code review.
- Cost-sensitive small clients for whom the licensing of a commercial bastion or PAM product is the actual blocker.
For all three, the design works. Below is what we’d update.
Updates to the design
1. PowerShell over C#, in some cases
The original showed C# with EventLogQuery / EventLogReader. For the simpler deployments above (a single Windows host, no central log pipeline), a PowerShell script run as a scheduled task is now usually a better choice than a full Windows Service:
# Watch Security log, block IPs after 3 fails in 5 min for 24 hours.
Get-WinEvent -LogName Security -FilterXPath @'
*[System[Provider[@Name='Microsoft-Windows-Security-Auditing']
and (EventID=4625) and TimeCreated[timediff(@SystemTime) <= 300000]]]
'@ |
Group-Object { $_.Properties[19].Value } | # source IP
Where-Object { $_.Count -ge 3 } |
ForEach-Object {
$ip = $_.Name
if ($ip -and $ip -ne '-') {
New-NetFirewallRule -DisplayName "RDPBlock-$ip" `
-Direction Inbound -RemoteAddress $ip -Action Block `
-Description "auto-block $(Get-Date -Format o)" | Out-Null
Write-EventLog -LogName Application -Source "RDPMonitor" `
-EntryType Warning -EventId 1001 -Message "Blocked $ip"
}
}
A 30-line scheduled task replaces a few hundred lines of C#, with no installer.
2. Push events off-box
The original service emitted to the local Application log. Ship the events to a centralised pipeline (Sentinel, OpenSearch, or a simple syslog endpoint). A monitor whose alerts only land on the box being attacked is a brittle monitor.
3. Don’t trust unblock-by-timer alone
The original design auto-unblocked an IP after 24 hours. We’ve kept that in the simple deployments and dropped it in the more sensitive ones, where an admin reviews the block list weekly. Auto-unblocking a real attacker IP after 24 hours is a slightly bad default for high-value workloads.
4. MFA is still the thing
If RDP must be exposed, the only durable mitigation is MFA on the RDP login itself — Duo, Yubikey + Smart-Card, or Windows Hello for Business in a domain context. The brute-force monitor is a defence in depth layer. Without MFA, a sufficiently patient attacker wins.
The agent-era footnote
Could an LLM-driven agent run as the RDP-monitor brain, reading the event log and deciding when to block? Technically yes. We have not seen a case where it adds value over a deterministic rule. The decision space (block, unblock, escalate) is small; deterministic logic is testable; LLM cost and latency are unjustified. This is one of the cases where the agent does not belong.
The pattern: read the event log, count failures by IP, write a firewall rule, log to a place a human reads. Boring. Effective. Compounds.
— Scott Li, wGrow