Find lateral movement paths using KQL Graph semantics
Graph databases offer great insights into existing data, that relational databases cannot or can only solve with more resources. Tools that leverage this ability to find lateral movement paths (edges) between user, computers and other entities (nodes) like Bloodhound offer an amazing data source for red teams and blue teams alike. But still the use in the defender world is yet limited. This might be because blue teams don’t like to use red teamers toolkit (really?) or companies often don’t see the immediate value of such additional data sources. But what we as defenders like to use is SIEM systems, EDR agents and other sensors that give us great, near real time insights into the companies IT landscape.
Microsoft made a lot of lateral movement paths easy consumable with Microsoft Security Exposure Management and Defender for Cloud attack paths without the need of “additional” software for all customers that already use Microsoft Defender XDR.
Other companies like the team at FalconForce also recognized this divide and startet cool projects like FalconHound. This tool allows you to query different data sources and push the data to another database. One use case is to send current sign in information from MDE as edges to Bloodhound or query attack paths from Bloodhound and ingest them as wachlist into Sentinel. But again this requires additional tooling that has to be setup and maintained in some sort or form, which might reduce the widespread usage.
But many people reading this blog post already have MDE and might even have Sentinel deployed and have all they need to query graph data. That is because you can generate and query graph data using KQL directly. This feature was introduced in public preview in July 2023 and released to General Availability in May 2024.
Quick introduction Graph theory
If you already know this, skip to chapter “Practical example”.
In short graphs are a mathematical structures that describe the relationship between multiple entities. Such entities are called nodes.
For example a user, computer or group is a node.
Each node can have multiple properties. The computer might have the property Tier0
or the user a property DistinguishedName
.
If a user is member of a group, than this relations between those nodes is called an edge. This edge has the type MemberOf
. Another edge could be the relationship between a user and a computer and describe the permission the user has on this device. The edge type is referred to as AdminTo
.
And of course this relationship can go both ways. The computer might have had an active session of the administrative user in the past because she signed in using RDP. This would be an HadSession
or HasSession
edge type depend on if the session is still considered active.
But why you want to do this? The goal is to find a path from nodeA to nodeN.
Let’s look at this example. You have UserA which has administrative permissions to computer#1. This computer was used by UserB a while back and the credentials of UserB might still be in the LSASS process of this computer.
Because UserA has the permissions to access Computer#1 she might be able to compromise UserB.
This simple lateral movement path is still easy to grasp even without a computer. But now think thousands of computers, users and groups. More nodes with more properties and even more edges between those nodes. This can get out of hand really quickly.
Let’s leave the theory behind and build nodes, edges and paths ourselves.
Practical example
With the data in your XDR and KQL Graph semantics available in Kusto you can now, without additional software, create queries that help you to identify lateral movement paths easy and with always up to date information. In the following example I will only rely on Microsoft MDE and MDI data sources to identify an lateral movement path to Domain Admin. It does not yet use the additional data that if available in Microsoft Security Exposure Management.
Step 1: Build nodes
First we need to build a list of nodes we want to evaluate. For this I use the last 30 days of Advanced Hunting data from IdentityInfo
and DeviceInfo
. This will give me a list of all users and computers that either MDE or MDI have seen in the last 30 days. As object type I either set Identity
or Device
. For all Nodes of type Identity
we also gather the additional properties OnPremSid
, Tags
and DistinguishedName
.
// List of all identities from the IdentityInfo table generated by MDI and add the columns Tags and DistinguishedName
let TempIdentiyInfo = IdentityInfo
| where Timestamp > ago(30d)
| where isnotempty(OnPremSid)
| summarize arg_max(Timestamp, Tags, DistinguishedName) by AccountName, OnPremSid
| project-rename AccountSid=OnPremSid
| extend Source = hash_many(tolower(AccountName), toupper(AccountSid))
| extend NodeDisplayName = AccountName
| extend ObjectType = "Identity";
// List of all devices known to MDE
let TempDeviceInfo = DeviceInfo
| where Timestamp > ago(30d)
| summarize max(Timestamp) by DeviceName, DeviceId
| extend Source = hash_many(tolower(DeviceName), tolower(DeviceId))
| project Source, DeviceId, DeviceName
| extend NodeDisplayName = DeviceName
| extend ObjectType = "Device";
// Join both together as nodes
let Nodes = union TempDeviceInfo, TempIdentiyInfo;
hash_many
operator to generate a unique id from either the lowercase account name and account SID or, in case of a device, the lowercase device name and device id. This helps me to avoid collisions through duplicated account names (e.g. Administrator).This query generates about 800 nodes in my demo environment alone. To keep it tidy, let’s concentrate on the following nodes.
Step 2: Build edges
The next step is to build edges between those node that are relevant to us. In this case I add the following edge types
- HasSession An account had an active session within the last hour to a certain computer. Device — [HasSession] → from Identity
- HadSession Same as HasSession but containing all data from the last 30 days. Device — [HadSession] → from Identity
- AdminTo A certainIdentity has administrative permissions on a certain device. Identity — [AdminTo] → Device
I borrowed the idea for HadSession
and the KQL query for the edge from Olaf Hartong.
In my demo environment this results in a very small list of results and for this exercise I filter that output to the identity ADM_TIERVIOLATOR
.
As you can see this user had a previous sessions (HadSession) on two devices ( DESKTOP-J0QV3M9 and AADC01) and this identity has administrative permission on this machine as well. Based on the naming of the computers and the edges available you might already guess that this users shouldn’t be all of over the place.
Let’s take a look how those edges fit in the bigger picture.
Are you able to spot the lateral movement path to Domain Admin? Okay this was maybe a bit to easy given the layout I choose for the graph representation and the limitation on just 5 of the 800 nodes. If not, don’t worry, we have KQL for that.
let excludeSid = '^S-1-5-(90|96)-0-';
let HistoricSession = DeviceInfo
| where Timestamp > ago(30d)
| mv-expand todynamic(LoggedOnUsers)
| extend AccountName = tolower(tostring(LoggedOnUsers.UserName))
| extend AccountSid = toupper((LoggedOnUsers.Sid))
| where isnotempty(AccountSid)
| extend Source = hash_many(tolower(DeviceName), tolower(DeviceId))
| extend Destination = hash_many(AccountName, AccountSid)
| summarize by Source, Destination, DeviceName, AccountName, AccountSid
| extend EdgeDisplayName = DeviceName
| extend EdgeType = "HadSession";
let AllSessions = DeviceLogonEvents
| where Timestamp > ago(30d)
| where ActionType == "LogonSuccess" and Protocol != "Negotiate"
| where LogonType !in ("Network", "Service")
| where isnotempty(AccountSid)
| where not(AccountSid matches regex excludeSid)
| summarize Timestamp=max(Timestamp), FirstSessionTimestamp=min(Timestamp) by DeviceName, DeviceId, AccountName, AccountSid, LogonType
| extend Source = hash_many(tolower(DeviceName), tolower(DeviceId))
| extend Destination = hash_many(tolower(AccountName), toupper(AccountSid))
| extend EdgeDisplayName = DeviceName
| extend EdgeType = "HadSession";
let HasInteractiveSession = AllSessions
| where Timestamp > ago(1h)
| extend EdgeType = "HasSession";
let AdminTo = DeviceLogonEvents
| where Timestamp > ago(30d)
| where ActionType == "LogonSuccess" and IsLocalAdmin == 1
| where isnotempty(AccountSid)
| summarize by DeviceName, DeviceId, AccountName, AccountSid
| extend Source = hash_many(tolower(AccountName), toupper(AccountSid))
| extend Destination = hash_many(tolower(DeviceName), tolower(DeviceId))
| extend EdgeDisplayName = AccountName
| extend EdgeType = "AdminTo";
let GraphInformation = union HistoricSession, AllSessions, HasInteractiveSession, AdminTo;
Step 3: Combine the data
To combine the information from the generated Nodes
and GraphInformation
tables we will use the new make-graph
operator. We provide the GraphInformation
aka the edges as input and then map Source and Destination information on the Nodes
table.
GraphInformation
| make-graph Source --> Destination with Nodes on Source
Since this will generate a graph structure in memory the output is not directly usable. For that you either have to use graph-to-table
or graph-match
.
For this use case graph-match
is the operator to use. It allows you to define nodes and the edges between the nodes and then gives you the ability to filter.
It’s important to understand that the names used for the nodes as well as for the edges are completely up to you. They can be used in the filter part of the query as well.
GraphInformation
| make-graph Source --> Destination with Nodes on Source
| graph-match
(Account)-[HasPathTo*3 .. 9]->(Administrator)
where HasPathTo.EdgeType in ("HasSession", "HadSession", "AdminTo") and Administrator.AccountName =~ "Administrator" and Account.ObjectType == "Identity" and Account.Source != Administrator.Source and HasPathTo.Source != HasPathTo.Destination
project User = Account.AccountName, Path = HasPathTo.EdgeDisplayName, PathEdges=HasPathTo.EdgeType, DomaindAdmin = Administrator.AccountName
| extend PathLength = array_length(Path)
While it looks daunting, the actual graph query is quite simple. A node named Account should have edges (HasPathTo) to a node named Administrator. The *3..9
in the the edge definition enforces that at least three edge node combinations are required, but also set a upper limit of nine. This allows me to get more or less “far” away accounts.
The filter is used to enforce the following conditions
- The edge “HasPathTo” only applies if the property EdgeType has either the value “HasSession”, “HadSession” or “AdminTo”.
- The Account node must be from object type “Identity”
- The Administrator node must be named “ADMINISTRATOR”
- The Account node must not be the same as the Administrator node
To visualize the results the starting account name is exposed in column User
, all node names that are part of the paths, are exposed as Path
and all matching edges in the column PathEdges
.
I also add a new column PathLength to easier sort from the shortest to longest paths.
graph-to-table
The graph-to-table
operator will give you the ability to inspect either all nodes or
GraphInformation
| make-graph Source --> Destination with Nodes on Source
| graph-to-table nodes with_node_id=NodeId
all edges that result of your input.
GraphInformation
| make-graph Source --> Destination with Nodes on Source
| graph-to-table edges with_source_id=SourceId with_target_id=TargetId
In this use case it’s not super helpful, but can be used to verify the generated dataset.
The result
When this query is run you will get a list of users (User) that have potential lateral movement paths (Path) to another user (DomainAdmin).
Let’s break down the shortest path from a regular user node (Takeshi) to Domain Admin as an overlay to the previous used graphic.
Refinement
You could add additional restrictions e.g. on naming schema to only output accounts you are not already aware of or expect to have a path.
and Account.Source !startswith "ADM_"
Of course using naming schema is not always great, but why not just use the organizational unit and exclude all Tier 0 objects.
and Account.DistinguishedName !contains "OU=T0,OU=_ADM"
If you don’t want to explicitly name the target account, you could also use the sensitive tag property to find even more targets.
and Administrator.Tags has "Sensitive"
Beautify
If you don’t like the current look of the list, because you want a nice attack graph, I’m sorry. At the moment there is no ready to go visualization within Sentinel or Advanced Hunting to display this in a nice way.
But of course with some KQL magic we can do a bit better than what you had seen before 🙂
GraphInformation
| make-graph Source --> Destination with Nodes on Source
| graph-match
(Account)-[HasPathTo*3 .. 9]->(Administrator)
where HasPathTo.EdgeType in ("HasSession", "HadSession", "AdminTo") and Administrator.AccountName =~ "Administrator" and Account.ObjectType == "Identity" and Account.Source != Administrator.Source and HasPathTo.Source != HasPathTo.Destination
project User = Account.AccountName, Path = HasPathTo.EdgeDisplayName, PathEdges=HasPathTo.EdgeType, DomaindAdmin = Administrator.AccountName
| extend PathLength = array_length(Path)
| summarize by User, tostring(Path), tostring(PathEdges), DomaindAdmin, PathLength
| extend Hash = hash_many(Path, PathEdges)
| extend Paths = zip(todynamic(Path), todynamic(PathEdges))
| mv-expand Paths to typeof(dynamic)
| mv-expand Paths to typeof(string)
| summarize LateralMovementPath = make_list(Paths) by User, DomaindAdmin, PathLength, Hash
| extend LateralMovementPath = strcat(strcat_array(LateralMovementPath, " --> "), ' --> ', DomaindAdmin)
| project-away Hash, DomaindAdmin
KQL query
This is the final KQL including the “graphical” representation of the attack paths. Run it in your environment and see how it turns out? Are the unexpected paths? How many are there?
// List of all identities from the IdentityInfo table generated by MDI and add the columns Tags and DistinguishedName
let TempIdentiyInfo = IdentityInfo
| where Timestamp > ago(30d)
| where isnotempty(OnPremSid)
| summarize arg_max(Timestamp, Tags, DistinguishedName) by AccountName, OnPremSid
| project-rename AccountSid=OnPremSid
| extend Source = hash_many(tolower(AccountName), toupper(AccountSid))
| extend NodeDisplayName = AccountName
| extend ObjectType = "Identity";
// List of all devices known to MDE
let TempDeviceInfo = DeviceInfo
| where Timestamp > ago(30d)
| summarize max(Timestamp) by DeviceName, DeviceId
| extend Source = hash_many(tolower(DeviceName), tolower(DeviceId))
| project Source, DeviceId, DeviceName
| extend NodeDisplayName = DeviceName
| extend ObjectType = "Device";
// Join both together as nodes
let Nodes = union TempDeviceInfo, TempIdentiyInfo;
let excludeSid = '^S-1-5-(90|96)-0-';
let HistoricSession = DeviceInfo
| where Timestamp > ago(30d)
| mv-expand todynamic(LoggedOnUsers)
| extend AccountName = tolower(tostring(LoggedOnUsers.UserName))
| extend AccountSid = toupper((LoggedOnUsers.Sid))
| where isnotempty(AccountSid)
| extend Source = hash_many(tolower(DeviceName), tolower(DeviceId))
| extend Destination = hash_many(AccountName, AccountSid)
| summarize by Source, Destination, DeviceName, AccountName, AccountSid
| extend EdgeDisplayName = DeviceName
| extend EdgeType = "HadSession";
let AllSessions = DeviceLogonEvents
| where Timestamp > ago(30d)
| where ActionType == "LogonSuccess" and Protocol != "Negotiate"
| where LogonType !in ("Network", "Service")
| where isnotempty(AccountSid)
| where not(AccountSid matches regex excludeSid)
| summarize Timestamp=max(Timestamp), FirstSessionTimestamp=min(Timestamp) by DeviceName, DeviceId, AccountName, AccountSid, LogonType
| extend Source = hash_many(tolower(DeviceName), tolower(DeviceId))
| extend Destination = hash_many(tolower(AccountName), toupper(AccountSid))
| extend EdgeDisplayName = DeviceName
| extend EdgeType = "HadSession";
let HasInteractiveSession = AllSessions
| where Timestamp > ago(1h)
| extend EdgeType = "HasSession";
let AdminTo = DeviceLogonEvents
| where Timestamp > ago(30d)
| where ActionType == "LogonSuccess" and IsLocalAdmin == 1
| where isnotempty(AccountSid)
| summarize by DeviceName, DeviceId, AccountName, AccountSid
| extend Source = hash_many(tolower(AccountName), toupper(AccountSid))
| extend Destination = hash_many(tolower(DeviceName), tolower(DeviceId))
| extend EdgeDisplayName = AccountName
| extend EdgeType = "AdminTo";
let GraphInformation = union HistoricSession, AllSessions, HasInteractiveSession, AdminTo;
GraphInformation
| make-graph Source --> Destination with Nodes on Source
| graph-match
(Account)-[HasPathTo*3 .. 9]->(Administrator)
where HasPathTo.EdgeType in ("HasSession", "HadSession", "AdminTo") and Administrator.AccountName =~ "Administrator" and Account.ObjectType == "Identity" and Account.Source != Administrator.Source and HasPathTo.Source != HasPathTo.Destination
project User = Account.AccountName, Path = HasPathTo.EdgeDisplayName, PathEdges=HasPathTo.EdgeType, DomaindAdmin = Administrator.AccountName
| extend PathLength = array_length(Path)
| summarize by User, tostring(Path), tostring(PathEdges), DomaindAdmin, PathLength
| extend Hash = hash_many(Path, PathEdges)
| extend Paths = zip(todynamic(Path), todynamic(PathEdges))
| mv-expand Paths to typeof(dynamic)
| mv-expand Paths to typeof(string)
| summarize LateralMovementPath = make_list(Paths) by User, DomaindAdmin, PathLength, Hash
| extend LateralMovementPath = strcat(strcat_array(LateralMovementPath, " --> "), ' --> ', DomaindAdmin)
| project-away Hash, DomaindAdmin
Current limitations
Error message like the following can happen easily and are not always helpful. In many cases the syntax is wrong or the result is just empty but in other cases it’s just the backend having trouble. I hope this get’s more reliable over time.
Conclusion…so far
As you can see it’s quite easy to query this information using KQL graph and existing information. Since Microsoft now also provides a trove of great nodes and edges through the tables ExposureGraphNodes
and ExposureGraphEdges
which are populated by Microsoft Security Exposure Management it would be almost irresponsible to not tap into this data source. But this blog post if aleady long enough and that you made it here is great. I will write a follow up in the coming month exploring the XSPM (Extended Security Posture Management) tables in more depth.
If you are looking for more right now, Sami Lamppu has a great blog post on this topic so be sure to check it out.
But already like this the new graph capabilities in KQL are an amazing tool in a defenders toolbelt. To know which users you have to protect the most, which edges you have to break to make lateral movement harder and what blast radius a single user might have is invaluable. Having this information right at your fingertips, without the need of additional software is great.
But be aware, I just demonstrated three edge types and relied only on live data. Tools like Bloodhound gather more information and calculate even more edges. So it’s not time to move completely over, but explore those as well.
Attribution and References
- Announcing General Availability of Graph Semantics in Kusto
- Kusto Query Language (KQL) graph semantics overview
- Introducing Microsoft Security Exposure Management
- Microsoft Security Exposure Management (XSPM) Deep Dive – Part 2
- SpecterOps/BloodHound
- FalconForceTeam/FalconHound
- A list of graph related blog posts by Andy Robbins (@wald0)