Today, my Windows server account was automatically locked due to a series of login failures (Event ID 4740), preventing me from logging in normally. I urgently checked the firewall rules and found that manually blocking these IPs was too late, and the frequent account lockouts were impacting normal business operations.

Overview

This script is primarily used to automatically detect IPs that fail to log in via RDP. When the number of failed attempts by the same IP exceeds a threshold within a set time window, its access will be automatically blocked. It can also detect account lockout events (4740) and attempt to unlock locked accounts, reducing manual intervention.

Core Logic

  • Permission Check: Ensures the script runs as an administrator.

  • Log Management: Creates log directories, rotates log files, and prevents files from becoming too large.

  • Status Management: Reads/saves blocked IP records for long-term management.

  • Event Scan: Reads 4625 events, extracts the source IPs of failed logins, and excludes them from the whitelist.

  • Blocking Policy: Counts the number of failed attempts by the same IP; if the number exceeds a threshold, it is added to the firewall blacklist.

  • Account Unlock: Checks 4740 events and executes unlock commands based on the domain/local environment.

  • Log Cleanup and Summary: Deletes expired logs and generates an execution summary.

Advantages and Precautions

  • Automates handling of RDP brute-force attacks, reducing security risks.

  • Retains blocking records for long-term tracking.

  • In a domain environment, ensure the ActiveDirectory module is available.

Note: Automatic removal of firewall rules is disabled and requires manual management by the administrator.

Flow diagram

  Start
   โ”‚
   โ–ผ
[Check Admin?]โ”€โ”€Noโ”€โ”€โ–บ(Exit: need admin)
   โ”‚Yes
   โ–ผ
[Ensure state/log dirs & rotate logs]
   โ”‚
   โ–ผ
[Load blocked state JSON]
   โ”‚
   โ–ผ
[Query Security: Event 4625 (since WindowMinutes)]
   โ”‚
   โ–ผ
[Parse events โ†’ Normalize IPs โ†’ Filter whitelist]
   โ”‚
   โ–ผ
[Group by IP & Count failures]
   โ”‚
   โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ If count >= Threshold โ”€โ”€โ–บ Not in state? โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                โ”‚Yes
                โ–ผ
         [Create Firewall Block rule]
                โ”‚
                โ–ผ
         [Save state (blocked_ips.json)]
                โ”‚
                โ–ผ
         [Search recent Event 4740 (locks)]
                โ”‚
                โ–ผ
         [Try Unlock: Unlock-ADAccount โ†’ Unlock-LocalUser โ†’ net user]
                โ”‚
                โ–ผ
         [Log result / continue]
                โ”‚
                โ–ผ
      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
      โ”‚If count < Threshold:     โ”‚
      โ”‚Skip block & unlock step  โ”‚
      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                โ”‚
                โ–ผ
[Clean old logs (LogRetentionDays)]
   โ”‚
   โ–ผ
[Summary โ†’ Write log โ†’ Exit]

PowerShell script code

<#
.SYNOPSIS
  RDP Automatic Anti-Burst Strike: If a login fails to detect a source IP address, the system will ban it and attempt to unlock the locked account after the ban.

.DESCRIPTION
  - Scan Security event logs for port 4625 (LogonType=10) to identify the source IP of RDP login failures.
  - IPs exceeding the threshold will be added to the Windows Firewall blacklist (for RDP ports).
  - Locate recent port 4740 (account locked) events and attempt to unlock them (domain/local), recording the results.
  - Save the blocking records as JSON for automatic unblocking upon expiration.
  - Output runtime logs and support simple rotation.

.NOTES
  - Run with administrator privileges.
  - If in a domain environment and you need to unlock a domain account, ensure the ActiveDirectory module is available and the running account has the necessary permissions.
#>

#region --- Parameters ---
param(
    [int]$Threshold = 5,                                # Threshold for the number of failures of the same IP within a time window
    [int]$WindowMinutes = 10,                           # Time range
    [int]$BlockDurationMinutes = 3650 * 24 * 60,        # Ban duration
    [int]$LogRetentionDays = 30,                        # Log retention time (Day)
    [string]$RDPPort = "3389",                          # RDP Port
    [string]$StateFile = "C:\Scripts\blocked_ips.json", # Ban records
    [string]$LogFile = "C:\Scripts\RDP-AutoBlock.log",  # Log file
    [string[]]$Whitelist = @("127.0.0.1","::1"),        # Whitelist
    [switch]$VerboseLogging
)
#endregion

