Fixing the "RDP Nesting" Analytic Rule

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.

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)
// =====================================================================
// 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
//               by source hostname (RemoteDeviceName), so no IP resolution is needed.
//               Established pivot paths are suppressed via a 14-day baseline.
// Type        : Detection
//
// Tables      : DeviceLogonEvents, IdentityInfo
// Connectors  : Microsoft Defender for Endpoint (DeviceLogonEvents),
//               Microsoft Sentinel UEBA (IdentityInfo)
// License     : Microsoft Defender for Endpoint P2 + Microsoft Sentinel;
//               Microsoft Entra ID P2 recommended (UEBA / IdentityInfo enrichment)
//
// Tuning      : - DetectionWindow - how recent the onward hop must be; one alert per onward hop
//               - HopWindow - max gap allowed between the inbound hop and the onward hop
//               - BaselineWindow - history depth for suppressing established pivot paths
//               - EstablishedHopThreshold - suppress B->C pivots this account has done >= N times in the baseline
//               - ExcludedHostRegex - sanctioned jump hosts / bastions / AVD pools (default \b\B matches nothing; replace, do NOT set "")
//               - ExcludedAccounts - service / monitoring accounts that pivot by design
//               - SensitiveRoles - directory roles that set the PrivilegedAccount indicator
//
// Known FPs   : - Admins hopping through a sanctioned jump host - exclude the host or raise EstablishedHopThreshold
//               - Established daily pivot paths - cleared by the baseline suppression
//               - Newly built or re-imaged pivot hosts - baseline clears within 14 days
//
// Author      : Bartosz Wysocki | https://www.itprofessor.cloud
// Version     : 1.0 | 2026-06-17
// =====================================================================
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 pivot paths
let IdentityLookback = 14d;
let EstablishedHopThreshold = 5;     // B->C pivots this account has done >= N times in the baseline are treated as routine
let ExcludedHostRegex = @"\b\B";     // never-matches default (excludes nothing); replace e.g. with "(?i)(AVD|RDS|JUMP|BASTION)"
let ExcludedAccounts = dynamic([]);  // e.g. ["svc-monitoring", "breakglass"] matched on SAM name
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",
"Domain Admins",
"Enterprise Admins"
]);
// Normalize successful RDP logons; short host names so FQDN and NetBIOS forms join cleanly
let RdpLogons = (windowStart:datetime, windowEnd:datetime) {
    DeviceLogonEvents
    | where Timestamp between (windowStart .. windowEnd)
    | where ActionType == "LogonSuccess"
    | where LogonType == "RemoteInteractive"
    | extend
        TargetHost = toupper(tostring(split(DeviceName, ".")[0])),        // host the logon landed on
        SourceHost = toupper(tostring(split(RemoteDeviceName, ".")[0])),  // host the connection came from
        Account = tolower(strcat(AccountDomain, "\\", AccountName)),
        AccountName = tolower(AccountName)
    | project Timestamp, DeviceId, DeviceName = toupper(DeviceName), TargetHost, SourceHost,
              RemoteIP, RemoteIPType, Account, AccountName, AccountDomain, AccountSid, IsLocalAdmin
    | 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")
};
// All RDP hops across the correlation window (onward hop window + the hop gap before it)
let Hops = materialize(RdpLogons(ago(DetectionWindow + HopWindow), now()));
// Onward hop B -> C: recent, with a known pivot host (B = SourceHost)
let OnwardHops = Hops
    | where Timestamp > ago(DetectionWindow)
    | where isnotempty(SourceHost)
    | project
        OnwardTime = Timestamp, TargetC = DeviceName, TargetCHost = TargetHost, PivotHost = SourceHost,
        OnwardRemoteIP = RemoteIP, OnwardRemoteIPType = RemoteIPType, OnwardIsLocalAdmin = IsLocalAdmin,
        TargetCDeviceId = DeviceId, Account, AccountName, AccountDomain, AccountSid, AccountType;
// Inbound hop ? -> B: any source (the origin may be external, which is itself interesting)
let InboundHops = Hops
    | project InboundTime = Timestamp, PivotTargetHost = TargetHost, OriginHost = SourceHost,
              OriginIP = RemoteIP, OriginIPType = RemoteIPType, Account;
