Short answer: run .\Block-RdpBruteForce.ps1 -Minutes 30 -Threshold 10 -AllowList 203.0.113.10 -Apply -WhatIf as Administrator to preview, then without -WhatIf to block. The script counts failed logons (Security event 4625, logon types 3 and 10) per source IP over the last -Minutes, and puts every address at or above -Threshold into an inbound Windows Firewall block rule. Private ranges and your allow-list are never blocked. Schedule it every 15 minutes for a small fail2ban-style blocker.
We ran this script on 6 October 2026 on a Windows Server 2025 Standard domain controller (build 26100.33438, September 2026 update, Windows PowerShell 5.1) and a Windows 11 Pro member PC in our lab domain contoso.com. The example output below is from that run; the lab domain is small and new, so your numbers will be bigger. It was also syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25.
Table of Contents
Lock-out risk: if you manage the server over RDP from the internet, put your own public address (and VPN egress addresses) in -AllowList or -AllowListPath before the first -Apply. Our what is my IP tool shows it. Keep console or out-of-band access available while you test.
What it does
A server with RDP open to the internet sees password guessing within hours. The right fix is to not expose RDP at all (VPN, RD Gateway, or a firewall rule that only allows known addresses), but where that is not possible yet, blocking the noisiest sources cuts the load and the log noise. The script:
- Reads event 4625 (“An account failed to log on”) from the Security log for the last
-Minutes. - Keeps logon type 3 (Network) and 10 (RemoteInteractive). With Network Level Authentication, failed RDP logons are usually recorded as type 3, which is why both are counted by default.
- Groups by the event’s
IpAddressfield (IPv4-mapped IPv6 addresses such as::ffff:203.0.113.10are converted to IPv4) and counts failures and distinct user names. - Decides per address: Block, AlreadyBlocked, BelowThreshold, AllowListed or Private.
- With
-Apply, writes the block list into inbound Block rules namedsrvScripts-RDP-BruteForce-001,-002and so on, and keeps a small state file with the time each address was first blocked, so-ExpireDayscan unblock old entries.
Without -Apply it only reports, which is also a quick way to see who is attacking you. To understand the event IDs behind it, read RDP connection logs: 14 event IDs.
Requirements
- Windows Server 2012 R2 or later (or Windows 10/11), Windows PowerShell 5.1 or PowerShell 7. Uses only built-in cmdlets:
Get-WinEventand the NetSecurity module (New-NetFirewallRule,Set-NetFirewallRule,Get-NetFirewallAddressFilter,Remove-NetFirewallRule). - An elevated PowerShell (Administrator): reading the Security log and changing firewall rules both need it. The scheduled task runs as SYSTEM.
- Failure auditing for logons. Event 4625 is generated by the Audit Logon subcategory (and Audit Account Lockout). Check with
auditpol /get /subcategory:"Logon"; if failures are not audited, set it in your audit policy (see Active Directory audit policy for domain machines). - Windows Firewall enabled for the active profile; block rules do nothing when the firewall is off. Domain-wide firewall settings are covered in Windows Firewall Group Policy.
Download and first run
- Copy the script from the box on this page (or use the download button) and save it as
C:\Scripts\Block-RdpBruteForce.ps1. - If you downloaded the file, clear the “downloaded from the internet” mark so the execution policy lets it run:
Unblock-File C:\Scripts\Block-RdpBruteForce.ps1. - Read the built-in help, then run it once interactively and look at the result on screen before you export or schedule anything.
cd C:\Scripts
Get-Help .\Block-RdpBruteForce.ps1 -Full
# Report only: who failed to log on in the last hour
.\Block-RdpBruteForce.ps1
# Preview the blocking
.\Block-RdpBruteForce.ps1 -Minutes 30 -Threshold 10 -AllowList 203.0.113.10 -Apply -WhatIf
Run it from an elevated PowerShell window. The script refuses to run without administrator rights.
Options
| Parameter | What it does | Default |
|---|---|---|
-Minutes | How far back to read event 4625 (1 to 10080) | 60 |
-Threshold | Failures from one address that trigger a block | 10 |
-LogonType | 3, 10 or both | 3, 10 |
-AllowList | IPs or CIDR ranges never blocked, for example 203.0.113.10,198.51.100.0/24 | None |
-AllowListPath | Text file with one IP or CIDR per line; # starts a comment | None |
-IncludePrivate | Also block private, loopback, link-local and CGNAT ranges | Off |
-Apply | Create or update the firewall rules (supports -WhatIf) | Report only |
-RuleName | Base name of the rules | srvScripts-RDP-BruteForce |
-LocalPort | Block only this TCP port (for example 3389) instead of all traffic | 0 (all traffic) |
-MaxAddressesPerRule | Addresses per rule before the next rule is started | 1000 |
-ExpireDays | Unblock addresses this many days after they were first blocked | 0 (never) |
-StatePath | State file | %ProgramData%\srvScripts\rdp-blocklist.json |
-LogPath | Append one line per change to this file | None |
-CsvPath, -PassThru | Per-address report as CSV / objects | Screen |
Never blocked unless you add -IncludePrivate: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, 100.64.0.0/10, ::1, fe80::/10 and fc00::/7. Use our subnet calculator to check a CIDR range before you add it to the allow-list.
Usage examples
# Last 24 hours, anything with 3+ failures, as CSV (report only)
.\Block-RdpBruteForce.ps1 -Minutes 1440 -Threshold 3 -CsvPath C:\Reports\rdp-failures.csv
# Block with an allow-list file, unblock after 30 days, keep a change log
.\Block-RdpBruteForce.ps1 -Minutes 30 -Threshold 10 -AllowListPath C:\Scripts\rdp-allow.txt -ExpireDays 30 -Apply -LogPath C:\Scripts\rdp-block.log
# See which addresses are in the rules now
Get-NetFirewallRule -Name 'srvScripts-RDP-BruteForce-*' | Get-NetFirewallAddressFilter | Select-Object -ExpandProperty RemoteAddress
# Remove everything the script created (state file too)
Remove-NetFirewallRule -Name 'srvScripts-RDP-BruteForce-*'
Remove-Item "$env:ProgramData\srvScripts\rdp-blocklist.json"
CSV columns
The example output further down is from our lab run. One row per source address seen in the window:
| Column | Meaning |
|---|---|
| SourceAddress | IpAddress from the event (IPv4-mapped IPv6 converted to IPv4) |
| Failures | Number of 4625 events from that address in the window |
| DistinctUsers, SampleUsers | How many different user names were tried, and up to five of them |
| LogonTypes | Logon types seen (3, 10) |
| FirstSeen, LastSeen | First and last failure in the window (local time) |
| Decision | Block, AlreadyBlocked, BelowThreshold, AllowListed or “Private (use -IncludePrivate)” |
Example output
We sent six NTLM logons with wrong passwords from the member PC (192.168.0.14, a private lab address, hence -IncludePrivate), then ran a preview and a real run on the DC:
PS> .\Block-RdpBruteForce.ps1 -Minutes 30 -Threshold 5 -IncludePrivate -Apply -WhatIf
Reading event 4625 since 2026-10-06 13:58 (logon types 3, 10)...
6 matching failures from 1 source address(es); 0 without a source address.
To block now: 1. To unblock (allow-listed, private or expired): 0. Total after this run: 1.
What if: Performing the operation "Create inbound block rule for 1 address(es)" on target "srvScripts-RDP-BruteForce-001".
Would block 192.168.0.14 (6 failures in 30 min)
PS> .\Block-RdpBruteForce.ps1 -Minutes 30 -Threshold 5 -IncludePrivate -Apply
2026-10-06T14:28:22 Created rule srvScripts-RDP-BruteForce-001 (1 addresses)
2026-10-06T14:28:22 Blocked 192.168.0.14 (6 failures in 30 min)
SourceAddress Failures DistinctUsers LogonTypes LastSeen Decision
------------- -------- ------------- ---------- -------- --------
192.168.0.14 6 1 3 10/6/2026 2:26:35 PM Block
The rule blocked all traffic from that address, which is the default (-LocalPort 0). Use -LocalPort 3389 if you only want to close RDP. One thing the lab showed: this server also had OpenSSH open to the internet, and more than 600 failed SSH password logons arrived in three hours. Windows logs those as event 4625 with logon type 8 and no source address, so no event-based blocker can block them. Switch OpenSSH to key-only logins (PasswordAuthentication no) instead.
Schedule it
Run it every 15 minutes as SYSTEM with a 30-minute window, so each attack is seen by at least two runs. schtasks keeps the repeat simple:
schtasks /Create /TN "srvScripts RDP brute-force blocker" /SC MINUTE /MO 15 /RU SYSTEM /RL HIGHEST /F `
/TR "powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\Block-RdpBruteForce.ps1 -Minutes 30 -Threshold 10 -AllowListPath C:\Scripts\rdp-allow.txt -ExpireDays 30 -Apply -LogPath C:\Scripts\rdp-block.log"
The script uses ConfirmImpact Medium, so it does not prompt when run unattended. Check C:\Scripts\rdp-block.log after the first few runs, and protect C:\Scripts so only administrators can change the script or the allow-list, because the task runs as SYSTEM.
How it works
Get-WinEvent -FilterHashtable @{ LogName='Security'; Id=4625; StartTime=... }reads the events. Each event is converted to XML and the named fieldsLogonType,IpAddressandTargetUserNameare read fromEventData, so the result does not depend on the language of the event text.- Addresses are parsed with
[System.Net.IPAddress]::TryParse; events whose address is “-” (no network source) are counted separately. - Allow-list and private ranges are checked with a bitwise CIDR match that works for IPv4 and IPv6.
- Existing rules matching
<RuleName>-*are read withGet-NetFirewallAddressFilterand merged with the state file, so deleting the state file never unblocks anyone by accident. - The final list is split into chunks of
-MaxAddressesPerRule. Each chunk is written withSet-NetFirewallRule -RemoteAddress(existing rule) orNew-NetFirewallRule -Direction Inbound -Action Block -Profile Any -RemoteAddress(new rule); surplus rules are removed. Every change goes throughShouldProcess, so-WhatIfshows it without doing it.
Limitations
- Missing source address. Some failed logons are recorded with IpAddress “-” (no network source in the event). They cannot be blocked by address; the script reports how many there were, and the RDP-specific logs described in our RDP event ID guide may still show the client address.
- Distributed attacks from thousands of addresses with one or two attempts each stay under any sensible threshold. Restricting who can reach port 3389 is the real fix.
- Account lockout still happens if attackers guess a real user name; combine this with a lockout policy and strong passwords.
- RD Gateway / RDS farms: failures may be logged on the gateway or broker, not on the session host. Run the script where the 4625 events are written.
- Not a replacement for MFA or a VPN on internet-facing RDP.
- Not yet run on a live server (see the note at the top).
Official documentation: Event 4625: An account failed to log on · New-NetFirewallRule · Get-NetFirewallAddressFilter
Related: RDP Connection Logs: 14 Event IDs to Track Who Connected and From Where · Enable Remote Desktop with Group Policy: NLA, Firewall Rules and User Access · Windows Firewall Group Policy: 5 Steps to Deploy Secure Rules · Active Directory Audit Policy: DC Settings and 35 Key Event IDs · fail2ban for Asterisk and FreePBX: Block SIP Password Guessing
See also: Block RDP Brute Force on Windows Server: Detect and Stop It
The script
# Block RDP Brute Force on Windows Server: PowerShell Script (v1.0.1) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/rdp-brute-force-blocker/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
<#
.SYNOPSIS
Block RDP brute-force sources on Windows Server: counts failed logons (Security event 4625, logon
types 3 and 10) per source IP over the last -Minutes and blocks every IP at or above -Threshold
with Windows Firewall rules. Report-only unless you add -Apply.
.DESCRIPTION
Default (no -Apply): reads the events and prints a table of source addresses with their failure
count and the decision the script would take. Nothing is changed.
With -Apply (supports -WhatIf):
1. New offenders are added to a small state file (JSON) together with the time they were first
blocked. Addresses already present in the script's firewall rules are imported into the state
file, so deleting the file does not unblock anything.
2. With -ExpireDays, entries older than that many days are dropped (unblocked).
3. The block list is written to one or more inbound Block rules named <RuleName>-001, -002, ...
(at most -MaxAddressesPerRule addresses each). Existing rules are updated with
Set-NetFirewallRule -RemoteAddress; surplus rules are removed.
Safety:
- Private, loopback, link-local and carrier-grade NAT ranges (10/8, 172.16/12, 192.168/16, 127/8,
169.254/16, 100.64/10, ::1, fe80::/10, fc00::/7) are never blocked unless -IncludePrivate.
- -AllowList / -AllowListPath addresses and CIDR ranges are never blocked (put your office and VPN
egress addresses here) and are removed from the rules if they were blocked earlier.
- Events without a source address ("-") are counted but cannot be blocked.
Requirements: Windows Server 2012 R2 or later (or Windows 10/11), Windows PowerShell 5.1 or
PowerShell 7, run as Administrator (reading the Security log and changing firewall rules both need
it). Failed logons must be audited: "Audit Logon" (Failure) under Advanced Audit Policy > Logon/Logoff.
The rules only matter while Windows Firewall is on for the active profile.
.PARAMETER Minutes Look back this many minutes in the Security log (default 60).
.PARAMETER Threshold Block a source with at least this many failures in the window (default 10).
.PARAMETER LogonType Logon types to count: 3 (Network, used by RDP with NLA) and/or 10 (RemoteInteractive). Default both.
.PARAMETER AllowList IP addresses or CIDR ranges that are never blocked.
.PARAMETER AllowListPath Text file with one IP or CIDR per line (# comments allowed).
.PARAMETER IncludePrivate Also block private, loopback, link-local and CGNAT addresses.
.PARAMETER Apply Create/update the firewall rules. Without it the script only reports.
.PARAMETER RuleName Base name of the firewall rules (default srvScripts-RDP-BruteForce).
.PARAMETER LocalPort Block only this TCP port (for example 3389). Default 0 = block all traffic from the address.
.PARAMETER MaxAddressesPerRule Addresses per firewall rule before a new rule is started (default 1000).
.PARAMETER ExpireDays Unblock addresses this many days after they were first blocked (default 0 = never).
.PARAMETER StatePath State file (default %ProgramData%\srvScripts\rdp-blocklist.json).
.PARAMETER LogPath Append one line per change to this text file.
.PARAMETER CsvPath Write the per-address report to CSV.
.PARAMETER PassThru Output the report objects to the pipeline.
.EXAMPLE .\Block-RdpBruteForce.ps1
Report only: failures per source IP in the last 60 minutes.
.EXAMPLE .\Block-RdpBruteForce.ps1 -Minutes 30 -Threshold 10 -AllowList 203.0.113.10,198.51.100.0/24 -Apply -WhatIf
.EXAMPLE .\Block-RdpBruteForce.ps1 -Minutes 30 -Threshold 10 -AllowListPath C:\Scripts\rdp-allow.txt -ExpireDays 30 -Apply -LogPath C:\Scripts\rdp-block.log
.EXAMPLE .\Block-RdpBruteForce.ps1 -Minutes 1440 -Threshold 3 -CsvPath C:\Reports\rdp-failures.csv
.NOTES
Name: Block-RdpBruteForce.ps1
Purpose: Turn failed RDP logons (event 4625) into Windows Firewall block rules
Source: https://srvscripts.com/scripts/rdp-brute-force-blocker/
License: MIT
Version: 1.0.1
Requires: Windows PowerShell 5.1 or PowerShell 7 on Windows, NetSecurity module (built in), Administrator.
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param(
[ValidateRange(1, 10080)]
[int]$Minutes = 60,
[ValidateRange(1, 100000)]
[int]$Threshold = 10,
[ValidateSet(3, 10)]
[int[]]$LogonType = @(3, 10),
[string[]]$AllowList,
[string]$AllowListPath,
[switch]$IncludePrivate,
[switch]$Apply,
[ValidatePattern('^[A-Za-z0-9 ._-]{3,60}$')]
[string]$RuleName = 'srvScripts-RDP-BruteForce',
[ValidateRange(0, 65535)]
[int]$LocalPort = 0,
[ValidateRange(10, 10000)]
[int]$MaxAddressesPerRule = 1000,
[ValidateRange(0, 3650)]
[int]$ExpireDays = 0,
[string]$StatePath,
[string]$LogPath,
[string]$CsvPath,
[switch]$PassThru
)
Set-StrictMode -Version 2
$ErrorActionPreference = 'Stop'
function Write-Status([string]$Message) { Write-Information $Message -InformationAction Continue }
$changeLogFile = $LogPath
$skipPrivate = -not $IncludePrivate
function Write-ChangeLog([string]$Message) {
$line = '{0} {1}' -f (Get-Date -Format 's'), $Message
Write-Status $line
if ($changeLogFile) { Add-Content -LiteralPath $changeLogFile -Value $line -Encoding UTF8 -WhatIf:$false }
}
# ---- Address helpers ------------------------------------------------------------------------------------
function ConvertTo-IpAddress([string]$Text) {
$ip = $null
if (-not [System.Net.IPAddress]::TryParse($Text.Trim(), [ref]$ip)) { return $null }
if ($ip.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6 -and $ip.IsIPv4MappedToIPv6) { $ip = $ip.MapToIPv4() }
if ($ip.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) { $ip.ScopeId = 0 }
return $ip
}
function ConvertTo-Network([string]$Text) {
# Accepts "192.0.2.10", "192.0.2.0/24", "2001:db8::/32". Returns @{ Bytes; Prefix } or $null.
$parts = $Text.Trim() -split '/', 2
$ip = ConvertTo-IpAddress $parts[0]
if (-not $ip) { return $null }
$bytes = $ip.GetAddressBytes()
$max = $bytes.Length * 8
$prefix = $max
if ($parts.Count -eq 2) {
$p = 0
if (-not [int]::TryParse($parts[1], [ref]$p) -or $p -lt 0 -or $p -gt $max) { return $null }
$prefix = $p
}
return @{ Bytes = $bytes; Prefix = $prefix }
}
function Test-InNetwork([System.Net.IPAddress]$Ip, [hashtable]$Network) {
$a = $Ip.GetAddressBytes()
$b = $Network.Bytes
if ($a.Length -ne $b.Length) { return $false }
$bits = $Network.Prefix
for ($i = 0; $i -lt $a.Length -and $bits -gt 0; $i++) {
$take = [math]::Min(8, $bits)
$mask = [byte]((0xFF -shl (8 - $take)) -band 0xFF)
if (($a[$i] -band $mask) -ne ($b[$i] -band $mask)) { return $false }
$bits -= $take
}
return $true
}
$privateNets = @('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.0/8', '169.254.0.0/16', '100.64.0.0/10',
'::1/128', 'fe80::/10', 'fc00::/7') | ForEach-Object { ConvertTo-Network $_ }
$allowNets = [System.Collections.Generic.List[object]]::new()
$allowEntries = @()
if ($AllowList) { $allowEntries += $AllowList }
if ($AllowListPath) {
if (-not (Test-Path -LiteralPath $AllowListPath)) { throw "Allow-list file not found: $AllowListPath" }
$allowEntries += @(Get-Content -LiteralPath $AllowListPath | ForEach-Object { ($_ -replace '#.*$', '').Trim() } | Where-Object { $_ })
}
foreach ($entry in $allowEntries) {
$n = ConvertTo-Network $entry
if (-not $n) { throw "Allow-list entry is not an IP address or CIDR range: '$entry'" }
$allowNets.Add($n)
}
function Get-SkipReason([System.Net.IPAddress]$Ip) {
foreach ($n in $allowNets) { if (Test-InNetwork $Ip $n) { return 'AllowListed' } }
if ($skipPrivate) { foreach ($n in $privateNets) { if (Test-InNetwork $Ip $n) { return 'Private (use -IncludePrivate)' } } }
return $null
}
# ---- Pre-flight -----------------------------------------------------------------------------------------
if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { throw 'This script runs on Windows only.' }
if (-not $StatePath) { $StatePath = Join-Path $env:ProgramData 'srvScripts\rdp-blocklist.json' }
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell (Administrator): reading the Security log and changing firewall rules need it.'
}
# ---- Read failed logons ---------------------------------------------------------------------------------
$since = (Get-Date).AddMinutes(-$Minutes)
Write-Status ("Reading event 4625 since {0:yyyy-MM-dd HH:mm} (logon types {1})..." -f $since, ($LogonType -join ', '))
$events = @()
try {
$events = @(Get-WinEvent -FilterHashtable @{ LogName = 'Security'; Id = 4625; StartTime = $since } -ErrorAction Stop)
} catch {
if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') { throw }
}
$stats = @{}
$noAddress = 0
foreach ($e in $events) {
$data = @{}
foreach ($d in ([xml]$e.ToXml()).Event.EventData.Data) { $data[[string]$d.Name] = [string]$d.InnerText }
$lt = 0
if (-not $data.ContainsKey('LogonType') -or -not [int]::TryParse($data['LogonType'], [ref]$lt)) { continue }
if ($LogonType -notcontains $lt) { continue }
$raw = if ($data.ContainsKey('IpAddress')) { $data['IpAddress'] } else { '' }
$ip = if ($raw -and $raw -ne '-') { ConvertTo-IpAddress $raw } else { $null }
if (-not $ip) { $noAddress++; continue }
$key = $ip.ToString()
if (-not $stats.ContainsKey($key)) {
$stats[$key] = @{ Ip = $ip; Fails = 0; Users = @{}; Types = @{}; First = $e.TimeCreated; Last = $e.TimeCreated }
}
$s = $stats[$key]
$s.Fails++
$user = if ($data.ContainsKey('TargetUserName')) { $data['TargetUserName'] } else { '' }
if ($user) { $s.Users[$user] = $true }
$s.Types[[string]$lt] = $true
if ($e.TimeCreated -lt $s.First) { $s.First = $e.TimeCreated }
if ($e.TimeCreated -gt $s.Last) { $s.Last = $e.TimeCreated }
}
$totalFails = 0
foreach ($s in $stats.Values) { $totalFails += $s.Fails }
Write-Status ("{0} matching failures from {1} source address(es); {2} without a source address." -f $totalFails, $stats.Count, $noAddress)
if (-not $events.Count) {
Write-Warning 'No 4625 events in the window. If you expected some, check that "Audit Logon" failure auditing is enabled (auditpol /get /subcategory:"Logon").'
}
# ---- Current rules and state ----------------------------------------------------------------------------
$rulePattern = "$RuleName-*"
$existingRules = @(Get-NetFirewallRule -Name $rulePattern -ErrorAction SilentlyContinue)
$blocked = @{}
foreach ($r in $existingRules) {
foreach ($a in @(($r | Get-NetFirewallAddressFilter).RemoteAddress)) {
$addr = ([string]$a) -replace '/(255\.255\.255\.255|32|128)$', ''
if ($addr -and $addr -ne 'Any') { $blocked[$addr] = @{ FirstBlockedUtc = (Get-Date).ToUniversalTime().ToString('o'); Source = 'rule' } }
}
}
if (Test-Path -LiteralPath $StatePath) {
try {
$parsed = Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
foreach ($entry in $parsed) {
if ($entry -and $entry.Address) { $blocked[[string]$entry.Address] = @{ FirstBlockedUtc = [string]$entry.FirstBlockedUtc; Source = 'state' } }
}
} catch { Write-Warning "State file could not be read and is ignored: $($_.Exception.Message)" }
}
# ---- Decide ---------------------------------------------------------------------------------------------
$nowUtc = (Get-Date).ToUniversalTime()
$report = [System.Collections.Generic.List[object]]::new()
$toAdd = [System.Collections.Generic.List[string]]::new()
foreach ($s in ($stats.Values | Sort-Object { $_['Fails'] } -Descending)) {
$addr = $s.Ip.ToString()
$skip = Get-SkipReason $s.Ip
$decision = if ($skip) { $skip }
elseif ($blocked.ContainsKey($addr)) { 'AlreadyBlocked' }
elseif ($s.Fails -ge $Threshold) { 'Block' }
else { 'BelowThreshold' }
if ($decision -eq 'Block') { $toAdd.Add($addr) }
$report.Add([pscustomobject][ordered]@{
SourceAddress = $addr
Failures = $s.Fails
DistinctUsers = $s.Users.Count
SampleUsers = (@($s.Users.Keys | Sort-Object | Select-Object -First 5) -join '; ')
LogonTypes = (@($s.Types.Keys | Sort-Object) -join ',')
FirstSeen = $s.First
LastSeen = $s.Last
Decision = $decision
})
}
# Allow-listed or private entries that are blocked from an earlier run get removed; expired ones too.
$toRemove = [System.Collections.Generic.List[string]]::new()
foreach ($addr in @($blocked.Keys)) {
$n = ConvertTo-Network $addr
if ($n) {
$ipText = ($addr -split '/', 2)[0]
$ip = ConvertTo-IpAddress $ipText
if ($ip -and (Get-SkipReason $ip)) { $toRemove.Add($addr); continue }
}
if ($ExpireDays -gt 0) {
$first = [datetime]::MinValue
if ([datetime]::TryParse($blocked[$addr].FirstBlockedUtc, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind, [ref]$first) -and
$first.ToUniversalTime() -lt $nowUtc.AddDays(-$ExpireDays)) { $toRemove.Add($addr) }
}
}
$final = @{}
foreach ($addr in $blocked.Keys) { if (-not $toRemove.Contains($addr)) { $final[$addr] = $blocked[$addr].FirstBlockedUtc } }
foreach ($addr in $toAdd) { $final[$addr] = $nowUtc.ToString('o') }
Write-Status ("To block now: {0}. To unblock (allow-listed, private or expired): {1}. Total after this run: {2}." -f $toAdd.Count, $toRemove.Count, $final.Count)
# ---- Apply ----------------------------------------------------------------------------------------------
if ($Apply) {
$addresses = @($final.Keys | Sort-Object)
$chunks = [System.Collections.Generic.List[object]]::new()
for ($i = 0; $i -lt $addresses.Count; $i += $MaxAddressesPerRule) {
$end = [math]::Min($i + $MaxAddressesPerRule, $addresses.Count) - 1
$chunks.Add(@($addresses[$i..$end]))
}
$wanted = @{}
for ($c = 0; $c -lt $chunks.Count; $c++) {
$name = '{0}-{1:000}' -f $RuleName, ($c + 1)
$wanted[$name] = $true
$list = [string[]]$chunks[$c]
$rule = $existingRules | Where-Object { $_.Name -eq $name }
if ($rule) {
if ($PSCmdlet.ShouldProcess($name, "Set block list to $($list.Count) address(es)")) {
Set-NetFirewallRule -Name $name -RemoteAddress $list
Write-ChangeLog "Updated rule $name ($($list.Count) addresses)"
}
} elseif ($PSCmdlet.ShouldProcess($name, "Create inbound block rule for $($list.Count) address(es)")) {
$p = @{
Name = $name
DisplayName = $name
Description = "Created by Block-RdpBruteForce.ps1 (srvscripts.com). Sources with $Threshold+ failed logons (event 4625)."
Direction = 'Inbound'
Action = 'Block'
Profile = 'Any'
RemoteAddress = $list
Enabled = 'True'
}
if ($LocalPort -gt 0) { $p.Protocol = 'TCP'; $p.LocalPort = [string]$LocalPort }
New-NetFirewallRule @p | Out-Null
Write-ChangeLog "Created rule $name ($($list.Count) addresses)"
}
}
foreach ($r in $existingRules) {
if (-not $wanted.ContainsKey($r.Name) -and $PSCmdlet.ShouldProcess($r.Name, 'Remove surplus block rule')) {
Remove-NetFirewallRule -Name $r.Name
Write-ChangeLog "Removed rule $($r.Name)"
}
}
foreach ($addr in $toAdd) {
$msg = "$addr ($(@($report | Where-Object SourceAddress -eq $addr)[0].Failures) failures in $Minutes min)"
if ($WhatIfPreference) { Write-Status "Would block $msg" } else { Write-ChangeLog "Blocked $msg" }
}
foreach ($addr in $toRemove) { if ($WhatIfPreference) { Write-Status "Would unblock $addr" } else { Write-ChangeLog "Unblocked $addr" } }
if ($PSCmdlet.ShouldProcess($StatePath, 'Save block list state')) {
$dir = Split-Path -Parent $StatePath
if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$items = @($final.Keys | Sort-Object | ForEach-Object { [pscustomobject]@{ Address = $_; FirstBlockedUtc = $final[$_] } })
$json = if ($items.Count) { ConvertTo-Json -InputObject $items -Depth 3 } else { '[]' }
Set-Content -LiteralPath $StatePath -Value $json -Encoding UTF8
}
} else {
Write-Status 'Report only. Add -Apply to create or update the firewall rules (add -WhatIf to preview).'
}
# ---- Output ---------------------------------------------------------------------------------------------
if ($CsvPath) {
$report | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8 -WhatIf:$false
Write-Status "CSV written: $CsvPath"
}
if ($PassThru) { return $report }
if (-not $CsvPath -and $report.Count) {
$report | Select-Object SourceAddress, Failures, DistinctUsers, LogonTypes, LastSeen, Decision | Format-Table -AutoSize
}
0f413709fc7102e3f2864d495fa4385b131e5cca8004fa5c95ffc7781e262476curl -fsSL -o Block-RdpBruteForce.ps1 https://scr.srvscripts.com/rdp-brute-force-blocker/Block-RdpBruteForce.ps1 && curl -fsSL https://scr.srvscripts.com/rdp-brute-force-blocker/Block-RdpBruteForce.ps1.sha256 | sha256sum -cInvoke-WebRequest -Uri 'https://scr.srvscripts.com/rdp-brute-force-blocker/Block-RdpBruteForce.ps1' -OutFile 'Block-RdpBruteForce.ps1'; if ((Get-FileHash 'Block-RdpBruteForce.ps1' -Algorithm SHA256).Hash -eq '0F413709FC7102E3F2864D495FA4385B131E5CCA8004FA5C95FFC7781E262476') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.Also on GitHub: github.com/srvscripts/scripts
Frequently asked questions
How do I block RDP brute force attacks on Windows Server?
Limit who can reach RDP (VPN, RD Gateway or an allow-list), and block repeat offenders. This script reads failed logons (event 4625) and adds the worst source IPs to a Windows Firewall block rule.
Which event ID shows failed RDP logons?
Event 4625 in the Security log. With Network Level Authentication the logon type is usually 3; without NLA it is 10 (RemoteInteractive).
Why does event 4625 have no source IP?
Windows did not record a network source for that attempt (the field shows “-“). Such events cannot be blocked by address; check the other RDP logs for the client address.
Will the script block my own IP?
Not if it is in -AllowList or -AllowListPath, and never if it is a private address (unless you add -IncludePrivate). Add your office and VPN addresses before the first -Apply.
How do I unblock an address?
Add it to the allow-list and run the script with -Apply: it removes allow-listed addresses from the rules. Or use -ExpireDays to unblock automatically.
Is this like fail2ban for Windows?
Similar idea: it reads failures from a log and blocks the source in the firewall. It runs on a schedule rather than watching the log continuously.