Detection rules › Kusto
RDP Nesting
Query detects potential lateral movement within a network by identifying when an RDP connection (EventID 4624, LogonType 10) is made to an initial system, followed by a subsequent RDP connection from that system to another, using the same account within a 60-minute window. To reduce false positives, it excludes scenarios where the same account has made 5 or more connections to the same set of computers in the previous 7 days. This approach focuses on highlighting unusual RDP behaviour that suggests lateral movement, which is often associated with attacker tactics during a network breach.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Lateral Movement | T1021 Remote Services |
Event coverage
| Provider | Event | Title |
|---|---|---|
| Security-Auditing | Event ID 4624 | An account was successfully logged on. |
Rule body kusto
id: 69a45b05-71f5-45ca-8944-2e038747fb39
name: RDP Nesting
description: |
'Query detects potential lateral movement within a network by identifying when an RDP connection (EventID 4624, LogonType 10) is made to an initial system, followed by a subsequent RDP connection from that system to another, using the same account within a 60-minute window.
To reduce false positives, it excludes scenarios where the same account has made 5 or more connections to the same set of computers in the previous 7 days. This approach focuses on highlighting unusual RDP behaviour that suggests lateral movement, which is often associated with attacker tactics during a network breach.'
severity: Medium
requiredDataConnectors:
- connectorId: SecurityEvents
dataTypes:
- SecurityEvent
- connectorId: WindowsSecurityEvents
dataTypes:
- SecurityEvent
- connectorId: WindowsForwardedEvents
dataTypes:
- WindowsEvent
queryFrequency: 1d
queryPeriod: 8d
triggerOperator: gt
triggerThreshold: 0
tactics:
- LateralMovement
relevantTechniques:
- T1021
query: |
let endtime = 1d;
// Function to resolve hostname to IP address using DNS logs or a lookup table (example syntax)
let rdpConnections =
(union isfuzzy=true
(
SecurityEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and LogonType == 10
// Labeling the first RDP connection time, computer and ip
| extend
FirstHop = bin(TimeGenerated, 1m),
FirstComputer = toupper(Computer),
FirstRemoteIPAddress = IpAddress,
Account = tolower(Account)
),
(
WindowsEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and EventData has ("10")
| extend LogonType = tostring(EventData.LogonType)
| where LogonType == 10 // Labeling the first RDP connection time, computer and ip
| extend Account = strcat(tostring(EventData.TargetDomainName), "", tostring(EventData.TargetUserName))
| extend IpAddress = tostring(EventData.IpAddress)
| extend
FirstHop = bin(TimeGenerated, 1m),
FirstComputer = toupper(Computer),
FirstRemoteIPAddress = IpAddress,
Account = tolower(Account)
))
| join kind=inner (
(union isfuzzy=true
(
SecurityEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and LogonType == 10
// Labeling the second RDP connection time, computer and ip
| extend
SecondHop = bin(TimeGenerated, 1m),
SecondComputer = toupper(Computer),
SecondRemoteIPAddress = IpAddress,
Account = tolower(Account)
),
(
WindowsEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and EventData has ("10")
| extend LogonType = toint(EventData.LogonType)
| where LogonType == 10 // Labeling the second RDP connection time, computer and ip
| extend Account = strcat(tostring(EventData.TargetDomainName), "", tostring(EventData.TargetUserName))
| extend IpAddress = tostring(EventData.IpAddress)
| extend
SecondHop = bin(TimeGenerated, 1m),
SecondComputer = toupper(Computer),
SecondRemoteIPAddress = IpAddress,
Account = tolower(Account)
))
)
on Account
| distinct
Account,
FirstHop,
FirstComputer,
FirstRemoteIPAddress,
SecondHop,
SecondComputer,
SecondRemoteIPAddress,
AccountType,
Activity,
LogonTypeName,
ProcessName;
// Resolve hostnames to IP addresses device network Ip's
let listOfFirstComputer = rdpConnections | distinct FirstComputer;
let listOfSecondComputer = rdpConnections | distinct SecondComputer;
let resolvedIPs =
DeviceNetworkInfo
| where TimeGenerated >= ago(endtime)
| where isnotempty(ConnectedNetworks) and NetworkAdapterStatus == "Up"
| extend ClientIP = tostring(parse_json(IPAddresses[0]).IPAddress)
| where isnotempty(ClientIP)
| where DeviceName in~ (listOfFirstComputer) or DeviceName in~ (listOfSecondComputer)
| summarize arg_max(TimeGenerated, ClientIP) by Computer= DeviceName
| project Computer=toupper(Computer), ResolvedIP = ClientIP;
// Join resolved IPs with the RDP connections
rdpConnections
| join kind=inner (resolvedIPs) on $left.FirstComputer == $right.Computer
| join kind=inner (resolvedIPs) on $left.SecondComputer == $right.Computer
// | where ResolvedIP != ResolvedIP1
| distinct
Account,
FirstHop,
FirstComputer,
FirstComputerIP = ResolvedIP,
FirstRemoteIPAddress,
SecondHop,
SecondComputer,
SecondComputerIP = ResolvedIP1,
SecondRemoteIPAddress,
AccountType,
Activity,
LogonTypeName,
ProcessName
// Ensure the first connection is before the second connection
// Identify only RDP to another computer from within the first RDP connection by only choosing matches where the Computer names do not match
// Ensure the IPAddresses do not match by excluding connections from the same computers with first hop RDP connections to multiple computers
| where FirstComputer != SecondComputer
and FirstRemoteIPAddress != SecondRemoteIPAddress
and SecondHop > FirstHop
// Ensure the second hop occurs within 30 minutes of the first hop
| where SecondHop <= FirstHop + 30m
| where SecondRemoteIPAddress == FirstComputerIP
| summarize FirstHopFirstSeen = min(FirstHop), FirstHopLastSeen = max(FirstHop)
by
Account,
FirstComputer,
FirstComputerIP,
FirstRemoteIPAddress,
SecondHop,
SecondComputer,
SecondComputerIP,
SecondRemoteIPAddress,
AccountType,
Activity,
LogonTypeName,
ProcessName
| extend
AccountName = tostring(split(Account, @"")[1]),
AccountNTDomain = tostring(split(Account, @"")[0])
| extend
HostName1 = tostring(split(FirstComputer, ".")[0]),
DomainIndex = toint(indexof(FirstComputer, '.'))
| extend HostNameDomain1 = iff(DomainIndex != -1, substring(FirstComputer, DomainIndex + 1), FirstComputer)
| extend
HostName2 = tostring(split(SecondComputer, ".")[0]),
DomainIndex = toint(indexof(SecondComputer, '.'))
| extend HostNameDomain2 = iff(DomainIndex != -1, substring(SecondComputer, DomainIndex + 1), SecondComputer)
| project-away DomainIndex
entityMappings:
- entityType: Account
fieldMappings:
- identifier: FullName
columnName: Account
- identifier: Name
columnName: AccountName
- identifier: NTDomain
columnName: AccountNTDomain
- entityType: Host
fieldMappings:
- identifier: FullName
columnName: FirstComputer
- identifier: HostName
columnName: HostName1
- identifier: NTDomain
columnName: HostNameDomain1
- entityType: Host
fieldMappings:
- identifier: FullName
columnName: SecondComputer
- identifier: HostName
columnName: HostName2
- identifier: NTDomain
columnName: HostNameDomain2
- entityType: IP
fieldMappings:
- identifier: Address
columnName: FirstIPAddress
version: 1.2.8
kind: Scheduled
metadata:
source:
kind: Community
author:
name: Microsoft Security Research
support:
tier: Community
categories:
domains: [ "Security - Threat Protection" ]
Stages and Predicates
Parameters
let endtime = 1d;
Let binding: listOfFirstComputer
let listOfFirstComputer = rdpConnections | distinct FirstComputer;
Derived from rdpConnections.
Let binding: listOfSecondComputer
let listOfSecondComputer = rdpConnections | distinct SecondComputer;
Derived from rdpConnections.
Let binding: resolvedIPs
let resolvedIPs = DeviceNetworkInfo
| where TimeGenerated >= ago(endtime)
| where isnotempty(ConnectedNetworks) and NetworkAdapterStatus == "Up"
| extend ClientIP = tostring(parse_json(IPAddresses[0]).IPAddress)
| where isnotempty(ClientIP)
| where DeviceName in~ (listOfFirstComputer) or DeviceName in~ (listOfSecondComputer)
| summarize arg_max(TimeGenerated, ClientIP) by Computer= DeviceName
| project Computer=toupper(Computer), ResolvedIP = ClientIP;
Derived from endtime, listOfFirstComputer, listOfSecondComputer.
union isfuzzy=true (2 sources)
Each leg below queries one source; the rule matches if any leg does. Sources: SecurityEvent, WindowsEvent
Leg 1: SecurityEvent
SecurityEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and LogonType == 10
| extend
FirstHop = bin(TimeGenerated, 1m),
FirstComputer = toupper(Computer),
FirstRemoteIPAddress = IpAddress,
Account = tolower(Account)
Leg 2: WindowsEvent
WindowsEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and EventData has ("10")
| extend LogonType = tostring(EventData.LogonType)
| where LogonType == 10
| extend Account = strcat(tostring(EventData.TargetDomainName), "", tostring(EventData.TargetUserName))
| extend IpAddress = tostring(EventData.IpAddress)
| extend
FirstHop = bin(TimeGenerated, 1m),
FirstComputer = toupper(Computer),
FirstRemoteIPAddress = IpAddress,
Account = tolower(Account)
Applied to the combined result
| join kind=inner (
(union isfuzzy=true
(
SecurityEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and LogonType == 10
| extend
SecondHop = bin(TimeGenerated, 1m),
SecondComputer = toupper(Computer),
SecondRemoteIPAddress = IpAddress,
Account = tolower(Account)
),
(
WindowsEvent
| where TimeGenerated >= ago(endtime)
| where EventID == 4624 and EventData has ("10")
| extend LogonType = toint(EventData.LogonType)
| where LogonType == 10
| extend Account = strcat(tostring(EventData.TargetDomainName), "", tostring(EventData.TargetUserName))
| extend IpAddress = tostring(EventData.IpAddress)
| extend
SecondHop = bin(TimeGenerated, 1m),
SecondComputer = toupper(Computer),
SecondRemoteIPAddress = IpAddress,
Account = tolower(Account)
))
)
on Account
| distinct
Account,
FirstHop,
FirstComputer,
FirstRemoteIPAddress,
SecondHop,
SecondComputer,
SecondRemoteIPAddress,
AccountType,
Activity,
LogonTypeName,
ProcessName | join kind=inner (resolvedIPs) on $left.FirstComputer == $right.Computer | join kind=inner (resolvedIPs) on $left.SecondComputer == $right.Computer | distinct
Account,
FirstHop,
FirstComputer,
FirstComputerIP = ResolvedIP,
FirstRemoteIPAddress,
SecondHop,
SecondComputer,
SecondComputerIP = ResolvedIP1,
SecondRemoteIPAddress,
AccountType,
Activity,
LogonTypeName,
ProcessName | where FirstComputer != SecondComputer
and FirstRemoteIPAddress != SecondRemoteIPAddress
and SecondHop > FirstHop | where SecondHop <= FirstHop + 30m | where SecondRemoteIPAddress == FirstComputerIP | summarize FirstHopFirstSeen = min(FirstHop), FirstHopLastSeen = max(FirstHop)
by
Account,
FirstComputer,
FirstComputerIP,
FirstRemoteIPAddress,
SecondHop,
SecondComputer,
SecondComputerIP,
SecondRemoteIPAddress,
AccountType,
Activity,
LogonTypeName,
ProcessName | extend
AccountName = tostring(split(Account, @"")[1]),
AccountNTDomain = tostring(split(Account, @"")[0]) | extend
HostName1 = tostring(split(FirstComputer, ".")[0]),
DomainIndex = toint(indexof(FirstComputer, '.')) | extend HostNameDomain1 = iff(DomainIndex != -1, substring(FirstComputer, DomainIndex + 1), FirstComputer) | extend
HostName2 = tostring(split(SecondComputer, ".")[0]),
DomainIndex = toint(indexof(SecondComputer, '.')) | extend HostNameDomain2 = iff(DomainIndex != -1, substring(SecondComputer, DomainIndex + 1), SecondComputer) | project-away DomainIndex
Indicators
Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.
| Field | Kind | Values |
|---|---|---|
ClientIP | is_not_null | |
ConnectedNetworks | is_not_null | |
DeviceName | in |
|
EventData | match |
|
EventID | eq |
|
FirstComputer | ne |
|
FirstRemoteIPAddress | ne |
|
LogonType | eq |
|
NetworkAdapterStatus | eq |
|
SecondHop | gt |
|
SecondRemoteIPAddress | eq |
|
Output fields
Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.
| Field | Source |
|---|---|
Account | summarize |
AccountType | summarize |
Activity | summarize |
FirstComputer | summarize |
FirstComputerIP | summarize |
FirstHopFirstSeen | summarize |
FirstHopLastSeen | summarize |
FirstRemoteIPAddress | summarize |
LogonTypeName | summarize |
ProcessName | summarize |
SecondComputer | summarize |
SecondComputerIP | summarize |
SecondHop | summarize |
SecondRemoteIPAddress | summarize |
AccountNTDomain | extend |
AccountName | extend |
HostName1 | extend |
HostNameDomain1 | extend |
HostName2 | extend |
HostNameDomain2 | extend |