Detection rules › Kusto

RDP Nesting

Severity
medium
Time window
8d
Group by
Account, AccountType, Activity, Computer, FirstComputer, FirstComputerIP, FirstRemoteIPAddress, LogonTypeName, ProcessName, SecondComputer, SecondComputerIP, SecondHop, SecondRemoteIPAddress
Author
Microsoft Security Research
Source
github.com/Azure/Azure-Sentinel

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

TacticTechniques
Lateral MovementT1021 Remote Services

Event coverage

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

Stage 0: let

let endtime = 1d;
let rdpConnections = (union <inlined as stages below>;
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;

Stage 1: source

let rdpConnections

Stage 2: source

let listOfFirstComputer

Stage 3: source

let listOfSecondComputer

Stage 4: union

union isfuzzy=true

Stage 5: source

SecurityEvent

Stage 6: where time_window=86400s

| where TimeGenerated >= ago(endtime)

Stage 7: where

| where EventID == 4624 and LogonType == 10

Stage 8: extend

| extend
FirstHop = bin(TimeGenerated, 1m),
FirstComputer = toupper(Computer),
FirstRemoteIPAddress = IpAddress,
Account = tolower(Account)

Stage 9: source

WindowsEvent

Stage 10: where time_window=86400s

| where TimeGenerated >= ago(endtime)

Stage 11: where

| where EventID == 4624 and EventData has ("10")

Stage 12: extend

| extend LogonType = tostring(EventData.LogonType)

Stage 13: where

| where LogonType == 10

Stage 14: extend

| extend Account = strcat(tostring(EventData.TargetDomainName), "", tostring(EventData.TargetUserName))

Stage 15: extend

| extend IpAddress = tostring(EventData.IpAddress)

Stage 16: extend

| extend
FirstHop = bin(TimeGenerated, 1m),
FirstComputer = toupper(Computer),
FirstRemoteIPAddress = IpAddress,
Account = tolower(Account)

Stage 17: join

| 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

Stage 18: distinct

| distinct
Account,
FirstHop,
FirstComputer,
FirstRemoteIPAddress,
SecondHop,
SecondComputer,
SecondRemoteIPAddress,
AccountType,
Activity,
LogonTypeName,
ProcessName

Stage 19: join

| join kind=inner (resolvedIPs) on $left.FirstComputer == $right.Computer

Stage 20: join

| join kind=inner (resolvedIPs) on $left.SecondComputer == $right.Computer

Stage 21: distinct

| distinct
Account,
FirstHop,
FirstComputer,
FirstComputerIP = ResolvedIP,
FirstRemoteIPAddress,
SecondHop,
SecondComputer,
SecondComputerIP = ResolvedIP1,
SecondRemoteIPAddress,
AccountType,
Activity,
LogonTypeName,
ProcessName

Stage 22: where

| where FirstComputer != SecondComputer
and FirstRemoteIPAddress != SecondRemoteIPAddress
and SecondHop > FirstHop

Stage 23: where where SecondHop - FirstHop <= 30m

| where SecondHop <= FirstHop + 30m

Stage 24: where

| where SecondRemoteIPAddress == FirstComputerIP

Stage 25: summarize

| summarize FirstHopFirstSeen = min(FirstHop), FirstHopLastSeen = max(FirstHop)
by
Account,
FirstComputer,
FirstComputerIP,
FirstRemoteIPAddress,
SecondHop,
SecondComputer,
SecondComputerIP,
SecondRemoteIPAddress,
AccountType,
Activity,
LogonTypeName,
ProcessName

Stage 26: extend

| extend
AccountName = tostring(split(Account, @"")[1]),
AccountNTDomain = tostring(split(Account, @"")[0])

Stage 27: extend

| extend
HostName1 = tostring(split(FirstComputer, ".")[0]),
DomainIndex = toint(indexof(FirstComputer, '.'))

Stage 28: extend

| extend HostNameDomain1 = iff(DomainIndex != -1, substring(FirstComputer, DomainIndex + 1), FirstComputer)

Stage 29: extend

| extend
HostName2 = tostring(split(SecondComputer, ".")[0]),
DomainIndex = toint(indexof(SecondComputer, '.'))

Stage 30: extend

| extend HostNameDomain2 = iff(DomainIndex != -1, substring(SecondComputer, DomainIndex + 1), SecondComputer)

Stage 31: project-away

| 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.

FieldKindValues
ClientIPis_not_null
  • (no value, null check)
ConnectedNetworksis_not_null
  • (no value, null check)
DeviceNamein
  • listOfFirstComputer
  • listOfSecondComputer
EventDatamatch
  • 10 corpus 3 (kusto 3)
EventIDeq
  • 4624 transforms: cased corpus 26 (splunk 13, kusto 9, chronicle 4)
FirstComputerne
  • SecondComputer transforms: cased
FirstRemoteIPAddressne
  • SecondRemoteIPAddress transforms: cased
LogonTypeeq
  • 10 transforms: cased corpus 8 (kusto 4, sigma 3, splunk 1)
NetworkAdapterStatuseq
  • Up transforms: cased
SecondHopgt
  • FirstHop transforms: cased
SecondRemoteIPAddresseq
  • FirstComputerIP transforms: cased

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.

FieldSource
Accountsummarize
AccountTypesummarize
Activitysummarize
FirstComputersummarize
FirstComputerIPsummarize
FirstHopFirstSeensummarize
FirstHopLastSeensummarize
FirstRemoteIPAddresssummarize
LogonTypeNamesummarize
ProcessNamesummarize
SecondComputersummarize
SecondComputerIPsummarize
SecondHopsummarize
SecondRemoteIPAddresssummarize
AccountNTDomainextend
AccountNameextend
HostName1extend
HostNameDomain1extend
HostName2extend
HostNameDomain2extend