Detect threats using GraphAPIAuditEvents - Part 3
For a long time now, defenders had the ability to monitor behavior of human- and workload identities in Entra tenants not only through AuditLogs
but with high level of insight with the MicrosoftGraphActivityLogs
logs. The last two articles of this series gave you detection ideas and hunting queries for this logs source and were meant as a kick starter for detection engineers. But in the end the high cost of this log prevented many companies from putting it into operation. This is about to change with the release of GraphAPIAuditEvents
logs in the XDR portal.
GraphAPIAuditEvents
In July 2025 this new table was releasedin public preview: GraphAPIAuditEvents.
And right from the start a difference between the Learn article and the actual schema in the tenant caught my eye.
The following columns were documented but not yet in the actual data.
- IPAddress
- UniqueTokenIdentifier
These columns are, in my opinion, very important for detection engineers. Luckily Microsoft updated the data in the meantime and these two columns are now available which opens up the great potential of is this dataset.
There are still differences between the paid Sentinel table and the XDR table and my friend Thomas Naunheim did a great job and published a comparison table. With his blessing I can can use it here to show you the differences.
But enough about schemas, differences and timelines, let’s get into the actual data.
AzureHound detection
One of the core detection ideas of the initial blog series was a behavior based detection method that relies on normalized data from the RequestUri
column and the knowledge which endpoints the tool is requesting.
For AzureHound we can test this, as the binary is available and super easy to use. Let’s borrow a access token for Microsoft Graph from a unsuspected test account and run AzureHound in the default configuration.
After some time the tenant information is fully dumped. So we head over to the Defender portal and have a look at the endpoints the user has queried. If we want to use the IdentityInfo table to resolve the object ID of the user, we need to have a longer lookback time as the actual time period we want to query. This is the reason I used Timestamp twice in this query.
IdentityInfo
| where Timestamp > ago(14d)
| where AccountUpn == "quellcrist.falconer@c4a8korriban.com"
| summarize by AccountObjectId
| join ( GraphAPIAuditEvents | where Timestamp > ago(1h) ) on AccountObjectId
| project-reorder Timestamp, AccountObjectId, RequestUri, Location, Scopes, UniqueTokenIdentifier
1605 requests by a single user in such a short period of time is already not normal in my environment.
GraphAPIAuditEvents
| summarize count() by bin(Timestamp, 1h), AccountObjectId
| where count_ > 100
| render timechart
But when we look at the endpoints this unique user queried in this time period it get’s even more interesting. At first it looks like another 1595 unique requests made.
GraphAPIAuditEvents
| where AccountObjectId == "db5ccf9c-9965-4659-bd76-97102d03b3f3"
| summarize count() by RequestUri
But when I only extract the actual endpoint by normalizing the RequestUri only 39 unique endpoints are actually requested.
GraphAPIAuditEvents
| where AccountObjectId == "db5ccf9c-9965-4659-bd76-97102d03b3f3"
| extend RequestUri = replace_regex(RequestUri, @'[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}', @'<UUID>')
| extend RequestUri = replace_regex(RequestUri, @'\?.*$', @'')
| summarize count() by RequestUri
Since I already had the query made for the MicrosoftGraphActivityLogs table I was able to quickly adapt it to the new GraphAPIAuditEvents. With this I now had a working detection for AzureHound. I also include one addition to the original query, you can now filter out activity based on the total requests made in the time period. This can be helpful to tune the query to your environment.
let AzureHoundGraphQueries = dynamic([
"https://graph.microsoft.com/beta/servicePrincipals/<UUID>/owners",
"https://graph.microsoft.com/beta/groups/<UUID>/owners",
"https://graph.microsoft.com/beta/groups/<UUID>/members",
"https://graph.microsoft.com/v1.0/servicePrincipals/<UUID>/appRoleAssignedTo",
"https://graph.microsoft.com/beta/applications/<UUID>/owners",
"https://graph.microsoft.com/beta/devices/<UUID>/registeredOwners",
"https://graph.microsoft.com/v1.0/users",
"https://graph.microsoft.com/v1.0/applications",
"https://graph.microsoft.com/v1.0/groups",
"https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments",
"https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions",
"https://graph.microsoft.com/v1.0/devices",
"https://graph.microsoft.com/v1.0/organization",
"https://graph.microsoft.com/v1.0/servicePrincipals"
]);
GraphAPIAuditEvents
| where ingestion_time() > ago(1h)
| extend ObjectId = coalesce(AccountObjectId, ApplicationId)
| where RequestUri !has "microsoft.graph.delta"
| extend NormalizedRequestUri = replace_regex(RequestUri, @'[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}', @'<UUID>')
| extend NormalizedRequestUri = replace_regex(NormalizedRequestUri, @'\?.*$', @'')
| summarize
TotalCount = count(),
GraphEndpointsCalled = make_set(NormalizedRequestUri, 1000),
arg_min(Timestamp, *)
by ObjectId, EntityType
| extend MatchingQueries=set_intersect(AzureHoundGraphQueries, GraphEndpointsCalled)
| extend ConfidenceScore = round(todouble(array_length(MatchingQueries)) / todouble(array_length(AzureHoundGraphQueries)), 1)
| where ConfidenceScore > 0.7
| where TotalCount > 1000
| project-away
NormalizedRequestUri,
RequestUri,
ResponseStatusCode,
RequestMethod,
RequestDuration,
ApiVersion
Sadly my second detection for AzureHound which worked as a near real-time detection is no longer possible. But since this only caught the laziest adversary, as it relied on the UserAgent to trigger, it’s not that big of deal that this field is not available.
Correlation with Sign-in logs
I mentioned that IP address and UniqueTokenIdentifier (UTI) are crucial fields and especially for the latter one this is true for hunting.
The UTI allows you to not only correlate the actual action to a specific sign in event, but because you will retrieve the session ID from those logs you can build a full impact analysis for any compromised session.
Microsoft themselves recently released a blog post on the importance of those identifiers
Strengthen identity threat detection and response with linkable token identifiers
UTI is available in the regular SigninLogs and therefor can be used to map GraphAPIAuditEvents to the original sign-in event. Sadly the same it not true for the AADSignInEventsBeta, as it only contains the SessionId. Hopefully if this table leaves the beta state it will also contain UTI.
So for now you still need some log data in Sentinel, but when you have Unified Advanced Hunting enabled you can query both datasets from the Defender portal.
With this query you will get the sign-in events of the user and from there can use the SessionId to correlate other logs to identify what the attacker might have done in other parts of Microsoft 365.
GraphAPIAuditEvents
| where RequestUri !has "microsoft.graph.delta"
| extend NormalizedRequestUri = replace_regex(RequestUri, @'[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}', @'<UUID>')
| extend NormalizedRequestUri = replace_regex(NormalizedRequestUri, @'\?.*$', @'')
| summarize
GraphEndpointsCalled = make_set(NormalizedRequestUri, 1000),
IpAddresses = make_set(IpAddress)
by UniqueTokenIdentifier
| join kind=inner (UnifiedSignInLogs
| where ResultType == 0
| project
UserPrincipalName,
AppDisplayName,
ResourceDisplayName,
IPAddress,
UniqueTokenIdentifier,
SessionId)
on UniqueTokenIdentifier
| project-away UniqueTokenIdentifier1
Summary
Releasing this dataset to every XDR customer is a great move by Microsoft, as it will allow more companies to leverage the information.
I hope that my queries and the detection example helps you to get started to build and share your own detections.
You find all KQL detections for AzureHound, GraphRunner and Purple Knight in my GitHub repository.
Further reading and thanks
Bert-Jan translated his hunting queries to the new table, which you can find in his GitHub repository.
Thanks again to Thomas Naunheim for the great comparisons of the two tables.