#region --- Check Environmental ---
function Assert-Admin {
    $current = [System.Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object System.Security.Principal.WindowsPrincipal($current)
    if (-not $principal.IsInRole([System.Security.Principal.WindowsBuiltinRole]::Administrator)) {
        Write-Error "This script needs to be run with administrator privileges."
        exit 1
    }
}
Assert-Admin

$stateDir = Split-Path $StateFile -Parent
$logDir = Split-Path $LogFile -Parent
if (-not (Test-Path $stateDir)) { New-Item -Path $stateDir -ItemType Directory -Force | Out-Null }
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }

#endregion

#region --- Log ---
function Write-Log {
    param(
        [string]$Message,
        [ValidateSet("INFO","WARN","ERROR")] [string]$Level = "INFO"
    )
    $ts = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
    $line = "[$ts] [$Level] $Message"
    try {
        # Using Add-Content can prevent BOM issues in some PS versions.
        Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue
    } catch {
        # Ignore log write errors (try not to affect the main process).
    }
    if ($VerboseLogging) { Write-Host $line }
}

function Rotate-LogIfNeeded {
    param(
        [int]$MaxSizeBytes = 5MB,
        [int]$Keep = 5
    )
    try {
        if (Test-Path $LogFile) {
            $fi = Get-Item $LogFile -ErrorAction SilentlyContinue
            if ($fi -and $fi.Length -gt $MaxSizeBytes) {
                $base = [IO.Path]::GetFileNameWithoutExtension($LogFile)
                $ext  = [IO.Path]::GetExtension($LogFile)
                $timestamp = (Get-Date).ToString("yyyyMMdd-HHmmss")
                $archive = Join-Path $fi.DirectoryName ("{0}-{1}{2}" -f $base, $timestamp, $ext)
                Move-Item -Path $LogFile -Destination $archive -Force -ErrorAction SilentlyContinue
                $archives = Get-ChildItem -Path $fi.DirectoryName -Filter "$base-*${ext}" | Sort-Object LastWriteTime -Descending
                if ($archives.Count -gt $Keep) {
                    $archives | Select-Object -Skip $Keep | ForEach-Object { Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue }
                }
            }
        }
    } catch {
        Write-Log "Log Err: $_" "WARN"
    }
}

# Try rotate
Rotate-LogIfNeeded

Write-Log "=== Start script ===. Threshold=$Threshold WindowMinutes=$WindowMinutes BlockDurationMinutes=$BlockDurationMinutes"

#endregion

#region --- Load and save ban logs ---
# Structure: { "<ip>": { ruleName, blockedAt, expire, reasonCount } }
$blockedHT = @{}

function Load-State {
    if (Test-Path $StateFile) {
        try {
            $raw = Get-Content $StateFile -Raw -ErrorAction Stop
            if ($raw -and $raw.Trim() -ne "") {
                $tmp = $raw | ConvertFrom-Json
                foreach ($p in $tmp.PSObject.Properties) {
                    $blockedHT[$p.Name] = $p.Value
                }
            }
        } catch {
            Write-Log "Failed to read status file, using empty status: $_" "WARN"
        }
    }
}
Load-State

function Save-State {
    try {
        # Construct ordered objects to ensure the stability of the output JSON structure.
        $obj = [ordered]@{}
        foreach ($k in $blockedHT.Keys) {
            $obj[$k] = $blockedHT[$k]
        }

        $json = $obj | ConvertTo-Json -Depth 6

        # Safe write: Write to a temporary file and then overwrite.
        $tmp = "$StateFile.tmp"
        $json | Out-File -FilePath $tmp -Encoding UTF8 -Force
        Move-Item -Path $tmp -Destination $StateFile -Force
    } catch {
        Write-Log "Save failed: $_" "ERROR"
    }
}
#endregion

#region --- Helper function: Get IP, user and more info from event ---
function Normalize-Ip {
    param([string]$ip)
    if (-not $ip) { return $null }
    $ip = $ip.Trim()
    if ($ip -eq "-" -or $ip -eq "127.0.0.1" -or $ip -eq "::1") { return $ip }
    # Remove port๏ผˆfor example: "1.2.3.4:12345"๏ผ‰
    if ($ip -match "^(?<addr>[^:]+):\d+$") { $ip = $matches['addr'] }
    return $ip
}

