High Level Overview:

Quick Deploy:
Description:
This query detects potential lateral movement within a network by identifying when an RDP connection (EventID 4624, LogonTypes 3,7 and 10) is made to an initial system, followed by a subsequent RDP connection from the initial compromised system to a second system, using the same account within a 30-minute window and considering only logon type 10 (Interactive Remote). This approach focuses on highlighting unusual RDP behavior that suggests lateral movement, which is often associated with attacker tactics during a network breach.
Framework:
MITRE ATT&CKTM
Tactic:
Lateral Movement
TA0008
Reference https://attack.mitre.org/tactics/TA0008/
Technique:
Remote Services
T1021
Rule type: KQL
Rule indices:
Rule Id: de3fffa4-2bc6-47fb-948c-9a9b1acb727d
Severity: Medium/High
Runs every: 1day/1day
Trigger an alert for each event: Yes
References: None
Domain: Endpoint
OS: Windows
Event ID: 4625
Use Case: Threat Detection
Tactic: Lateral Movement
Version: 1.1
Rule authors: Aniket
Pre-Requisites
This rule requires data coming in from Azure Monitor Agent using a data collection rule within Microsoft Sentinel.
Windows Security Events via AMA data connector.
Microsoft Sentinel Workspace and enough RBAC permissions!
Investigation Guide
Triage and analysis
Investigating Suspicious Lateral Movement
The rule identifies movement within a network by identifying when an RDP connection (EventID 4624, LogonTypes 3,7 and 10) is made to an initial system, followed by a subsequent RDP connection from the initial compromised system to a second system, using the same account within a 30-minute window and considering only logon type 10; thus indicating lateral movement
This rule will generate a lot of noise for systems that are being used as a jump host or in an environment where administrators usually move from one server to the other.
In case this rule generates too much noise, consider grouping the alerts together and add whitelisted entities to a watchlist to exempt them from this rule.
Also; consider removing logon types 3,7 and keep only 10 in order to avoid unncessary noise.
Possible investigation steps
Investigate the login failure user name(s).
Investigate the source IP address of the failed SSH login attempt(s).
Run a check against Threat Intelligence database for the IP address.
Investigate other alerts associated with the user/host during the past 48 hours.
Identify the source and the target computer and their roles in the environment.
False positive analysis and exemption creation, if required.
Related Rules
RDP Nesting - 69a45b05-71f5-45ca-8944-2e038747fb39
Response and remediation
Initiate the incident response process based on the outcome of the triage.
Isolate the involved hosts to prevent further post-compromise behavior.
Add the attacker IP Address to Threat intelligence and consider blocking at network level.
Investigate credential exposure on systems compromised or used by the attacker to ensure all compromised accounts are identified. Reset passwords for these accounts and other potentially compromised credentials, such as email, business systems, and web services.
Determine the initial vector abused by the attacker and take action to prevent reinfection through the same vector.
Overview
I was trying to trigger out of the box RDP Nesting rule however noticed that it did not work as expected. Upon closer look at the query, I noticed that it is trying to join on the field "Account" which in my case was different as I was trying to move laterally across using local account. For eg. winserver01/aniket != winserver02/aniket
Another caveat that I noticed in the out of the box rule was that it was trying to check for the IP Address within a table namely "DeviceNetworkInfo" which did not exist in my environment. As this table was not there, the subsequent joins that follow will give an empty result hence rest of the query will never execute.
So I sat down and tried to optimize the query, have a look and finetune according to your environment!
Test Setup
I deployed three virtual machines within Azure for demo purposes. I placed them all in the same Virtual Network and made sure to create public inbound rules both against HTTPS and RDP.
For the first part of this demo, I will RDP into Server 1 using my Public IP and then RDP from Server 1 into Server 2 in a short time-period.
For the second part of this demo, I will then RDP from Server 2 into Server 3.
Make sure the following inbound rules are allowed.

