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:

/images/GraphAPIActivity/Ninjacat.jpeg
RequestUri Length

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      
AppIdThe identifier for the application.
IPAddressThe IP address of the client from where the request occurred.
RequestIdThe identifier representing the request.
RequestMethodThe HTTP method of the event.
RequestUri    The URI of the request.
RolesThe roles in token claims.
ScopesThe scopes in token claims.
ServicePrincipalIdThe identifier representing the sign-in activities.
UserAgentThe user agent information related to the request.
UserIdThe identifier of the user making the request.

/images/GraphAPIActivity/Data.png
MicrosoftGraphActivityLogs Data

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.

/images/GraphAPIActivity/RequestUri.png
RequestUri Length

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.

/images/GraphAPIActivity/RequestTypes.png
Summarize Reqeuest Paths

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_v2security
/v1.0/identity/conditionalAccess/policiesidentity

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.

/images/GraphAPIActivity/Resources.png
Graph API Resource Statistics

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.

/images/GraphAPIActivity/AppEnrichment1.png
Enrich Application Information with AADNonInteractiveUserSignInLogs Data

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.

/images/GraphAPIActivity/EnrichUsers.png
Graph API Resource Statistics

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.

/images/GraphAPIActivity/AzureHound.png
AzureHound Detection

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 MicrosoftGraphActivityLogsInvictus Incident Response
A Defenders Guide to GraphRunner — Part IInvictus Incident Response
A Defenders Guide to GraphRunner — Part IIInvictus Incident Response
Detect threats using Microsoft Graph activity logs - Part 1Fabian Bader
Detect threats using Microsoft Graph activity logs - Part 2Fabian Bader

Questions? Feel free to reach out to me on any of my socials.