Any Windows host directly connected to the Internet with an open RDP port is periodically logged for remote brute-force password attempts. To effectively protect the default Remote Desktop protocol port (3389
) from password brute-force attacks and vulnerability exploitations, it is recommended that the RDP host be placed behind a VPN or Remote Desktop Gateway. If it is not possible to implement such a scheme, you will need to configure additional means of RDP protection:
- Don’t use the default user account names in Windows. If this is not done, users with popular account names (
admin
,administrator
,user1
) will be locked regularly according to the account lockup policy. - Enable users to use complex passwords through the Windows password policy;
- Change the default RDP port number 3389 to a different one;
- Configure 2FA for RDP logins (learn how to use the open-source MultiOTP service to enable RDP two-factor authentication).
In this article, we will configure a PowerShell script that mitigates brute-force Remote Desktop (RDP) logins and automatically blocks the IP addresses from which failed RDP authentication attempts are logged.
Detect and Block RDP Brute Force Attacks with PowerShell
If an attempt to authenticate in Windows over RDP fails, event ID 4625 will appear in the Security event log ( An account failed to log on
with LogonType = 3
, See: Analyzing RDP connection event logs ). The event description includes the username under which the connection was attempted and the source IP address.
You can use the Get-WinEvent PowerShell cmdlet to list events with failed RDP logon attempts in the past 12 hour:
$result = Get-WinEvent -FilterHashtable @{LogName='Security';ID=4625; StartTime=(get-date).AddHours(-12)} | ForEach-Object { $eventXml = ([xml]$_.ToXml()).Event [PsCustomObject]@{ UserName = ($eventXml.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text' IpAddress = ($eventXml.EventData.Data | Where-Object { $_.Name -eq 'IpAddress' }).'#text' EventDate = [DateTime]$eventXml.System.TimeCreated.SystemTime } } $result
You can use a PowerShell script to parse the Event Viewer log. If there are more than 5 failed RDP login attempts from a specific IP address in the last 3 hours, the script will add that IP to the Windows Firewall blocking rule.
# The number of failed login attempts from an IP address, after which the IP is blocked. $badAttempts = 5 # Check the number of failed RDP logon events for the last N hours. $intervalHours = 2 # Create a new Windows Firewall rule if the previous blocking rule contains more than N unique IP addresses $ruleMaxEntries = 2000 # Port number on which the RDP service is listening $RdpLocalPort=3389 # Log file $log = "c:\ps\rdp_block.log" # A list of trusted IP addresses from which RDP connections should never be blocked $trustedIPs = @("192.168.1.100", "192.168.1.101","8.8.8.8") $startTime = [DateTime]::Now.AddHours(-$intervalHours) $badRDPlogons = Get-EventLog -LogName 'Security' -After $startTime -InstanceId 4625 | Where-Object { $_.Message -match 'logon type:\s+(3)\s' } | Select-Object @{n='IpAddress';e={$_.ReplacementStrings[-2]}} $ipsArray = $badRDPlogons | Group-Object -Property IpAddress | Where-Object { $_.Count -ge $badAttempts } | ForEach-Object { $_.Name } # Remove trusted IP addresses from the list $ipsArray = $ipsArray | Where-Object { $_ -notin $trustedIPs } if ($ipsArray.Count -eq 0) { return } [System.Collections.ArrayList]$ips = @() [System.Collections.ArrayList]$current_ip_lists = @() $ips.AddRange([string[]]$ipsArray) $ruleCount = 1 $ruleName = "BlockRDPBruteForce" + $ruleCount $foundRuleWithSpace = 0 while ($foundRuleWithSpace -eq 0) { $firewallRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue if ($null -eq $firewallRule) { New-NetFirewallRule -DisplayName $ruleName –RemoteAddress 1.1.1.1 -Direction Inbound -Protocol TCP –LocalPort $RdpLocalPort -Action Block $firewallRule = Get-NetFirewallRule -DisplayName $ruleName $current_ip_lists.Add(@(($firewallRule | Get-NetFirewallAddressFilter).RemoteAddress)) $foundRuleWithSpace = 1 } else { $current_ip_lists.Add(@(($firewallRule | Get-NetFirewallAddressFilter).RemoteAddress)) if ($current_ip_lists[$current_ip_lists.Count – 1].Count -le ($ruleMaxEntries – $ips.Count)) { $foundRuleWithSpace = 1 } else { $ruleCount++ $ruleName = "BlockRDPBruteForce" + $ruleCount } } } # Remove IP addresses already in the blocking firewall rule for ($i = $ips.Count – 1; $i -ge 0; $i--) { foreach ($current_ip_list in $current_ip_lists) { if ($current_ip_list -contains $ips[$i]) { $ips.RemoveAt($i) break } } } if ($ips.Count -eq 0) { exit } # Block the IP address in Windows Firewall and log the action. $current_ip_list = $current_ip_lists[$current_ip_lists.Count – 1] foreach ($ip in $ips) { $current_ip_list += $ip (Get-Date).ToString().PadRight(22) + ' | ' + $ip.PadRight(15) + ' | The IP address has been blocked due to ' + ($badRDPlogons | Where-Object { $_.IpAddress -eq $ip }).Count + ' failed login attempts over ' + $intervalHours + ' hours' >> $log } Set-NetFirewallRule -DisplayName $ruleName -RemoteAddress $current_ip_list
The PowerShell script creates a Windows Defender Firewall rule that blocks connections to the RDP port for the resulting list of IP addresses.
The PowerShell script also writes to the log file the list of blocked IP addresses.
Download the PS1 script from GitHub to your local drive:
$path = "C:\PS\"
If(!(test-path -PathType container $path)){
New-Item -ItemType Directory -Path $path}
$url = "https://raw.githubusercontent.com/maxbakhub/winposh/main/RDS/block-rdp-brute-force.ps1"
$output = "$path\block-rdp-brute-force.ps1"
Invoke-WebRequest -Uri $url -OutFile $output
You can run the script automatically when event 4625 appears in the Event Viewer. You can do this by creating a task trigger by assigning a Task Scheduler job to an Event ID. Such a scheduled task can be created using a PowerShell script:
$taskname="Block_RDP_Brute_Force_Attacks_PS"
$scriptPath="C:\PS\block-rdp-brute-force.ps1"
$triggers = @()
$triggers += New-ScheduledTaskTrigger -AtLogOn
$CIMTriggerClass = Get-CimClass -ClassName MSFT_TaskEventTrigger -Namespace Root/Microsoft/Windows/TaskScheduler:MSFT_TaskEventTrigger
$trigger = New-CimInstance -CimClass $CIMTriggerClass -ClientOnly
$trigger.Subscription =
@"
<QueryList><Query Id="0" Path="Security"><Select Path="Security">*[System[(EventID=4625)]]</Select></Query></QueryList>
"@
$trigger.Enabled = $True
$triggers += $trigger
$User='Nt Authority\System'
$Action=New-ScheduledTaskAction -Execute "Powershell.exe" -Argument "-NoProfile -NoLogo -NonInteractive -ExecutionPolicy Bypass -File $scriptPath"
Register-ScheduledTask -TaskName $taskname -Trigger $triggers -User $User -Action $Action -RunLevel Highest -Force
The triggered scheduled task will run on every failed RDP logon attempt. It will analyze the Event Viewer logs and block IP addresses in the firewall rule. The task runs under a LocalSystem account and is independent of whether the user is logged on or not.
16 comments
Excellent script !
A very minor point is that the script looks for the attempts of the last 3 hours ($Last_n_Hours = [DateTime]::Now.AddHours(-3)) but logs ‘ attempts for 2 hours’. I used a variable instead in order to customise easily and print the corresponding value.
Thank you so much.
Very useful THANK YOU
Only thing I inserted due of error for Set-NetFirewallRule saying “The address is invalid”:
$current_ips = @()
$currentIps = (Get-NetFirewallRule -DisplayName “BlockRDPBruteForce” | Get-NetFirewallAddressFilter ).RemoteAddress
foreach ($cip in $currentIps)
{
$current_ips += $cip
}
Hello, I’m having trouble with this scrip
I get “The address is invalid” when I run it and when add what R0MAN suggest I get no error but nothing is added to the firewall rule… any idea what I’m doing wrong?
Thanks in advance.
Hi Federico
Did you ever find the solution. Battling with this aswell
Sadly no, I never find a solution to this.
Replace
$currentIps = (Get-NetFirewallRule -DisplayName “BlockRDPBruteForce” | Get-NetFirewallAddressFilter ).RemoteAddress
With
$current_ips = @((Get-NetFirewallRule -DisplayName “BlockRDPBruteForce” | Get-NetFirewallAddressFilter ).RemoteAddress)
And it should work.
The script to create the firewall rule works but then the next script to select the offending IPs gives me this error… What is wrong here?
At line:1 char:45
+ $Last_n_Hours = [DateTime]::Now.AddHours(-3)$badRDPlogons = Get-EventLog -LogNam …
+ ~~~~~~~~~~~~~
Unexpected token ‘$badRDPlogons’ in expression or statement.
+ CategoryInfo : ParserError: (:) [], ParentContainsErrorRecordException
+ FullyQualifiedErrorId : UnexpectedToken
$Last_n_Hours = [DateTime]::Now.AddHours(-3)
$badRDPlogons = Get-EventLog -LogName ‘Security’ -after $Last_n_Hours -InstanceId 4625 | ?{$_.Message -match ‘logon type:\s+(3)\s’} | Select-Object @{n=’IpAddress’;e={$_.ReplacementStrings[-2]} }
$getip = $badRDPlogons | group-object -property IpAddress | where {$_.Count -gt 5} | Select -property Name
$log = “C:\ps\rdp_blocked_ip.txt”
$current_ips = @((Get-NetFirewallRule -DisplayName “BlockRDPBruteForce” | Get-NetFirewallAddressFilter ).RemoteAddress)
foreach ($ip in $getip)
{
$current_ips += $ip.name
(Get-Date).ToString() + ‘ ‘ + $ip.name + ‘ The IP address has been blocked due to ‘ + ($badRDPlogons | where {$_.IpAddress -eq $ip.name}).count + ‘ attempts for 2 hours’>> $log # writing the IP blocking event to the log file
}
$current_ips = $current_ips | select -Unique
Set-NetFirewallRule -DisplayName “BlockRDPBruteForce” -RemoteAddress $current_ips
Can anyone post the whole script in its entirety once its working?
$Last_n_Hours = [DateTime]::Now.AddHours(-6)
$badRDPlogons = Get-EventLog -LogName ‘Security’ -after $Last_n_Hours -InstanceId 4625 | Where-Object { $_.Message -match ‘logon type:\s+(3)\s’ } | Select-Object @{n = ‘IpAddress’; e = { $_.ReplacementStrings[-2] } }
$getip = $badRDPlogons | group-object -property IpAddress | Where-Object { $_.Count -gt 10 } | Select-Object -property Name
$log = “C:\ps\rdp_blocked_ip.txt”
$current_ips = @(Get-NetFirewallRule -DisplayName “_BlockRDPBruteForce” | Get-NetFirewallAddressFilter ).RemoteAddress | Sort-Object -Unique
foreach ($ip in $getip) {
$current_ips += $ip.name
(Get-Date).ToString() + ‘ ‘ + $ip.name + ‘ The IP address has been blocked due to ‘ + ($badRDPlogons | Where-Object { $_.IpAddress -eq $ip.name }).count + ‘ attempts for 6 hours’>> $log # writing the IP blocking event to the log file
}
‘Script ran’ >> $log
Set-NetFirewallRule -DisplayName “_BlockRDPBruteForce” -RemoteAddress $current_ips
On the last part:
Set-NetFirewallRule -DisplayName “BlockRDPBruteForce” -RemoteAddress $current_ips
I’m getting the error: The address is invalid. Addresses may be specified as IP addresses, ranges, or subnets.
somehow it’s not parsing it correctly, anyone has run to this?
$getip returns nothing. Looking at the log entries and none of the 4625 entries have an IP address in them, how is this possible?
Here’s my version of the script that creates the rule for you if it doesn’t exist and checks for already blocked IPs. I set it up to trigger on failed login attempts (https://woshub.com/run-script-when-app-opens-closes/). I set it to run whether the user is logged in or not, but not to store password and to run with highest privileges (needed to modify firewall). This helps hide the window but no clue if it actually works while logged off. The action is to call powershell.exe with arguments:
-WindowStyle Hidden -File “”
The script is below. Make sure to set your log path and RDP port (you should probably use a random port in the range 49152 – 65535 rather than the default).
$hours = 3
$log = “”
$startTime = [DateTime]::Now.AddHours(-$hours)
$badRDPlogons = Get-EventLog -LogName ‘Security’ -after $startTime -InstanceId 4625 | ?{$_.Message -match ‘logon type:\s+(3)\s’} | Select-Object @{n=’IpAddress’;e={$_.ReplacementStrings[-2]} }
$getip = $badRDPlogons | group-object -property IpAddress | where {$_.Count -gt 5} | Select -property Name
$firewallRule = Get-NetFirewallRule -DisplayName “BlockRDPBruteForce” -ErrorAction SilentlyContinue
if ($null -eq $firewallRule) {
New-NetFirewallRule -DisplayName “BlockRDPBruteForce” –RemoteAddress 1.1.1.1 -Direction Inbound -Protocol TCP –LocalPort -Action Block
$firewallRule = Get-NetFirewallRule -DisplayName “BlockRDPBruteForce”
}
$current_ips = @(($firewallRule | Get-NetFirewallAddressFilter ).RemoteAddress)
foreach ($ip in $getip) {
if ($current_ips -notcontains $ip.name) {
$current_ips += $ip.name
(Get-Date).ToString() + ‘ ‘ + $ip.name + ‘ The IP address has been blocked due to ‘ + ($badRDPlogons | where {$_.IpAddress -eq $ip.name}).count + ‘ failed login attempts over ‘ + $hours + ‘ hours’>> $log
}
}
Set-NetFirewallRule -DisplayName “BlockRDPBruteForce” -RemoteAddress $current_ips
The formatting cut out a few bits in my comment. So the -File argument should point to your powershell script, you need to enter a log path on the “$log =” line, and make sure to put in your TCP port in the -LocalPort argument.
Also -WindowStyle Hidden isn’t really needed. If you set it to run whether the user is logged in or not, it hides the window. If you don’t set that, then the argument helps. But it still flickers in for a fraction of a second.
Updated version since my rule stopped adding entries after a certain point, not sure what the max is since mine was at 6400+ which it stopped working. This one creates new entries when it hits the max (1000 by default).
$badAttempts = 10
$intervalHours = 3
$ruleMaxEntries = 1000
$log = “”
$startTime = [DateTime]::Now.AddHours(-$intervalHours)
$badRDPlogons = Get-EventLog -LogName ‘Security’ -after $startTime -InstanceId 4625 | ?{$_.Message -match ‘logon type:\s+(3)\s’} | Select-Object @{n=’IpAddress’;e={$_.ReplacementStrings[-2]} }
$ipsArray = $badRDPlogons | Group-Object -property IpAddress | Where-Object {$_.Count -ge $badAttempts} | ForEach-Object {$_.Name}
if ($ipsArray.Count -eq 0) {
return
}
[System.Collections.ArrayList]$ips = @()
$ips.AddRange([string[]]$ipsArray)
$ruleCount = 1
$ruleName = “BlockRDPBruteForce” + $ruleCount
$foundRuleWithSpace = 0
[System.Collections.ArrayList]$current_ip_lists = @()
while ($foundRuleWithSpace -eq 0) {
$firewallRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if ($null -eq $firewallRule) {
New-NetFirewallRule -DisplayName $ruleName –RemoteAddress 1.1.1.1 -Direction Inbound -Protocol TCP –LocalPort -Action Block
$firewallRule = Get-NetFirewallRule -DisplayName $ruleName
$current_ip_lists.Add(@(($firewallRule | Get-NetFirewallAddressFilter ).RemoteAddress))
$foundRuleWithSpace = 1
} else {
$current_ip_lists.Add(@(($firewallRule | Get-NetFirewallAddressFilter ).RemoteAddress))
if ($current_ip_lists[$current_ip_lists.Count – 1].Count -le ($ruleMaxEntries – $ips.Count)) {
$foundRuleWithSpace = 1
} else {
$ruleCount++
$ruleName = “BlockRDPBruteForce” + $ruleCount
}
}
}
for ($i = $ips.Count – 1; $i -ge 0; $i–) {
foreach ($current_ip_list in $current_ip_lists) {
if ($current_ip_list -contains $ips[$i]) {
$ips.RemoveAt($i)
break
}
}
}
if ($ips.Count -eq 0) {
exit
}
$current_ip_list = $current_ip_lists[$current_ip_lists.Count – 1]
foreach ($ip in $ips) {
$current_ip_list += $ip
(Get-Date).ToString().PadRight(22) + ‘ | ‘ + $ip.PadRight(15) + ‘ | The IP address has been blocked due to ‘ + ($badRDPlogons | Where-Object {$_.IpAddress -eq $ip}).Count + ‘ failed login attempts over ‘ + $intervalHours + ‘ hours’>> $log
}
Set-NetFirewallRule -DisplayName $ruleName -RemoteAddress $current_ip_list
Works like a charm! Thank you!