Recap: Microsoft’s Security Exposure Management Graph
In the dynamic world of cybersecurity, staying ahead of threats is not merely about reacting to threats but proactively understanding and managing the security posture of every asset within an organization. The introduction of Microsoft’s ExposureGraphEdges & ExposureGraphNodes tables within Advanced Hunting signifies a substantial advancement in exposure management tools. These tables encapsulate the entire dataset of the Microsoft Security Exposure Management Graph. In this blog, we delve into key concepts and provide powerful queries that you can implement in your own environment.
Figure 1: Screenshot from Microsoft Security Exposure Management's Attack Surface Map
Before we proceed, let’s revisit our tables:
ExposureGraphNodes represent all nodes within the Attack Surface Map, encompassing organizational entities like devices, identities, user groups, and cloud assets such as virtual machines, storage, and containers. Each node details individual entities with comprehensive information about their characteristics, attributes, and security insights within the organizational framework.
ExposureGraphEdges detail all connections between these nodes, providing visibility into the relationships between entities and assets. This visibility is crucial for exploring entity relationships and attack paths, such as uncovering critical organizational assets potentially exposed to specific vulnerabilities.
In our first blog post, we explained the schemas and illustrated how these tables improve the investigation of security posture using several real-world scenarios. We also shared several generic queries that can be adapted to your usage by specifying the parameters. If you missed that, we highly recommend reviewing this blog post.
Blast Radius
The term “Blast Radius” is traditionally associated with the physical impact of an explosive event. According to Wikipedia, it is defined as “the distance from the source that will be affected when an explosion occurs.” This concept is commonly linked to bombs, mines, and other explosive devices.
In the realm of cybersecurity, however, the term takes on a metaphorical meaning. While we may not witness a literal explosion, the concept of a Blast Radius is equally significant. It refers to the potential extent of damage an attacker could inflict by exploiting a compromised asset. In our case, we calculate Blast Radius on top of all walkable paths in the map that can be potentially used by an attacker for lateral movement.
Figure 2: Blast radius concept
By leveraging this concept, we can achieve several things:
- Uncover all potential paths: Expose all paths that could be taken from a specific (potentially compromised) starting point
- Prioritize high-risk entities: Rank and filter entities based on their Blast Radius Scores, allowing for a more efficient response.
- Enrich other security products (such as alerts or recommendations)
Queries and results
Calculating Blast Radius is based on previously calculated paths with relevant definitions. For example, we want to expose all the storage accounts and SQL servers accessible by a specific user, or all high-value assets accessible by a service principal.
For that, we would like to reiterate the XGraph_PathExploration from the previous blogpost. We will find all the paths suitable for our scenario, for example, the following query will find all the paths between users and storages of different types that contain sensitive data. Note that in this example the sourcePropertiesList is empty, meaning there is no filter on source properties – so all users are relevant. We save the list of such paths as ‘relevantPaths’ for later referral.
Using the following definitions, we can find all paths between users or identities, to VMs or KeyVaults that are either critical or sensitive.
let sourceTypesList = pack_array('user', 'managedidentity', 'serviceprincipal');
let sourcePropertiesList = pack_array('');
let targetTypesList = pack_array('microsoft.keyvault/vaults', 'microsoft.compute/virtualmachines');
let targetPropertiesList = pack_array('criticalityLevel', 'containsSensitiveData');
Now we would like to aggregate such paths by starting point (user in this case) and see all the accessible targets. We also count the targets in a field called BlastRadiusScore, that can be used for ranking. For this, we define a function called XGraph_BlastRadius (note that it is tailored to output format of the XGraph_PathExploration function):
let XGraph_BlastRadius = (T:(SourceId:string, SourceName:string, SourceType:string, TargetId:string, TargetName:string, TargetType:string, PathLength:long, CountTargetProperties:long)) {
T
| summarize arg_min(PathLength, *) by SourceId, TargetId
| summarize BlastRadiusTargetIds = make_set(TargetId)
, BlastRadiusTargetTypes = make_set(TargetType)
, BlastRadiusScore = dcount(TargetId)
, BlastRadiusScoreWeighted = sum(CountTargetProperties)
, MinPathLength = min(PathLength)
, MaxPathLength = max(PathLength)
by SourceType, SourceId, SourceName
| sort by BlastRadiusScore desc
};
In order to use it, we run the XGraph_BlastRadius function on top of relevantPaths:
relevantPaths
| invoke XGraph_BlastRadius()
The output is a list of starting points, each listing the list of accessible target types and IDs, ranked by BlastRadiusScore.
We also provide an additional field – BlastRadiusScoreWeighted – summing the numbers of relevant properties in the targets. It can be useful as an alternative to simple BlastRadiusScore, for example, if the number of properties each target possesses matter (e.g., a target that is both critical and sensitive is even more important).
Asset Exposure
While Blast Radius focuses on all the routes originating from an entity, Asset Exposure provides the complementary perspective by revealing all the routes leading to an entity.
Figure 3: Asset Exposure concept
Asset Exposure gives us a look at how easy it is to access assets (especially valuable ones) from different relevant starting points in the graph. This helps us identify where we need stronger hardening or protection.
By leveraging the concept of Asset Exposure, we can achieve several things:
- Gain a comprehensive understanding of the routes leading to an asset
- Harden potential entry points and cut potential unneeded paths
- Discover unintended paths to high-value assets
Queries and results
Here, as well, we will find the relevant path discovered using the XGraph_PathExploration from the previous blogpost and save them as relevantPaths. We’ll follow up on the same definitions from a previous example.
We would like to aggregate such paths by target and see all the sources that it can be accessed from. We also calculate ExposureScore (count of sources) and ExposureScoreWeighted (sum of the numbers of sources’ relevant properties). For this, we define a function called XGraph_AssetExposure (based on the output format of the XGraph_PathExploration function):
let XGraph_AssetExposure = (T:(SourceId:string, SourceName:string, SourceType:string, TargetId:string, TargetName:string, TargetType:string, PathLength:long, CountSourceProperties:long)) {
T
| summarize arg_min(PathLength, *) by SourceId, TargetId
| summarize ExposureSourceIds = make_set(SourceId)
, ExposureSourceTypes = make_set(SourceType)
, ExposureScore = dcount(SourceId)
, ExposureScoreWeighted = sum(CountSourceProperties)
, MinPathLength = min(PathLength)
, MaxPathLength = max(PathLength)
by TargetType, TargetId, TargetName
| sort by ExposureScore desc
};
In order to use it, we run the XGraph_AssetExposure function on top of relevantPaths:
relevantPaths
| invoke XGraph_AssetExposure()
The output is a list of starting points, each listing the list of accessible target types and Ids, ranked by ExposureScore. We also provide the ExposureScoreWeighted for alternative ranking.
Groups in Graph
The ‘small world phenomenon’ is a well-known concept in Graph Theory. It is an empirical rule saying that most graphs representing real-world phenomena tend to be divided into relatively small and dense neighborhoods, with sparse connections between them. Since exposure graphs represent some aspects of real organizations (such as walkable paths), they tend to follow this rule. For example, we might find closely connected groups of entities and assets related to the same project or business logic.
For this reason, it makes sense to add the ability to define and use groups in exposure graphs. These groups can be defined by hierarchical attributes (such as subscription), tags, naming conventions or any other logic. A more advanced approach is graph clustering – which allows to find such groups proactively, based on the density of internal and internal connections.
For this, we will use a field called ‘GroupId’, which needs to be added to the original tables.
For example, we can straightforwardly use SubscriptionId as GroupId:
let nodesWithGroups = (
ExposureGraphNodes
| extend SubscriptionId = extract("subscriptions/([a-f0-9-]{36})", 1, tostring(EntityIds), typeof(string))
| extend GroupId = SubscriptionId
);
nodesWithGroups
Alternatively, we can create GroupId based on some business logic (e.g., based on known lists of subscriptions, names, or any other logic):
let groupData = datatable(SubscriptionId:string, GroupId:string, GroupType:string) [
'a1***’, 'Backup Platform', 'Backup',
'4f***’,'Test environment', 'Test',
'e9***’, 'Backend', 'Production',
'03***’, 'Billing', 'Production',
'ba***’, 'Web service', 'Production'
];
let nodesWithGroups = (
ExposureGraphNodes
| project NodeId, NodeLabel, NodeName, NodeProperties, EntityIds
| extend SubscriptionId = extract("subscriptions/([a-f0-9-]{36})", 1, tostring(EntityIds), typeof(string))
| lookup kind = leftouter (groupData) on SubscriptionId
);
nodesWithGroups
Finding paths between groups
Now, we want to look for paths using the GroupId as source and target.
We can use several fields for grouping. The simplest option is to aggregate start and end points by GroupId, This allows us to find valid paths in the same group, or between different groups (we can do this explicitly by filtering SourceGroupId != TargetGroupId). Note that the nodes and edges that are connecting the start and end points are not grouped. This is done to prevent false discovery of non-existent paths. For example, if an asset in group A is connected to some asset in group B, and another asset in group B connected to asset in group C, it would not necessarily be true to build a path A-B-C (since internal nodes in group B might be different).
An alternative option is to group start and end points by GroupId and NodeLabel to create paths between different types of resources in different groups. This allows exposing various scenarios – e.g., connection between virtual machines in group A and storage accounts in group B.
Query and results
In the function XGraph_PathExplorationWithGroups that appears below, we work with the common tables ExposureGraphNodes and ExposureGraphEdges. We also assume there is a table groupData (already existing or defined ad hoc using let statement), that can be linked to nodes table using SubscriptionId, and providing fields GroupId and GroupType – similarly to what appears in the example above. The field used for joining as well as other fields can be changed.
let XGraph_PathExplorationWithGroups = (sourceTypes:dynamic, sourceProperties:dynamic
, targetTypes:dynamic, targetProperties:dynamic
, maxPathLength:long = 6, resultCountLimit:long = 100000)
{
let edgeTypes = pack_array('has permissions to', 'contains', 'can authenticate as', 'can authenticate to', 'can remote interactive logon to'
, 'can interactive logon to', 'can logon over the network to', 'contains', 'has role on', 'member of');
let sourceNodePropertiesFormatted = strcat('(', strcat_array(sourceProperties, '|'), ')');
let targetNodePropertiesFormatted = strcat('(', strcat_array(targetProperties, '|'), ')');
let nodes = (
ExposureGraphNodes
| project NodeId, NodeName, NodeLabel, EntityIds
, SourcePropertiesExtracted = iff(sourceProperties != "[\"\"]", extract_all(sourceNodePropertiesFormatted, tostring(NodeProperties)), pack_array(''))
, TargetPropertiesExtracted = iff(targetProperties != "[\"\"]", extract_all(targetNodePropertiesFormatted, tostring(NodeProperties)), pack_array(''))
, criticalityLevel = toint(NodeProperties.rawData.criticalityLevel.criticalityLevel)
| mv-apply SourcePropertiesExtracted, TargetPropertiesExtracted on (
summarize SourcePropertiesExtracted = make_set_if(SourcePropertiesExtracted, isnotempty(SourcePropertiesExtracted))
, TargetPropertiesExtracted = make_set_if(TargetPropertiesExtracted, isnotempty(TargetPropertiesExtracted))
)
| extend SubscriptionId = extract("subscriptions/([a-f0-9-]{36})", 1, tostring(EntityIds), typeof(string))
| extend CountSourceProperties = coalesce(array_length(SourcePropertiesExtracted), 0)
, CountTargetProperties = coalesce(array_length(TargetPropertiesExtracted), 0)
| extend SourceRelevancyByLabel = iff(NodeLabel in (sourceTypes) or sourceTypes == "[\"\"]", 1, 0)
, TargetRelevancyByLabel = iff(NodeLabel in (targetTypes) or targetTypes == "[\"\"]", 1, 0)
, SourceRelevancyByProperties = iff(CountSourceProperties > 0 or sourceProperties == "[\"\"]", 1, 0)
, TargetRelevancyByProperties = iff(CountTargetProperties > 0 or targetProperties == "[\"\"]", 1, 0)
| extend SourceRelevancy = iff(SourceRelevancyByLabel == 1 and SourceRelevancyByProperties == 1, 1, 0)
, TargetRelevancy = iff(TargetRelevancyByLabel == 1 and TargetRelevancyByProperties == 1, 1, 0)
| lookup kind = leftouter (groupData) on SubscriptionId
);
let edges = (
ExposureGraphEdges
| where EdgeLabel in (edgeTypes)
| project EdgeId, EdgeLabel, SourceNodeId, SourceNodeName, SourceNodeLabel, TargetNodeId, TargetNodeName, TargetNodeLabel
);
let paths = (
edges
// Build the graph from all the nodes and edges and enrich it with node data (properties)
| make-graph SourceNodeId --> TargetNodeId with nodes on NodeId
// Look for existing paths between source nodes and target nodes with up to predefined number of hops
| graph-match (s)-[e*1..maxPathLength]->(t)
// Filter by sources and targets with GroupId
where (isnotempty(s.GroupId) and isnotempty(t.GroupId))
project SourceName = s.NodeName
, SourceType = s.NodeLabel
, SourceId = s.NodeId
, SourceProperties = s.SourcePropertiesExtracted
, CountSourceProperties = s.CountSourceProperties
, SourceRelevancy = s.SourceRelevancy
, SourceSubscriptionId = s.SubscriptionId
, SourceGroupId = s.GroupId
, SourceGroupType = s.GroupType
, TargetName = t.NodeName
, TargetType = t.NodeLabel
, TargetId = t.NodeId
, TargetProperties = t.TargetPropertiesExtracted
, CountTargetProperties = t.CountTargetProperties
, TargetRelevancy = t.TargetRelevancy
, TargetSubscriptionId = t.SubscriptionId
, TargetGroupId = t.GroupId
, TargetGroupType = t.GroupType
, EdgeLabels = e.EdgeLabel
, EdgeIds = e.EdgeId
, EdgeAllTargetIds = e.TargetNodeId
, EdgeAllTargetNames = e.TargetNodeId
, EdgeAllTargetTypes = e.TargetNodeLabel
| extend PathLength = array_length(EdgeIds) + 1
| extend PathId = hash_md5(strcat(SourceGroupId, TargetGroupId, PathLength))
);
let pathsWithGroups = (
paths
| summarize CountPaths = count(), CountSources = dcount(SourceId), CountTargets = dcount(TargetId)
, take_any(SourceGroupId, SourceGroupType, TargetType, TargetGroupId, TargetGroupType, PathLength)
by PathId
| limit resultCountLimit
);
pathsWithGroups
};
We can use it as follows (given an existing groupData table):
let sourceTypesList = pack_array('');
let sourcePropertiesList = pack_array('');
let targetTypesList = pack_array('');
let targetPropertiesList = pack_array('');
let pathsWithGroups = XGraph_PathExplorationWithGroups(sourceTypes=sourceTypesList, sourceProperties=sourcePropertiesList
, targetTypes=targetTypesList, targetProperties=targetPropertiesList);
pathsWithGroups
In the first row of the table above, you can see that there are paths of length 5 between 4 assets in ‘Test environment’ group and 9 assets in the same group.
As suggested above, the function XGraph_PathExplorationWithGroups aggregates source and target nodes by their GroupId and PathLength. The output presents a single row for all paths between or inside groups for each length. This is done by defining the parameter PathId and aggregating by it:
| extend PathId = hash_md5(strcat(SourceGroupId, TargetGroupId, PathLength))
This definition can be easily changed to adapt to other scenarios. For example, we can use the following definition to show paths between groups and asset types (as well as adding new fields to aggregation accordingly):
| extend PathId = hash_md5(strcat(SourceGroupId, SourceType, TargetGroupId, TargetType, PathLength))
Just as well, we can disregard PathLength, take into account intermediate edges, etc.
Note that the function still has the types and properties of sources and targets as required parameters. They are disregarded in our example by using empty arrays as input which takes all sources and targets without filtering them. You can use the filtering of relevant source and target nodes by labels and properties like in XGraph_PathExploration function described in the previous post by providing non-empty lists and filtering paths by s.SourceRelevancy == 1 and t.TargetRelevancy == 1 inside the function.
Cross-boundary paths between different group types
We can assign an additional describing property to each group. For example, each group can be flagged as Production/Non-Production, Development/Test, tagged with the project or business area it is assigned to, etc. In this case, cross-boundary paths might be worth attention from a security point of view. For example, walkable paths between Non-Production and Production environments might be illegitimate and pose security risks.
In the sample above, the GroupData datatable contains the SubscriptionId (so it can be joined to ExposureGraphNodes table), GroupId and a field called GroupType – representing some group (or subscription) property – such as differentiation to Production, Test and Backup environments. Thus, we can look for cross-boundary paths, that connect different environments – which might pose security risk and contradict company policy.
Query and results
This is done by adding the following filter for path discovery:
| graph-match (s)-[e*1..maxPathLength]->(t)
// Filter by sources and targets with GroupId and different GroupType
where (isnotempty(s.GroupId) and isnotempty(t.GroupId) and s.GroupType != t.GroupType)
The output is similar to the one above, but only shows the paths between different GroupTypes. Such paths can have various security implications, such as showing illegitimate and insecure connections between non-production and production environments.
As you can see in the first row of the table above, there are 79 paths of length 4 connecting between assets in ‘Billing’ group of type ‘Production’ and assets in ‘Test environment’ group of type ‘Test’.
Blast Radius and Asset Exposure for groups
Now that we know how to calculate Blast radius and Asset Exposure, and examine our graphical data on a group level, let’s connect the dots and calculate a group’s Blast Radius and group exposure. When trying to apply the concept of Blast Radius to group, this concept evaluates the potential impact of a compromised group on other groups.
On the other hand, group exposure evaluates how easy it is to access a group (especially containing valuable assets) from different groups in the graph.
By doing so, we can understand how interconnected risks may spread across our defined groups, enabling us to pinpoint critical areas that require enhanced protection. This approach not only saves time but also helps prioritize efforts by focusing on the group level before diving into individual assets.
Query and results
Let's assume we save the paths with GroupIds - either simply aggregated by GroupId or filtered by cross-boundary scenarios - as pathsWithGroups. Then we can calculate the Blast Radius and Asset Exposure at the group level.
let XGraph_GroupBlastRadius = (T:(SourceGroupId:string, SourceGroupType:string, TargetGroupId:string, TargetGroupType:string, PathLength:long, CountTargets:long)) {
T
| summarize arg_min(PathLength, *) by SourceGroupId, TargetGroupId
| summarize BlastRadiusTargetGroupIds = make_set(TargetGroupId)
, BlastRadiusTargetTypes = make_set(TargetGroupType)
, BlastRadiusGroupScore = dcount(TargetGroupId)
, BlastRadiusCountTargetIds = sum(CountTargets)
, MinPathLength = min(PathLength)
, MaxPathLength = max(PathLength)
by SourceGroupId, SourceGroupType
| sort by BlastRadiusGroupScore desc
};
The function XGraph_GroupBlastRadius shows what groups (per GroupId) can be reached from each source group and counts them as BlastRadiusGroupScore. Individual target assets are also counted as BlastRadiusCountTargetIds. These are useful insights that can show to what extent each group is connected to other target groups or resources. A well-connected group is more valuable for a potential attacker as a starting zone, thus should be protected and monitored more closely. Alternatively, if you run it on top of paths between different groups of different types (for example, test and production environments), the will show to what degree the source group tends to cross boundaries, which might indicate a misconfiguration with security implications.
It can be used like this on top of pathsWithGroups:
pathsWithGroups
| invoke XGraph_GroupBlastRadius()
In a similar way, we can define the function XGraph_GroupAssetExposure that shows Asset Exposure per target group.
let XGraph_GroupAssetExposure = (T:(SourceGroupId:string, SourceGroupType:string, TargetGroupId:string, TargetGroupType:string, PathLength:long, CountSources:long)) {
T
| summarize arg_min(PathLength, *) by SourceGroupId, TargetGroupId
| summarize ExposureSourceGroupIds = make_set(SourceGroupId)
, ExposureSourceTypes = make_set(SourceGroupType)
, ExposureGroupScore = dcount(SourceGroupId)
, ExposureCountSourceIds = sum(CountSources)
, MinPathLength = min(PathLength)
, MaxPathLength = max(PathLength)
by TargetGroupId, TargetGroupType
| sort by ExposureGroupScore desc
};
The XGraph_GroupAssetExposure tells us the degree that each target group is accessible from other groups (or, alternatively, groups of different type). An over-exposed target group, especially containing important assets, can be a security risk.
pathsWithGroups
| invoke XGraph_GroupAssetExposure()
Mastering Security Posture with Microsoft’s Advanced Exposure Management Tables
In this second post of our series, we dive deeper into the capabilities of Microsoft Security Exposure Management Graph. We have explored key concepts like Blast Radius, Asset Exposure and groups in graphs – accompanied by sample queries you can customize and utilize in your own environment. We hope this will empower you to explore and mitigate exposure risks more efficiently.
If you are having trouble accessing Advanced Hunting, please start with this guide.
Note: For full Security Exposure Management access, user roles need access to all Defender for Endpoint device groups. Users who have access restricted to specific device groups can access the Security Exposure Management attack surface map and advanced hunting schemas (ExposureGraphNodes and ExposureGraphEdges) for the device groups to which they have access.