Investigating Microsoft Graph Activity Logs
At the beginning of April (2024) Microsoft announced the general availability of the Microsoft Graph activity logs. The logs can be forwarded using the Azure Diagnostics settings in Entra ID, which will in most cases result in a populated MicrosoftGraphActivityLogs table in your log analytics workspace.
This blog discusses the following topics:
- Microsoft Graph Activity Logs Content
- Effectively Querying The Graph API Logs
- Enriching Microsoft Graph Activity Logs
- Detecting Suspicious Activities
- Related Expert Blogs
Microsoft Graph activity logs content
The MicrosoftGraphActivityLogs are an audit trail of all HTTP requests that the Microsoft Graph service received and processed for a tenant. The table consists of 31 different columns, which are documented in the Microsoft Documentation.
The key columns for detection and forensics are in my opinion as follows:
Column | Description |
---|---|
AppId | The identifier for the application. |
IPAddress | The IP address of the client from where the request occurred. |
RequestId | The identifier representing the request. |
RequestMethod | The HTTP method of the event. |
RequestUri | The URI of the request. |
Roles | The roles in token claims. |
Scopes | The scopes in token claims. |
ServicePrincipalId | The identifier representing the sign-in activities. |
UserAgent | The user agent information related to the request. |
UserId | The identifier of the user making the request. |
Effectively Querying The Graph API Logs
The data in the MicrosoftGraphActivityLogs is by default not optimized yet for effective querying, luckily KQL offers opportunities to optimize the data for better performance and results.
Parsing RequestUri
The column RequestUri contains the request that has been executed, this often results in a very long string, and searching this with has or contains is not effective.
To extract the different request parameters from the RequestUri column the parse_url() function is used. This function parses the request and returns the URL components.
MicrosoftGraphActivityLogs
| extend ParsedUri = tostring(parse_url(RequestUri))
| project ParsedUri, RequestUri
| take 10
The initial RequestUri https://graph.microsoft.com/beta/users/microsoft.graph.delta()?$deltatoken=rvjDUCsbAVEsCEIyk6Grb--......" is now parsed to the JSON below and saved in the ParsedUri column. This allows us to split the original RequestUri into different areas of interest.
{
"Scheme":"https",
"Host":"graph.microsoft.com",
"Port":"",
"Path":"/beta/users/microsoft.graph.delta()",
"Username":"",
"Password":"",
"Query Parameters":{
"$deltatoken":"=rvjDUCsbAVEsCEIyk6Grb--......"
},
"Fragment":""
}
Retrieving Request Statistics
Retrieving request statistics gives us the opportunity for new use cases. One can now summarize all the GraphAPI request types easily with the following query. The unique deltatokens have been removed from the data, returning a better overview of the executed requests.
MicrosoftGraphActivityLogs
| extend ParsedUri = tostring(parse_url(RequestUri).Path)
| summarize TotalRequest = count() by ParsedUri
| sort by TotalRequest
Graph API KQL: The GitHub Repository Hunting-Queries-Detection-Rules has a dedicated section for Graph API queries you can leverage: Give me the Graph API Queries! There is already one DFIR query available for Graph API to return all the API Requests a suspicious users has performed.
Resource Statistics
The requests that are executed by the Graph API are standardized, thus we can use the RequestUri to get statistics on which Resource is requested. The {resource} parameter is used for the resource in Microsoft Graph that you’re referencing.
{HTTP method} https://graph.microsoft.com/{version}/{resource}?{query-parameters}
Source: Use the Microsoft Graph API
The table below shows some examples of users, security and identity resources and the RequestUriPath associated with those requests.
RequestUriPath | Resource |
---|---|
/beta/users/microsoft.graph.delta() | users |
/v1.0/security/alerts_v2 | security |
/v1.0/identity/conditionalAccess/policies | identity |
Because we know that the second parameter is the resource, we can use KQL to extract the resource. This is done using the line:
| extend GraphAPIResource = tostring(split(GraphAPIPath, "/")[2])
This line splits the GraphAPIPath at each /, resulting in an array of elements. For the request /v1.0/security/alerts_v2 this array is ["",“v1.0”,“security”,“alerts_v2”]. The [2] in the query selects the third element (count starts at 0) and the column GraphAPIResource is filled with this value. When performing statistics on the resource types the results below are retrieved.
MicrosoftGraphActivityLogs
| extend ParsedUri = tostring(parse_url(RequestUri).Path)
// Normalize Data
| extend GraphAPIPath = tolower(replace_string(ParsedUri, "//", "/"))
// Extract
| extend GraphAPIResource = tostring(split(GraphAPIPath, "/")[2])
| summarize TotalRequest = count() by GraphAPIResource
| sort by TotalRequest
The data can be further normalized depending on your use case by replacing IDs (UserID or ServicePrincipalId) with normalized values. The blog: Detect threats using Microsoft Graph activity logs - Part 1 by Fabian Bader explains this in more detail, including KQL examples.
Enriching Microsoft Graph Activity Logs
The contents of the columns in the MicrosoftGraphActivityLogs table contain multiple IDs, such as the AppId, UserId and ServicePrincipalId. These IDs are unique identifiers but do need to be enriched with understandable context, the ApplicationName or AccountDisplayName is easy to understand for people who analyze the logs. This section shows multiple examples of how the MicrosoftGraphActivityLogs can be enriched.
AppId
The AppId column can be enriched in two different methods, the first method collects the application information from the AADNonInteractiveUserSignInLogs table.
let ApplicationName = AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| summarize arg_max(TimeGenerated, *) by ResourceIdentity
| project-rename ApplicationName = ResourceDisplayName
| project ApplicationName, ResourceIdentity;
MicrosoftGraphActivityLogs
// Your filter here
| lookup kind=leftouter ApplicationName on $left.AppId == $right.ResourceIdentity
| project-reorder AppId, ApplicationName
This query does have a limitation, a user must have signed in to the application to show up in the logs. Using the Azure_Application_ID list developed by @Beercow 1000+ AppIds can be enriched with the externaldata operator resulting in the query below.
let ApplicationInformation = externaldata (ApplicationName: string, AppId: string, Reference: string ) [h"https://raw.githubusercontent.com/Beercow/Azure-App-IDs/master/Azure_Application_IDs.csv"] with (ignoreFirstRecord=true, format="csv");
MicrosoftGraphActivityLogs
// Your filter here
| take 1000
| lookup kind=leftouter ApplicationInformation on $left.AppId == $right.AppId
| project-reorder AppId, ApplicationName
The lookup operator in the shared queries can be replaced with the join operator. The differences are described in the lookup operator documentation.
UserId
The MicrosoftGraphActivityLogs can be enriched with user information from the IdentityInfo table to get more context in the results. The example below shows that the AccountDisplayName and AccountUPN are added to return more context for analysts.
MicrosoftGraphActivityLogs
| where isnotempty(UserId)
| lookup kind=leftouter (IdentityInfo
| where TimeGenerated > ago(30d)
| summarize arg_max(TimeGenerated, *) by AccountObjectId
| project AccountObjectId, AccountDisplayName, AccountUPN)
on $left.UserId == $right.AccountObjectId
| project-reorder AccountDisplayName, AccountUPN, RequestMethod, RequestUri
IPAddress
The IP information can be enriched using the geo_info_from_ip_address() function, which returns the country, state, city, latitude and longitude of each IPv4 and IPv6 address.
MicrosoftGraphActivityLogs
| extend GeoIPInfo = geo_info_from_ip_address(IPAddress)
| extend country = tostring(parse_json(GeoIPInfo).country), state = tostring(parse_json(GeoIPInfo).state), city = tostring(parse_json(GeoIPInfo).city), latitude = tostring(parse_json(GeoIPInfo).latitude), longitude = tostring(parse_json(GeoIPInfo).longitude)
| project-reorder IPAddress, country, state, RequestUri
Detecting Suspicious Activities
These new logs offer great potential for new detections as also shared in the related blog section. One example is detecting AzureHound which is the BloodHound data collector for Azure.
The query below uses the MicrosoftGraphActivityLogs to collect potential AzureHound executions. This is done by filtering on GET requests with status 200 since AzureHound is a collector that submits GET requests to retrieve the data. Furthermore, statistics are applied to count the number of bytes retrieved and how many unique requests have been executed within the timeframe of one hour. Lastly, the stats are compared against the thresholds, if the results are bigger than the thresholds the results are returned and your analysis can begin. These thresholds depend on the size of your Entra ID tenant. My test environment has a limited set of accounts, thus the total amount of unique requests is limited. If your organisation has more than 1000 users, the UniqueRequestThreshold can easily be set above 5000.
let WhitelistedObjects = dynamic(["obj1", "obj2"]);
let UniqueRequestThreshold = 1000; // Depends on Entra ID tentant size. You can use the function 0.5 * TotalAzure Resources to get this number. KQL: arg("").Resources | count
let TotalResponseSizeTHreshold = 1000000; // Depends on Entra ID tentant size
let ResourceThreshold = 4;
let ReconResources = dynamic(["organization","groups","devices","applications","users","rolemanagement","serviceprincipals"]);
MicrosoftGraphActivityLogs
| where RequestMethod == "GET"
| where ResponseStatusCode == 200
| extend ParsedUri = tostring(parse_url(RequestUri).Path)
| extend GraphAPIPath = tolower(replace_string(ParsedUri, "//", "/"))
| extend GraphAPIResource = tostring(split(GraphAPIPath, "/")[2])
| where GraphAPIResource in (ReconResources)
| extend ObjectId = coalesce(UserId, ServicePrincipalId)
// Filer whitelist
| where not(ObjectId in (WhitelistedObjects))
| summarize TotalResponseSize = sum(ResponseSizeBytes), UniqueRequests = dcount(RequestId), Requests = make_set(RequestUri, 1000), Paths = make_set(GraphAPIPath), Resources = make_set(GraphAPIResource), UniqueResourceCount = dcount(GraphAPIResource) by UserId, bin(TimeGenerated, 1h), UserAgent, ObjectId
| where UniqueRequests >= UniqueRequestThreshold and TotalResponseSize >= TotalResponseSizeTHreshold and UniqueResourceCount >= ResourceThreshold
Related Expert Blogs
Multiple related blogs have been written by experts which are worthwhile to further explore. These blogs also include valuable detections based on the MicrosoftGraphActivityLogs table.
Title | Source |
---|---|
Everything you need to know about the MicrosoftGraphActivityLogs | Invictus Incident Response |
A Defenders Guide to GraphRunner — Part I | Invictus Incident Response |
A Defenders Guide to GraphRunner — Part II | Invictus Incident Response |
Detect threats using Microsoft Graph activity logs - Part 1 | Fabian Bader |
Detect threats using Microsoft Graph activity logs - Part 2 | Fabian Bader |
Questions? Feel free to reach out to me on any of my socials.