Every lateral movement technique ends in an authentication that connects a source to a destination as some account. Collected consistently, those events form a graph. Detection then asks graph questions: is this edge new, is this account new to this host, is this source suddenly reaching many nodes, and how far is the new node from tier 0?
Signals at a Glance
| Signal | Where | ATT&CK |
|---|---|---|
| Edge (source host, destination host, account) never seen in the baseline window | Event 4624 type 3, 10 | T1021 |
| Privileged account logging on to a host outside its normal set (first-time admin pair) | Event 4624 + 4672 | T1078.002 |
| Source with unusual out-degree: many distinct destinations in one hour | Event 4624, Kerberos 4769 | T1021 |
| New edge that moves into a higher tier (workstation to server to domain controller) | Edge table + asset tiers | T1021 |
| Chain of new edges within a short time, each starting from the previous destination | Edge table | T1021 |
Building the Edge Table
Start with one normalised table: time, src_host, dst_host, account, logon_type, protocol. Source it from 4624 types 3 and 10 (the source address and workstation name), supplement with Kerberos 4769 on domain controllers, 5140 and 5145 for SMB, Sysmon 3 for process-level connections and, where you have it, Zeek conn and kerberos logs. Resolve IP addresses to hostnames through DHCP and your CMDB, because addresses churn. Drop computer accounts and anonymous logons into a separate edge type rather than discarding them.
// Sentinel (KQL) - normalised edge table for network and remote-interactive logons
let edges = SecurityEvent
| where EventID == 4624 and LogonType in (3, 10)
| where TargetUserName !endswith "$" and TargetUserName !in~ ("ANONYMOUS LOGON", "-")
| extend Src = replace_string(tostring(IpAddress), "::ffff:", "")
| where isnotempty(Src) and Src !in ("-", "::1", "127.0.0.1")
| project TimeGenerated, Src, Dst=Computer, Account=tolower(TargetUserName), LogonType;
edges
| summarize count=count(), first=min(TimeGenerated), last=max(TimeGenerated)
by Src, Dst, Account, LogonType
// tune/validate: map Src to hostnames using a DHCP/CMDB join before baselining.
Baselining and Scoring Edges
With 14 to 30 days of edges as the baseline, the simplest high-value detection is the anti-join: a recent edge absent from the baseline. Rank new edges by account privilege and destination tier so the list stays short:
// Sentinel (KQL) - first-time (source, destination, account) pairs for privileged accounts let baseline = SecurityEvent | where TimeGenerated between (ago(30d) .. ago(1d)) | where EventID == 4624 and LogonType in (3, 10) | extend Src = replace_string(tostring(IpAddress), "::ffff:", "") | distinct Src, Dst=Computer, Account=tolower(TargetUserName); let privileged = SecurityEvent | where TimeGenerated > ago(30d) and EventID == 4672 | distinct Account=tolower(SubjectUserName); SecurityEvent | where TimeGenerated > ago(1d) and EventID == 4624 and LogonType in (3, 10) | extend Src = replace_string(tostring(IpAddress), "::ffff:", ""), Account = tolower(TargetUserName) | where Account in (privileged) | join kind=leftanti baseline on Src, $left.Computer == $right.Dst, Account | summarize newEdges=count(), dests=dcount(Computer) by Src, Account | order by newEdges desc // tune/validate: add a join to a tier list so edges into tier 0 rank first.
index=wineventlog EventCode=4624 (Logon_Type=3 OR Logon_Type=10) | bin _time span=1h | stats dc(ComputerName) as out_degree values(ComputerName) as dests by Source_Network_Address, Account_Name, _time | eventstats avg(out_degree) as mu stdev(out_degree) as sigma by Account_Name | eval z=if(sigma>0, (out_degree-mu)/sigma, 0) | where out_degree >= 5 AND z >= 3 | sort - out_degree # tune/validate: per-account statistics need history; exclude scanners and jump hosts from the baseline.
- Tag assets by tier (domain controllers and identity infrastructure as tier 0, servers as tier 1, workstations as tier 2). Any new edge that moves from a lower tier into a higher one gets priority.
- Model time as well as edges: a chain of new edges where each source is the previous destination within minutes is the shape of an intruder walking the network.
- Use graph tooling for investigation. BloodHound-style attack-path analysis shows defenders the same reachability an attacker sees, and a graph database makes shortest-path queries to tier 0 routine.
- Feed back what you learn: every accepted new edge extends the baseline, so review and document admin paths rather than silently absorbing them.
Use Microsoft and vendor UEBA documentation, MITRE CAR analytics for lateral movement, Elastic and Splunk ESCU first-time-access content and Zeek conn and kerberos logs as inputs. Atomic Red Team lets you create known-bad edges to test the pipeline. Baseline your own environment before alerting — edge statistics are only as good as the quality of your host and account mapping.
Build Detections That Fire
Mjolnir Security helps blue teams turn threat intelligence into high-fidelity detections across endpoint, network, identity and cloud telemetry.
- Detection Engineering We build and tune detections mapped to ATT&CK from your own telemetry.
- Threat Hunting as a Service Hypothesis-driven hunts for the behaviours on this page.
- 24/7 Incident Response Containment when a detection fires. Call +1 833 403 5875.
Stay ahead of emerging threats. Get notified when we publish new intelligence reports and advisories.
Subscribe to Alerts