// Established B -> C pivots for this account over the baseline (routine jump-host paths)
let EstablishedPivots = RdpLogons(ago(BaselineWindow), ago(DetectionWindow))
    | where isnotempty(SourceHost)
    | summarize PivotHopCount = count() by Account, PivotHost = SourceHost, TargetCHost = TargetHost;
// Identity context, keyed on the normalized SAM account name
let IdentityContext = IdentityInfo
    | where TimeGenerated > ago(IdentityLookback)
    | extend NormalizedAccountName = tolower(trim(" ", AccountName))
    | summarize arg_max(TimeGenerated, AccountDisplayName, AccountUPN, IsAccountEnabled, UserType, AssignedRoles, GroupMembership, RiskLevel, RiskState) by NormalizedAccountName;
OnwardHops
| join kind=inner InboundHops on Account
| where PivotTargetHost == PivotHost                 // the pivot (B) is the target of the inbound hop
| where TargetCHost != PivotHost                     // the onward target (C) is a different host
| where InboundTime < OnwardTime and OnwardTime <= InboundTime + HopWindow
| summarize
    OnwardFirstSeen = min(OnwardTime),
    OnwardLastSeen = max(OnwardTime),
    ChainCount = count(),
    arg_max(InboundTime, OriginHost, OriginIP, OriginIPType)
  by Account, AccountName, AccountDomain, AccountSid, AccountType,
     PivotHost, TargetC, TargetCHost, OnwardRemoteIP, OnwardRemoteIPType, OnwardIsLocalAdmin, TargetCDeviceId
| join kind=leftouter EstablishedPivots on Account, PivotHost, TargetCHost
| extend PivotHopCount = coalesce(PivotHopCount, 0)
| where PivotHopCount < EstablishedHopThreshold
| where not(PivotHost matches regex ExcludedHostRegex) and not(TargetCHost matches regex ExcludedHostRegex)
| where array_length(ExcludedAccounts) == 0 or AccountName !in~ (ExcludedAccounts)
| join kind=leftouter IdentityContext on $left.AccountName == $right.NormalizedAccountName
| extend Risk_1 = iff(PivotHopCount == 0, "NewNestedEdge", "")
| extend Risk_2 = iff(OriginIPType =~ "Public", "ExternalOrigin", "")
| extend Risk_3 = iff(OnwardIsLocalAdmin == true, "LocalAdminOnTarget", "")
| extend Risk_4 = iff(tostring(AssignedRoles) has_any (SensitiveRoles), "PrivilegedAccount", "")
| extend Risk_5 = iff(IsAccountEnabled == false or UserType =~ "Guest", "DisabledOrGuestAccount", "")
| extend Risk_6 = iff(AccountType == "Machine", "MachineAccountRDP", "")
| extend RiskIndicators = trim(@"\s\|\s*$", strcat(
    iff(isnotempty(Risk_1), strcat(Risk_1, " | "), ""),
    iff(isnotempty(Risk_2), strcat(Risk_2, " | "), ""),
    iff(isnotempty(Risk_3), strcat(Risk_3, " | "), ""),
    iff(isnotempty(Risk_4), strcat(Risk_4, " | "), ""),
    iff(isnotempty(Risk_5), strcat(Risk_5, " | "), ""),
    iff(isnotempty(Risk_6), strcat(Risk_6, " | "), "")
))
| project
    OnwardFirstSeen, OnwardLastSeen, RiskIndicators,
    Account, AccountName, AccountDomain, AccountType, AccountSid, AccountUPN, AccountDisplayName,
    OriginHost, OriginIP, OriginIPType, InboundTime,
    PivotHost,
    TargetC, TargetCHost, TargetCDeviceId, OnwardRemoteIP, OnwardRemoteIPType, OnwardIsLocalAdmin,
    ChainCount, PivotHopCount,
    IsAccountEnabled, UserType, AssignedRoles, GroupMembership, RiskLevel, RiskState
| sort by OnwardLastSeen desc

You can also download this as an analytic rule and import it directly to Sentinel.

Follow my repo - GitHub

What You Should Do Next

  1. 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.
  2. 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.
  3. 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.
  4. 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

Consent Preferences