Emergency server help: get in touch

Block RDP Brute Force on Windows Server: PowerShell Script

Free PowerShell script that reads failed RDP logons (event 4625), counts them per source IP and blocks repeat offenders with Windows Firewall rules, with allow-list and -WhatIf.

Version
1.0.1
Last updated
October 7, 2026
Language
PowerShell
Tested on
Run on 6 Oct 2026 on a Windows Server 2025 DC (build 26100.33438, Windows PowerShell 5.1) in our lab domain with a Windows 11 Pro member; syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25.
License
MIT
Pricing
Free

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.

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:

  1. Reads event 4625 (“An account failed to log on”) from the Security log for the last -Minutes.
  2. 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.
  3. Groups by the event’s IpAddress field (IPv4-mapped IPv6 addresses such as ::ffff:203.0.113.10 are converted to IPv4) and counts failures and distinct user names.
  4. Decides per address: Block, AlreadyBlocked, BelowThreshold, AllowListed or Private.
  5. With -Apply, writes the block list into inbound Block rules named srvScripts-RDP-BruteForce-001, -002 and so on, and keeps a small state file with the time each address was first blocked, so -ExpireDays can 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-WinEvent and 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

  1. Copy the script from the box on this page (or use the download button) and save it as C:\Scripts\Block-RdpBruteForce.ps1.
  2. 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.
  3. 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

ParameterWhat it doesDefault
-MinutesHow far back to read event 4625 (1 to 10080)60
-ThresholdFailures from one address that trigger a block10
-LogonType3, 10 or both3, 10
-AllowListIPs or CIDR ranges never blocked, for example 203.0.113.10,198.51.100.0/24None
-AllowListPathText file with one IP or CIDR per line; # starts a commentNone
-IncludePrivateAlso block private, loopback, link-local and CGNAT rangesOff
-ApplyCreate or update the firewall rules (supports -WhatIf)Report only
-RuleNameBase name of the rulessrvScripts-RDP-BruteForce
-LocalPortBlock only this TCP port (for example 3389) instead of all traffic0 (all traffic)
-MaxAddressesPerRuleAddresses per rule before the next rule is started1000
-ExpireDaysUnblock addresses this many days after they were first blocked0 (never)
-StatePathState file%ProgramData%\srvScripts\rdp-blocklist.json
-LogPathAppend one line per change to this fileNone
-CsvPath, -PassThruPer-address report as CSV / objectsScreen

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:

ColumnMeaning
SourceAddressIpAddress from the event (IPv4-mapped IPv6 converted to IPv4)
FailuresNumber of 4625 events from that address in the window
DistinctUsers, SampleUsersHow many different user names were tried, and up to five of them
LogonTypesLogon types seen (3, 10)
FirstSeen, LastSeenFirst and last failure in the window (local time)
DecisionBlock, 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

  1. Get-WinEvent -FilterHashtable @{ LogName='Security'; Id=4625; StartTime=... } reads the events. Each event is converted to XML and the named fields LogonType, IpAddress and TargetUserName are read from EventData, so the result does not depend on the language of the event text.
  2. Addresses are parsed with [System.Net.IPAddress]::TryParse; events whose address is “-” (no network source) are counted separately.
  3. Allow-list and private ranges are checked with a bitwise CIDR match that works for IPv4 and IPv6.
  4. Existing rules matching <RuleName>-* are read with Get-NetFirewallAddressFilter and merged with the state file, so deleting the state file never unblocks anyone by accident.
  5. The final list is split into chunks of -MaxAddressesPerRule. Each chunk is written with Set-NetFirewallRule -RemoteAddress (existing rule) or New-NetFirewallRule -Direction Inbound -Action Block -Profile Any -RemoteAddress (new rule); surplus rules are removed. Every change goes through ShouldProcess, so -WhatIf shows 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-RdpBruteForce.ps1Download
# 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
}
Version 1.0.1 · SHA-256 0f413709fc7102e3f2864d495fa4385b131e5cca8004fa5c95ffc7781e262476
Download and verify on Linux or macOS
curl -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 -c
Download and verify in Windows PowerShell
Invoke-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.

Changelog

  • 1.0.1 (6 Oct 2026): with -WhatIf the change log now says "Would block" and nothing is written to the log file. Tested on a Windows Server 2025 DC.
  • 1.0.0: First release.

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.