Once logged in, try to RDP to the next server 10.0.0.5
You might get the following error but don’t worry, the event is logged.

Next, I will try to RDP using my public ip to server 3 (10.0.0.6) and then try to RDP locally to server 2 (10.0.0.5)
Incident:
Note- Here, I have used alert grouping and hence, you can see that there are 2 events attached to this incident.

Let's have a look at the most recent incident that I had to wait for!


Tip: If you don't like to wait and want to see the incident immediately, consider using the Start Running property (set to 15 minutes from current date-time) when setting up the Rule Logic within the analytic rule.

Rule Query:
//Suspicious Lateral Movement across Two servers via RDP
let endtime = 1d;
let rdpConnection1=
SecurityEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and LogonType in (7,3,10)
// Labeling the first RDP connection time, computer and ip
| extend
FirstHop = bin(TimeGenerated, 1m),
FirstComputer = toupper(Computer),
FirstRemoteIPAddress = IpAddress,
FirstComputerDomain = tostring(split(Account, @"\")[0]),
Account = tolower(Account);
let rdpConnection2=
SecurityEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and LogonType in (7,3,10)
// Labeling the second RDP connection time, computer and ip
| extend
SecondHop = bin(TimeGenerated, 1m),
SecondComputer = toupper(Computer),
SecondRemoteIPAddress = IpAddress,
SecondComputerDomain = tostring(split(Account, @"\")[0]),
Account = tolower(Account);
SecurityEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and LogonType in (7,3,10)
| join kind=innerunique rdpConnection1 on TargetUserName
| join kind=innerunique rdpConnection2 on TargetUserName
| where FirstComputer != SecondComputer
| where FirstRemoteIPAddress != SecondRemoteIPAddress
and SecondHop > FirstHop
// Ensure the second hop occurs within 30 minutes of the first hop
| where SecondHop <= FirstHop + 30m
//| where SecondRemoteIPAddress == FirstRemoteIPAddress
//| where ThirdRemoteIPAddress == SecondComputerIP
// | extend AccountNTDomain = tostring(split(Account, @"\")[0])
//| extend Account = tostring(split(Account, @"\")[1])
| summarize FirstHopFirstSeen = min(FirstHop), FirstHopLastSeen = max(FirstHop)
by
TargetUserName,
FirstHop,
FirstComputer,
FirstRemoteIPAddress,
FirstComputerDomain,
SecondHop,
SecondComputer,
SecondRemoteIPAddress,
SecondComputerDomain,
AccountType,
Activity,
LogonTypeName
| where LogonTypeName =~ '10 - RemoteInteractive'
| distinct
TargetUserName,
FirstHop,
FirstComputer,
FirstRemoteIPAddress,
FirstComputerDomain,
SecondHop,
SecondComputer,
SecondRemoteIPAddress,
SecondComputerDomain,
AccountType,
Activity,
LogonTypeName
Query Breakdown
Step 1: Define Time Range
let endtime = 1d;
Set the time period/range to the last 24 hours (1d).
Step 2: Extract First RDP Connections
let rdpConnection1 = SecurityEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and LogonType in (7,3,10)
| extend FirstHop = bin(TimeGenerated, 1m), FirstComputer = toupper(Computer), FirstRemoteIPAddress = IpAddress, FirstComputerDomain = tostring(split(Account, @"\")[0]), Account = tolower(Account);
Filters logon events for RDP (LogonType 7, 3, 10) in the last day.
Labels the first RDP hop with time, computer, and remote IP.
Consider reducing to only logon-type 10 to reduce noise
Step 3: Extract Second RDP Connections
let rdpConnection2 = SecurityEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and LogonType in (7,3,10)
| extend SecondHop = bin(TimeGenerated, 1m), SecondComputer = toupper(Computer), SecondRemoteIPAddress = IpAddress, SecondComputerDomain = tostring(split(Account, @"\")[0]), Account = tolower(Account);
Performs the same process as Step 2 but for potential second RDP hops.
Consider reducing to only logon-type 10 to reduce noise
Step 4: Correlate First and Second Connections
SecurityEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and LogonType in (7,3,10)
| join kind=innerunique rdpConnection1 on TargetUserName
| join kind=innerunique rdpConnection2 on TargetUserName
Joins the main dataset with rdpConnection1 and rdpConnection2 using the TargetUserName field.
Ensures the same user is involved in both hops.
Step 5: Filter Suspicious Behavior
| where FirstComputer != SecondComputer
| where FirstRemoteIPAddress != SecondRemoteIPAddress and SecondHop > FirstHop
| where SecondHop <= FirstHop + 30m
Ensures the source and destination computers/IPs are different.
Ensures the second hop happens only after the first hop but within 30 minutes.
Step 6: Summarize and Refine Results
| summarize FirstHopFirstSeen = min(FirstHop), FirstHopLastSeen = max(FirstHop) by TargetUserName, FirstHop, FirstComputer, FirstRemoteIPAddress, FirstComputerDomain, SecondHop, SecondComputer, SecondRemoteIPAddress, SecondComputerDomain, AccountType, Activity, LogonTypeName
| where LogonTypeName =~ '10 - RemoteInteractive'
| distinct TargetUserName, FirstHop, FirstComputer, FirstRemoteIPAddress, FirstComputerDomain, SecondHop, SecondComputer, SecondRemoteIPAddress, SecondComputerDomain, AccountType, Activity, LogonTypeName
Groups the data by user and connection attributes.
Filters only LogonTypeName 10 - RemoteInteractive events (specific to RDP).
Extracts unique results for easier interpretation.
Entity Mapping

Conclusion
This query is useful in detecting lateral movement in the network, a technique often used by attackers post-initial compromise to move between machines and escalate privileges. By identifying unexpected RDP connections between servers involving the same user within a short timeframe, security teams can investigate potential breaches. Automation can also be leveraged to block malicious ip address.
ARM Template
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"workspace": {
"type": "String"
}
},
"resources": [
{
"id": "[concat(resourceId('Microsoft.OperationalInsights/workspaces/providers', parameters('workspace'), 'Microsoft.SecurityInsights'),'/alertRules/de3fffa4-2bc6-47fb-948c-9a9b1acb727d')]",
"name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/de3fffa4-2bc6-47fb-948c-9a9b1acb727d')]",
"type": "Microsoft.OperationalInsights/workspaces/providers/alertRules",
"kind": "Scheduled",
"apiVersion": "2023-12-01-preview",
"properties": {
"displayName": "Suspicious Lateral Movement Detected via RDP (2 Servers)",
"description": "Query detects potential lateral movement within a network by identifying when an RDP connection (EventID 4624, LogonTypes 3,7 and 10) is made to an initial system, followed by a subsequent RDP connection from the initial compromised system to a second system, using the same account within a 30-minute window and considering only logon type 10 (Interactive Remote). This approach focuses on highlighting unusual RDP behavior that suggests lateral movement, which is often associated with attacker tactics during a network breach.",
"severity": "Medium",
"enabled": true,
"query": "//Suspicious Lateral Movement across Two servers via RDP\r\nlet endtime = 1d;\r\nlet rdpConnection1=\r\n SecurityEvent\r\n | where TimeGenerated >= ago(endtime)\r\n | where EventID == 4624 and LogonType in (7,3,10)\r\n // Labeling the first RDP connection time, computer and ip\r\n | extend\r\n FirstHop = bin(TimeGenerated, 1m),\r\n FirstComputer = toupper(Computer),\r\n FirstRemoteIPAddress = IpAddress,\r\n FirstComputerDomain = tostring(split(Account, @\"\\\")[0]),\r\n Account = tolower(Account);\r\nlet rdpConnection2=\r\n SecurityEvent\r\n | where TimeGenerated >= ago(endtime)\r\n | where EventID == 4624 and LogonType in (7,3,10)\r\n // Labeling the second RDP connection time, computer and ip\r\n | extend\r\n SecondHop = bin(TimeGenerated, 1m),\r\n SecondComputer = toupper(Computer),\r\n SecondRemoteIPAddress = IpAddress,\r\n SecondComputerDomain = tostring(split(Account, @\"\\\")[0]),\r\n Account = tolower(Account);\r\nSecurityEvent\r\n| where TimeGenerated >= ago(endtime)\r\n| where EventID == 4624 and LogonType in (7,3,10)\r\n| join kind=innerunique rdpConnection1 on TargetUserName\r\n| join kind=innerunique rdpConnection2 on TargetUserName\r\n| where FirstComputer != SecondComputer\r\n| where FirstRemoteIPAddress != SecondRemoteIPAddress\r\n and SecondHop > FirstHop\r\n// Ensure the second hop occurs within 30 minutes of the first hop\r\n| where SecondHop <= FirstHop + 30m\r\n //| where SecondRemoteIPAddress == FirstRemoteIPAddress\r\n //| where ThirdRemoteIPAddress == SecondComputerIP\r\n // | extend AccountNTDomain = tostring(split(Account, @\"\\\")[0])\r\n //| extend Account = tostring(split(Account, @\"\\\")[1])\r\n | summarize FirstHopFirstSeen = min(FirstHop), FirstHopLastSeen = max(FirstHop)\r\n by\r\n TargetUserName,\r\n FirstHop,\r\n FirstComputer,\r\n FirstRemoteIPAddress,\r\n FirstComputerDomain,\r\n SecondHop,\r\n SecondComputer,\r\n SecondRemoteIPAddress,\r\n SecondComputerDomain,\r\n AccountType,\r\n Activity,\r\n LogonTypeName\r\n | where LogonTypeName =~ '10 - RemoteInteractive'\r\n | distinct\r\n TargetUserName,\r\n FirstHop,\r\n FirstComputer,\r\n FirstRemoteIPAddress,\r\n FirstComputerDomain,\r\n SecondHop,\r\n SecondComputer,\r\n SecondRemoteIPAddress,\r\n SecondComputerDomain,\r\n AccountType,\r\n Activity,\r\n LogonTypeName\r\n",
"queryFrequency": "P1D",
"queryPeriod": "P7D",
"triggerOperator": "GreaterThan",
"triggerThreshold": 0,
"suppressionDuration": "PT5H",
"suppressionEnabled": false,
"startTimeUtc": null,
"tactics": [
"LateralMovement"
],
"techniques": [
"T1021"
],
"subTechniques": [],
"alertRuleTemplateName": null,
"incidentConfiguration": {
"createIncident": true,
"groupingConfiguration": {
"enabled": false,
"reopenClosedIncident": false,
"lookbackDuration": "PT5H",
"matchingMethod": "AllEntities",
"groupByEntities": [],
"groupByAlertDetails": [],
"groupByCustomDetails": []
}
},
"eventGroupingSettings": {
"aggregationKind": "AlertPerResult"
},
"alertDetailsOverride": null,
"customDetails": null,
"entityMappings": [
{
"entityType": "Account",
"fieldMappings": [
{
"identifier": "Name",
"columnName": "TargetUserName"
}
]
},
{
"entityType": "Host",
"fieldMappings": [
{
"identifier": "HostName",
"columnName": "FirstComputer"
}
]
},
{
"entityType": "Host",
"fieldMappings": [
{
"identifier": "HostName",
"columnName": "SecondComputer"
}
]
},
{
"entityType": "IP",
"fieldMappings": [
{
"identifier": "Address",
"columnName": "FirstRemoteIPAddress"
}
]
}
],
"sentinelEntitiesMappings": null,
"templateVersion": null
}
}
]
}
Comments