The KQL join Revealed The Attacker
The email looked suspicious.
The URL click appeared in another table.
The endpoint connection looked ordinary.
The process had a familiar Windows name.
Every signal appeared to describe a different event.
Then one KQL operator connected the entire attack.

Cross-table investigation
The clue was not hidden in one table. It was hidden in the relationship between them.
Four tables. Four partial stories.
The first join connected the email to the click
NetworkMessageId exists in both email and URL-click telemetry, making it the strongest correlation key for this stage of the investigation.- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
let Clicks =
UrlClickEvents
| where Timestamp > ago(7d)
| project
ClickTime = Timestamp,
AccountUpn,
NetworkMessageId,
Url,
ActionType;
EmailEvents
| where Timestamp > ago(7d)
| project
EmailTime = Timestamp,
NetworkMessageId,
SenderFromAddress,
RecipientEmailAddress,
Subject,
DeliveryAction
| join kind=inner Clicks on NetworkMessageId
| project EmailTime, ClickTime, RecipientEmailAddress,
SenderFromAddress, Subject, Url, ActionType,
DeliveryAction, AccountUpn
| sort by ClickTime asc
What the first result proved
The second join followed the user onto the endpoint
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
let Clicks =
UrlClickEvents
| where Timestamp > ago(7d)
| extend ClickHost = tostring(parse_url(Url).Host)
| project ClickTime = Timestamp, AccountUpn,
NetworkMessageId, Url, ClickHost;
DeviceNetworkEvents
| where Timestamp > ago(7d)
| extend AccountUpn = tostring(InitiatingProcessAccountUpn)
| extend RemoteHost = tostring(parse_url(RemoteUrl).Host)
| join kind=inner Clicks on AccountUpn
| where Timestamp between (ClickTime .. ClickTime + 30m)
| where RemoteHost == ClickHost
| project
ClickTime,
NetworkTime = Timestamp,
AccountUpn,
DeviceName,
DeviceId,
RemoteUrl,
RemoteIP,
InitiatingProcessFileName,
InitiatingProcessId,
InitiatingProcessCommandLine
| sort by NetworkTime asc
The final join identified the process
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
let NetworkActivity =
DeviceNetworkEvents
| where Timestamp > ago(7d)
| where RemoteUrl has "suspicious-example.com"
| project
NetworkTime = Timestamp,
DeviceId,
DeviceName,
RemoteUrl,
RemoteIP,
InitiatingProcessId;
NetworkActivity
| join kind=inner (
DeviceProcessEvents
| where Timestamp > ago(7d)
| project
ProcessTime = Timestamp,
DeviceId,
ProcessId,
FileName,
ProcessCommandLine,
InitiatingProcessFileName,
InitiatingProcessCommandLine,
AccountUpn
) on DeviceId, $left.InitiatingProcessId == $right.ProcessId
| where ProcessTime between (NetworkTime - 5m .. NetworkTime + 5m)
| project NetworkTime, ProcessTime, DeviceName, AccountUpn,
FileName, ProcessCommandLine, InitiatingProcessFileName,
InitiatingProcessCommandLine, RemoteUrl, RemoteIP
The process tree changed the verdict
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
08:12 Suspicious email delivered 08:17 User clicked embedded URL 08:18 Browser connected to suspicious host 08:19 Browser spawned powershell.exe 08:19 PowerShell downloaded payload.ps1 08:20 rundll32.exe executed downloaded content 08:22 Outbound connection opened to new IP 08:24 Scheduled task created for persistence
Choosing the right join kind
innerReturns rows with matching keys on both sides. Use it when the investigation requires confirmed correlation.leftouterKeeps every row from the left side, even when no matching evidence exists on the right. Useful for finding missing telemetry.inneruniqueDeduplicates the left-side key before matching. It can reduce duplicate correlations when only one left-side event is needed.Why joins go wrong
tostring(), toint() or another appropriate function before joining.project before the join. Smaller datasets are easier to understand and generally perform better.