function Get-EventIp {
    param($ev)
    try {
        # Sometimes the event object already contains properties; try ToXml first.
        $xml = [xml]$ev.ToXml()
        # Common fields: IpAddress, Ip
        $dataNode = $xml.Event.EventData.Data | Where-Object { $_.Name -in @('IpAddress','Ip') }
        if ($dataNode) {
            $val = $dataNode.'#text'
            $val = Normalize-Ip $val
            if ($val) { return $val }
        }

        # Some systems put the IP address in TargetDomainName, WorkstationName, or Message.
        $dataNode2 = $xml.Event.EventData.Data | Where-Object { $_.Name -in @('WorkstationName','TargetDomainName') }
        if ($dataNode2) {
            $val = $dataNode2.'#text'
            $val = Normalize-Ip $val
            if ($val) { return $val }
        }

        # fallback: Parse from Message (Contain: 'Source Network Address:')
        if ($ev.Message -match 'Source Network Address:\s*([^\r\n]+)') {
            $val = $matches[1]
            $val = Normalize-Ip $val
            if ($val) { return $val }
        }
    } catch {
        # Ignore parse error
    }
    return $null
}
#endregion

#region --- Main logic: Unable to log in and have your IP blocked ---
$startTime = (Get-Date).AddMinutes(-1 * $WindowMinutes)
Write-Log "Check $startTime -> Now: 4625 Login failed event..."

$normalizedWhitelist = @{}
foreach ($w in $Whitelist) {
    if ($w) { $normalizedWhitelist[$w.ToLower()] = $true }
}

try {
    $filter = @{
        LogName = 'Security'
        ProviderName = 'Microsoft-Windows-Security-Auditing'
        Id = 4625
        StartTime = $startTime
    }
    $failEvents = Get-WinEvent -FilterHashtable $filter -MaxEvents 10000 -ErrorAction Stop
} catch {
    Write-Log "Cannot read log of the 4625 event: $_" "ERROR"
    $failEvents = @()
}

$rdpFails = @()
foreach ($ev in $failEvents) {
    try {
        $xml = [xml]$ev.ToXml()
        $logonType = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'LogonType' }).'#text'
        if ($logonType -and ($logonType -eq '3' -or $logonType -eq '10')) {
            $ip = Get-EventIp -ev $ev
            $ip = Normalize-Ip $ip
            if ($ip -and $ip.Trim() -ne "") {
                if ($normalizedWhitelist.ContainsKey($ip.ToLower())) {
                    continue
                }
                $acct = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
                $rdpFails += [PSCustomObject]@{ Time=$ev.TimeCreated; IP=$ip; Account=$acct; Event=$ev }
            }
        }
    } catch {
        # Ignore parse error
    }
}

# Group by IP and count
$groups = $rdpFails | Group-Object -Property IP

foreach ($g in $groups) {
    $ip = $g.Name
    $count = $g.Count
    if ($count -ge $Threshold) {
        if ($blockedHT.ContainsKey($ip)) {
            Write-Log "IP $ip already in ban list (Exp: $($blockedHT[$ip].expire))"
            continue
        }

        $safeNamePart = $ip -replace '[:\\\/\*\?"<>| ]','-'
        $safeRuleName = "AutoBlockRDP-$safeNamePart"

        try {
            Write-Log "Ready to ban IP $ip (Err qty=$count) -> Rule name=$safeRuleName"
            # Create firewall rule.
            New-NetFirewallRule -DisplayName $safeRuleName -Direction Inbound -Action Block -RemoteAddress $ip -Protocol TCP -LocalPort $RDPPort -Description "Auto-blocked due to RDP brute force" -ErrorAction Stop

            $expire = (Get-Date).AddMinutes($BlockDurationMinutes)
            $blockedHT[$ip] = @{
                ruleName = $safeRuleName
                blockedAt = (Get-Date).ToString("o")
                expire = $expire.ToString("o")
                reasonCount = $count
            }
            Save-State
            Write-Log "Ban successful: $ip -> $safeRuleName, expire=$expire"
        } catch {
            Write-Log "Create failed: $_" "ERROR"
            continue
        }

        # Try to unlock account๏ผˆ4740๏ผ‰
        Write-Log "Find the most recent 4740 (account locked) event and try to unlock it..."
        try {
            $lockFilter = @{
                LogName = 'Security'
                ProviderName = 'Microsoft-Windows-Security-Auditing'
                Id = 4740
                StartTime = $startTime
            }
            $lockEvents = Get-WinEvent -FilterHashtable $lockFilter -MaxEvents 1000 -ErrorAction SilentlyContinue
        } catch {
            $lockEvents = @()
            Write-Log "Failed to read event 4740: $_" "WARN"
        }

        foreach ($le in $lockEvents) {
            try {
                $xml = [xml]$le.ToXml()
                $lockedUser = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
                if (-not $lockedUser) { continue }
                Write-Log "Locked account detected: $lockedUser (Event time: $($le.TimeCreated))"

                # If it is a domain environment, try Unlock-ADAccount first.
                $isDomain = $false
                try { $isDomain = (Get-CimInstance -ClassName Win32_ComputerSystem).PartOfDomain } catch {}
                if ($isDomain) {
                    if (Get-Command -Name Unlock-ADAccount -ErrorAction SilentlyContinue) {
                        try {
                            Unlock-ADAccount -Identity $lockedUser -ErrorAction Stop
                            Write-Log "Domain account $lockedUser has been unlocked via Unlock-ADAccount."
                            continue
                        } catch {
                            Write-Log "Unlock-ADAccount failed: $_" "WARN"
                        }
                    } else {
                        try {
                            Import-Module ActiveDirectory -ErrorAction Stop
                            Unlock-ADAccount -Identity $lockedUser -ErrorAction Stop
                            Write-Log "The domain account $lockedUser has been unlocked after importing the module."
                            continue
                        } catch {
                            Write-Log "Unable to unlock domain account ${lockedUser} using ActiveDirectory module: $_" "WARN"
                        }
                    }
                }

                if (Get-Command -Name Unlock-LocalUser -ErrorAction SilentlyContinue) {
                    try {
                        Unlock-LocalUser -Name $lockedUser -ErrorAction Stop
                        Write-Log "Local account $lockedUser has been unlocked via Unlock-LocalUser."
                        continue
                    } catch {
                        Write-Log "Unlock-LocalUser failed: $_" "WARN"
                    }
                }

                try {
                    net user $lockedUser /active:yes | Out-Null
                    Write-Log "The rollback command has been executed: net user $lockedUser /active:yes (This may not actually clear the lock)."
                } catch {
                    Write-Log "The rollback command `net user` failed: $_" "ERROR"
                }
            } catch {
                Write-Log "An error occurred while handling event 4740: $_" "WARN"
            }
        }
    }
}
#endregion

