Fixing the "RDP Nesting" Analytic Rule
Alright class.
Sixth lesson in this series. In the last one, fixing "Rare RDP Connections", I said nesting was a separate problem and deserved its own post. Here it is.
RDP nesting is the chain. An operator lands on one host, opens RDP from there to a second, then onward to a third, repeating until they reach what they came for. Each hop launders the origin: from the final target's point of view the connection came from the previous internal host, a trusted machine, not from the attacker sitting three boxes back. It is one of the cleaner signals of hands-on-keyboard lateral movement, and a single RDP logon in isolation cannot show it. You have to stitch the hops together.
Same as last time, if you ingest Security Events but not Defender DeviceLogonEvents, this one is not for you.
How the Original Stitches Hops, and Where It Strains
The Microsoft rule template has the right idea. It takes every RemoteInteractive logon in the window, self-joins them on the account, and looks for a pair where the second logon's source is the first logon's host, inside a time window. Land on B, then RDP from B to C, same account, within the hour. That is nesting.
The strain is in how it links the two. The Security Event log records the source as an IP, so to say "the second hop came from the first host" the rule has to resolve every hostname to an IP, which it does with a DeviceNetworkInfo lookup and an arg_max on the latest known address. That resolution is the fragile part. DHCP churn means the latest address is not necessarily the one the host held at the moment of the logon. Multiple NICs give a host several addresses. NAT between segments rewrites the source entirely. Every one of those breaks the IP-to-host match, and when it breaks the chain silently fails to assemble.
The Cleaner Link: Source Hostname
DeviceLogonEvents removes the fragile step outright. Each logon already carries RemoteDeviceName, the source host that initiated the connection, by name. So the link needs no IP resolution at all. The logon on C already says it came from B. The logon on B says it was itself an RDP target. Same account, time ordered, done.
Suppressing the Jump-Host Noise
Plenty of nesting is legitimate. Admins hop through a jump host to reach servers every day, and that is a chain by any definition. The original suppressed the repeat offenders by excluding accounts that had made five or more connections to the same set of computers in the previous week.
The rewrite keeps that instinct but scopes it to the specific pivot path. It baselines each account's B-to-C RDP hops over fourteen days and suppresses any pivot the account has already travelled often enough to look routine. A brand new pivot path stands out and fires; the daily walk through the bastion to the same servers does not. Known sanctioned jump hosts and bastions can be carved out by name as well, and so can service or monitoring accounts that pivot by design.
The Blind Spot You Should Know About
This only covers devices onboarded to Defender for Endpoint, so not onboarded host in the middle of a chain is a blind link, and the answer is to onboard it. It is success-only, keyed on LogonSuccess. And it links hops by the same account, so an attacker who harvests a fresh credential on the pivot and continues under a different account breaks the correlation. That cross-account case is a harder problem for another day; same-account chaining catches the common one.
MITRE Mappings for the Updated Rule
Tactic: Lateral Movement, with Initial Access for the external-origin case.
T1021.001 Remote Desktop Protocol. The precise mapping, observed across multiple links rather than one.
T1078.002 and T1078.003 Valid Accounts, Domain and Local. The chain is driven by a valid credential, which is what the identity context is built around.
T1133 External Remote Services. The ExternalOrigin indicator covers a chain that begins with a valid account reaching in over RDP from a public address.
Rule Settings
Run every 60 minutes with a 2 hour query period, which covers the 1 hour detection window plus the 60 minute gap allowed between hops. The original ran once a day over eight days, far too slow for a live chain. The suppression baseline still reaches back the full 14 days independently. DeviceLogonEvents can carry ingestion latency, so widen the windows if your lag runs long. Medium severity, raised by the indicators. .
Entity mapping:
- AccountName to Account (Name), AccountDomain to Account (NTDomain)
- TargetCHost to Host (HostName) for the final target, PivotHost to a second Host (HostName) for the intermediate
- OriginIP to IP (Address) for where the operator actually sits
Custom details to surface in the incident: RiskIndicators, PivotHost, TargetC, OriginHost, OriginIP, OriginIPType, OnwardIsLocalAdmin, ChainCount, PivotHopCount, AccountUPN, RiskLevel.
KQL
// =====================================================================
// RDP Nesting - Defender for Endpoint (DeviceLogonEvents) - v3.0
// =====================================================================
// Description : Detects RDP nesting (lateral movement chains) by linking an inbound
// RemoteInteractive logon to a host with an onward RemoteInteractive logon
// from that host, by the same account, within a time window.
// Hops are linked on the source address carried by the logon, resolved to a
// device. Defender does not populate RemoteDeviceName on RemoteInteractive
// rows, so the source name is never available on the hop itself.
// Established paths are suppressed on two keys: the exact path, and the
// account reaching the same target through any pivot. The second key is what
// survives session host rotation in an RDS or AVD collection.
// Type : Detection
// Platform : Microsoft Sentinel / Log Analytics (TimeGenerated)
//
// Tables : DeviceLogonEvents, DeviceNetworkInfo, IdentityInfo
// Connectors : Microsoft Defender XDR (DeviceLogonEvents, DeviceNetworkInfo),
// Microsoft Sentinel UEBA (IdentityInfo)
// License : Microsoft Defender for Endpoint P2 + Microsoft Sentinel;
// Microsoft Entra ID P2 recommended (UEBA / IdentityInfo enrichment)
// Rule timing : queryFrequency PT1H, queryPeriod P14D
//
// Coverage : Around 40 percent of RemoteInteractive rows carry a source address. A hop
// without one cannot be linked to a pivot, so it is used as an inbound hop
// only and never as an onward hop.
//
// Tuning : - DetectionWindow - how recent the onward hop must be. Do not widen this to
// size volume: BaselineWindow must stay larger or the baseline range inverts,
// returns nothing, and every threshold below silently stops working. The
// max_of guard prevents the inversion but a wide window still leaves no
// usable history, and the IP map is only sound across the detection window.
// - HopWindow - max gap allowed between the inbound hop and the onward hop
// - BaselineWindow - history depth for suppressing established paths
// - IpMapWindow - snapshot depth for resolving a source address to a device
// - EstablishedHopThreshold - suppress a path this account has walked >= N times
// - EstablishedTargetThreshold - suppress a target this account has reached by
// nesting >= N times through any pivot. Raise this first in an RDS estate.
// - ExcludedHostRegex - sanctioned jump hosts, bastions and session host pools.
// Match the pool prefix rather than each member, e.g. "(?i)^(AVD-|SAVD-|.*RDS)"
// (default \b\B matches nothing; replace, do NOT set "")
// - ExcludedAccounts - service accounts, matched case insensitively on SAM name
//
// Known FPs : - RDS or AVD collections. The target key clears these once the baseline has
// 14 days of history. Add the pool prefix to ExcludedHostRegex if still noisy.
// - RD Gateway or NAT between hops presents the middlebox as the pivot
// - A pivot host that changes address resets its own path baseline, alerts once
// - B2B guest contractors raise the guest indicator by design
//
// Author : Bartosz Wysocki | https://www.itprofessor.cloud
// Version : 3.0 | 2026-09-09
// =====================================================================
let DetectionWindow = 1h; // the onward hop must land within this window
let HopWindow = 60m; // max gap between the inbound hop and the onward hop
let BaselineWindow = 14d; // history for suppressing established paths
let IdentityLookback = 14d;
let IpMapWindow = 12h; // snapshot depth for resolving an address to a device
let EstablishedHopThreshold = 5; // exact path walked >= N times is routine
let EstablishedTargetThreshold = 5; // target reached by nesting >= N times is routine
let ExcludedHostRegex = @"\b\B"; // never-matches default; e.g. "(?i)^(AVD-|SAVD-|.*RDS)"
let ExcludedAccounts = dynamic([]); // e.g. ["svc-monitoring", "breakglass"] on SAM name
let RdpLogonTypes = dynamic(["RemoteInteractive", "CachedRemoteInteractive"]);
let NullAddresses = dynamic(["0.0.0.0", "::", "127.0.0.1"]);
let SensitiveRoles = dynamic([
"Global Administrator",
"Privileged Role Administrator",
"Privileged Authentication Administrator",
"Security Administrator",
"Exchange Administrator",
"SharePoint Administrator",
"User Administrator",
"Intune Administrator",
"Application Administrator",
"Hybrid Identity Administrator"
]);
let SensitiveGroups = dynamic([ // on-premises groups land in GroupMembership
"Domain Admins",
"Enterprise Admins",
"Schema Admins",
"Account Operators",
"Server Operators",
"Backup Operators"
]);
let BaselineEnd = ago(DetectionWindow);
let BaselineStart = ago(max_of(BaselineWindow, DetectionWindow + 1d));
let IpMapWindowEffective = max_of(IpMapWindow, DetectionWindow);
let Hops = materialize(
DeviceLogonEvents
| where TimeGenerated > ago(DetectionWindow + HopWindow)
| where ActionType == "LogonSuccess"
| where LogonType in (RdpLogonTypes)
| where RemoteIPType != "Loopback"
| extend
TargetHost = toupper(tostring(split(DeviceName, ".")[0])),
Account = tolower(strcat(AccountDomain, "\\", AccountName))
| extend AccountName = tolower(AccountName)
| extend AccountType = case(
AccountName endswith "$" or AccountSid in ("S-1-5-18", "S-1-5-19", "S-1-5-20"), "Machine",
isempty(AccountSid), "Unknown",
"User")
| project TimeGenerated, DeviceId, DeviceName = toupper(DeviceName), TargetHost,
RemoteIP, RemoteIPType, Account, AccountName, AccountDomain, AccountSid,
AccountType, IsLocalAdmin
);
let IpCandidates = toscalar(
Hops
| where isnotempty(RemoteIP) and RemoteIP !in (NullAddresses)
| summarize make_set(RemoteIP, 100000));
let IpFromNetworkInfo =
DeviceNetworkInfo
| where TimeGenerated > ago(IpMapWindowEffective)
| where isnotempty(IPAddresses)
| mv-apply Adapter = parse_json(tostring(IPAddresses)) on (
project AdapterIP = tostring(Adapter.IPAddress)
)
| where isnotempty(AdapterIP)
| where set_has_element(IpCandidates, AdapterIP)
| summarize arg_max(TimeGenerated, DeviceName), Claimants = dcount(DeviceId) by AdapterIP
| project ResolvedIP = AdapterIP,
ResolvedHost = toupper(tostring(split(DeviceName, ".")[0])),
ResolvedSource = "NetworkInfo",
Claimants;
let IpFromLogonWorkstation =
DeviceLogonEvents
| where TimeGenerated > ago(DetectionWindow + HopWindow)
| where ActionType == "LogonSuccess"
| where isnotempty(RemoteDeviceName)
| where set_has_element(IpCandidates, RemoteIP)
| summarize arg_max(TimeGenerated, RemoteDeviceName) by RemoteIP
| project ResolvedIP = RemoteIP,
ResolvedHost = toupper(tostring(split(RemoteDeviceName, ".")[0])),
ResolvedSource = "LogonWorkstation",
Claimants = tolong(1);
let IpToHost = materialize(
union isfuzzy=true IpFromNetworkInfo, IpFromLogonWorkstation
| where isnotempty(ResolvedHost)
| extend SourceRank = iff(ResolvedSource == "NetworkInfo", 1, 2)
| summarize arg_min(SourceRank, ResolvedHost, ResolvedSource, Claimants) by ResolvedIP
);
// Onward hop B -> C: recent, with a pivot identified from the source address
let OnwardHops =
Hops
| where TimeGenerated > ago(DetectionWindow)
| where isnotempty(RemoteIP) and RemoteIP !in (NullAddresses)
| lookup kind=leftouter (
IpToHost
| project ResolvedIP, PivotHost = ResolvedHost, PivotSource = ResolvedSource,
PivotIpClaimants = Claimants
) on $left.RemoteIP == $right.ResolvedIP
| where isnotempty(PivotHost) // no pivot identity, no chain to claim
| where PivotHost != TargetHost // ignore a host reached from itself
| project OnwardTime = TimeGenerated, TargetC = DeviceName, TargetCHost = TargetHost,
TargetCDeviceId = DeviceId, PivotHost, PivotIP = RemoteIP, PivotIPType = RemoteIPType,
PivotSource, PivotIpClaimants, OnwardIsLocalAdmin = IsLocalAdmin,
Account, AccountName, AccountDomain, AccountSid, AccountType;
// Inbound hop ? -> B: any source, including external, unmanaged, or not recorded at all
let InboundHops =
Hops
| project InboundTime = TimeGenerated, PivotTargetHost = TargetHost, PivotDeviceId = DeviceId,
OriginIP = RemoteIP, OriginIPType = RemoteIPType, Account;
// One baseline scan, two views of it. Keyed on the source address rather than the resolved
// name, so a stale IP map can never suppress a real chain.
let BaselinePaths = materialize(
DeviceLogonEvents
| where TimeGenerated between (BaselineStart .. BaselineEnd)
| where ActionType == "LogonSuccess"
| where LogonType in (RdpLogonTypes)
| where isnotempty(RemoteIP) and RemoteIP !in (NullAddresses)
| extend Account = tolower(strcat(AccountDomain, "\\", AccountName)),
TargetCHost = toupper(tostring(split(DeviceName, ".")[0]))
| summarize PivotHopCount = count() by Account, PivotIP = RemoteIP, TargetCHost
);
let EstablishedTargets = BaselinePaths | summarize TargetHopCount = sum(PivotHopCount) by Account, TargetCHost;
// Identity context, keyed on the normalised SAM account name. column_ifexists guards every
// optional column: IdentityInfo varies by UEBA version and a missing column fails the rule
// at bind time rather than at runtime.
let IdentityContext = materialize(
IdentityInfo
| where TimeGenerated > ago(IdentityLookback)
| extend NormalizedAccountName = tolower(trim(" ", tostring(column_ifexists("AccountName", ""))))
| where isnotempty(NormalizedAccountName)
| extend
IdDisplayName = tostring(column_ifexists("AccountDisplayName", "")),
IdUPN = tostring(column_ifexists("AccountUPN", "")),
IdEnabled = tobool(column_ifexists("IsAccountEnabled", true)),
IdUserType = tostring(column_ifexists("UserType", "")),
IdRoles = tostring(column_ifexists("AssignedRoles", "")),
IdGroups = tostring(column_ifexists("GroupMembership", "")),
IdRiskLevel = tostring(column_ifexists("RiskLevel", "")),
IdRiskState = tostring(column_ifexists("RiskState", ""))
| summarize arg_max(TimeGenerated, IdDisplayName, IdUPN, IdEnabled, IdUserType,
IdRoles, IdGroups, IdRiskLevel, IdRiskState)
by NormalizedAccountName
);
OnwardHops
| join kind=inner InboundHops on Account, $left.PivotHost == $right.PivotTargetHost
| where InboundTime < OnwardTime and OnwardTime <= InboundTime + HopWindow
| summarize
OnwardFirstSeen = min(OnwardTime),
OnwardLastSeen = max(OnwardTime),
ChainCount = count(),
arg_max(InboundTime, OriginIP, OriginIPType, PivotDeviceId)
by Account, AccountName, AccountDomain, AccountSid, AccountType,
PivotHost, PivotIP, PivotIPType, PivotSource, PivotIpClaimants,
TargetC, TargetCHost, TargetCDeviceId, OnwardIsLocalAdmin
// Both suppression keys have to clear
| lookup kind=leftouter BaselinePaths on Account, PivotIP, TargetCHost
| lookup kind=leftouter EstablishedTargets on Account, TargetCHost
| extend PivotHopCount = coalesce(PivotHopCount, tolong(0)),
TargetHopCount = coalesce(TargetHopCount, tolong(0))
| where PivotHopCount < EstablishedHopThreshold
| where TargetHopCount < EstablishedTargetThreshold
| where not(PivotHost matches regex ExcludedHostRegex) and not(TargetCHost matches regex ExcludedHostRegex)
| where array_length(ExcludedAccounts) == 0 or AccountName !in~ (ExcludedAccounts)
// Name the origin where possible. Enrichment, never a filter: an origin that cannot be named
// is the more interesting case, not the one to drop. A logon with no address recorded at all
// is normal for AVD and gateway reverse connect, so it is classified rather than scored.
| lookup kind=leftouter (
IpToHost
| project ResolvedIP, OriginResolvedHost = ResolvedHost, OriginResolvedSource = ResolvedSource
) on $left.OriginIP == $right.ResolvedIP
| extend OriginSource = case(
isempty(OriginIP) or OriginIP in (NullAddresses) or OriginIPType =~ "Unspecified", "NoSourceRecorded",
isnotempty(OriginResolvedHost), OriginResolvedSource,
"Unresolved")
| extend OriginHost = iff(OriginSource in ("NetworkInfo", "LogonWorkstation"), OriginResolvedHost, "")
| extend PivotConfidence = case(
PivotSource == "NetworkInfo" and PivotIpClaimants == 1, "High",
PivotSource == "NetworkInfo", "Medium",
PivotSource == "LogonWorkstation", "Medium",
"Low")
| extend ChainPath = strcat(
case(isnotempty(OriginHost), OriginHost,
OriginSource == "NoSourceRecorded", "(no source)",
OriginIP),
" > ", PivotHost, " > ", TargetCHost)
| lookup kind=leftouter IdentityContext on $left.AccountName == $right.NormalizedAccountName
| extend W_NewEdge = iff(PivotHopCount == 0, 25, 0)
| extend W_ExternalOrig = iff(OriginIPType =~ "Public", 25, 0)
| extend W_LocalAdmin = iff(OnwardIsLocalAdmin == true, 15, 0)
| extend W_Privileged = iff(IdRoles has_any (SensitiveRoles) or IdGroups has_any (SensitiveGroups), 20, 0)
| extend W_AccountState = iff(IdEnabled == false or IdUserType =~ "Guest", 15, 0)
| extend W_MachineAcct = iff(AccountType == "Machine", 20, 0)
| extend W_UnknownOrig = iff(OriginSource == "Unresolved", 10, 0)
| extend RiskScore = W_NewEdge + W_ExternalOrig + W_LocalAdmin + W_Privileged
+ W_AccountState + W_MachineAcct + W_UnknownOrig
| extend RiskIndicators = strcat_array(set_difference(pack_array(
iff(W_NewEdge > 0, "NewNestedEdge", ""),
iff(W_ExternalOrig > 0, "ExternalOrigin", ""),
iff(W_LocalAdmin > 0, "LocalAdminOnTarget", ""),
iff(W_Privileged > 0, "PrivilegedAccount", ""),
iff(W_AccountState > 0, "DisabledOrGuestAccount", ""),
iff(W_MachineAcct > 0, "MachineAccountRDP", ""),
iff(W_UnknownOrig > 0, "UnresolvedOrigin", "")
), dynamic([""])), " | ")
| project
OnwardFirstSeen, OnwardLastSeen, RiskScore, RiskIndicators, ChainPath,
Account, AccountName, AccountDomain, AccountType, AccountSid,
AccountUPN = IdUPN, AccountDisplayName = IdDisplayName,
OriginHost, OriginIP, OriginIPType, OriginSource, InboundTime,
PivotHost, PivotIP, PivotIPType, PivotSource, PivotConfidence, PivotDeviceId,
TargetC, TargetCHost, TargetCDeviceId, OnwardIsLocalAdmin,
ChainCount, PivotHopCount, TargetHopCount,
IsAccountEnabled = IdEnabled, UserType = IdUserType, AssignedRoles = IdRoles,
GroupMembership = IdGroups, RiskLevel = IdRiskLevel, RiskState = IdRiskState
| sort by RiskScore desc, OnwardLastSeen desc
Follow my repo - GitHub
What You Should Do Next
- Confirm onboarding coverage across the middle of your estate, not just the edges. A chain is only visible if every host on it is onboarded; an un-onboarded pivot is a blind link.
- Run the query manually over the last few days before deploying. Most of what comes back will be your real jump-host and admin pivot paths. Feed the obvious ones into ExcludedHostRegex and confirm EstablishedHopThreshold suppresses the routine traffic without hiding the new.
- Watch the ExternalOrigin indicator closely. A nested chain that began from a public address is rarely a normal admin workflow and is the first thing to triage.
- Pair it with the Rare RDP Connections rule. That one catches the first unusual hop; this one catches the chain. Together they cover both the entry and the movement.
Class dismissed