#region --- Remove expired ban (already disabled) ---
Write-Log "Automatic removal is disabled: The script will not automatically remove firewall rules. Please manually remove them or edit the state file." "WARN"
#endregion

#region --- Clean up very old log files (based on retention days) ---
try {
    $cutoff = (Get-Date).AddDays(-1 * $LogRetentionDays)
    Get-ChildItem -Path $logDir -Filter "RDP-AutoBlock-*.log" -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt $cutoff } | ForEach-Object {
        Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue
    }
} catch {
    Write-Log "Clear failed: $_" "WARN"
}
#endregion

#region --- Summary log and exit ---
try {
    $blockedSummary = $blockedHT.GetEnumerator() | ForEach-Object { "$($_.Key) -> expire=$($_.Value.expire) rule=$($_.Value.ruleName)" } | Out-String
    Write-Log "Script ended. Current ban count: $($blockedHT.Keys.Count)"
    if ($blockedSummary.Trim() -ne "") { Write-Log "Current ban detail:`n$blockedSummary" }
    Write-Log "=== Script execution complete ==="
} catch {
    Write-Log "End of summary write failed: $_" "WARN"
}
#endregion

Create a scheduled task

<#
.SYNOPSIS
  Create a scheduled task.
.DESCRIPTION
  The script C:\Scripts\RDP-AutoBlock.ps1 is executed in the background every 5 minutes. 
  (If your script is not in the path mentioned above, you should change the script path.)
#>

# Parameters
$TaskName = "RDP-AutoBlock"
$ScriptPath = "C:\Scripts\RDP-AutoBlock.ps1"
$IntervalMinutes = 5

# Check if the script exists
if (-not (Test-Path $ScriptPath)) {
    Write-Host "Not found the script file: $ScriptPath" -ForegroundColor Red
    exit 1
}

# Remove old task (If existsed)
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
    Write-Host "Old task [$TaskName] was exists๏ผŒremoving..."
    Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
}

# Create task action: execute PowerShell script
$action = New-ScheduledTaskAction `
    -Execute "PowerShell.exe" `
    -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$ScriptPath`""

# Create trigger: Starting from the current time, repeat every 5 minutes.
$trigger = New-ScheduledTaskTrigger `
    -Once -At (Get-Date) `
    -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes) `
    -RepetitionDuration (New-TimeSpan -Days 3650)  # 10 year

# Registry task
Write-Host "Registering scheduled task [$TaskName]..."
Register-ScheduledTask `
    -TaskName $TaskName `
    -Action $action `
    -Trigger $trigger `
    -Description "Auto block brute-force RDP IPs and unlock accounts" `
    -RunLevel Highest `
    -User "SYSTEM"

# Verificate
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
    Write-Host "Task [$TaskName] has been created successfully.!" -ForegroundColor Green
    Write-Host "Executes automatically every $IntervalMinutes minutes: $ScriptPath"
} else {
    Write-Host "Task creation failed. Please check permissions or path." -ForegroundColor Red
}