A Defenders Guide to GraphRunner — Part II

November 23, 2023

Introduction

You can find Part I of this series here. In the second and final part we will look at the remaining modules and share some advice on how to prevent Graph API abuse. Since the release of the first blog, the creators of GraphRunner did a talk on the inner workings and it’s available on YouTube.

Structure

In the second and final part of the series we will cover the following components of GraphRunner:

  • Pillage Modules
  • Supplemental Modules

Pillage Modules

GraphRunner incorporates seven pillage modules designed to help identify valuable data following the compromise of a Microsoft 365 account.

  1. Invoke-SearchSharePointAndOneDrive — Search across all SharePoint sites and OneDrive drives visible to the user.
  2. Invoke-ImmersiveFileReader — Open restricted files with the immersive reader.
  3. Invoke-SearchMailbox — Has the ability to do deep searches across a user’s mailbox and can export messages.
  4. Invoke-SearchTeams — Can search all Teams messages in all channels that are readable by the current user.
  5. Invoke-SearchUserAttributes — Search for terms across all user attributes in a directory.
  6. Get-Inbox — Gets the latest inbox items from a mailbox and can be used to read other user mailboxes (shared).
  7. Get-TeamsChat — Downloads full Teams chat conversations.

Invoke-SearchSharePointAndOneDrive

GraphRunner includes a module known as Invoke-SearchSharePointAndOneDrive, which utilizes the search functionality within SharePoint and OneDrive. This module not only allows you to search for files but also provides the option to download any discovered files. When you execute this module, it initiates a request to:

https://graph.microsoft.com/v1.0/search/query

Upon finding a file matching your search criteria, GraphRunner prompts you to decide whether to download the file. If you opt to download the file, an additional event entry will be generated, which appears as follows:

https://graph.microsoft.com/v1.0/drives/[ID]

In addition to MicrosoftGraphActivityLogs, this behavior can also be identified in the Unified Audit Log. The process involves initiating a SharePoint search, followed by the subsequent download of the located files.

Evidence

KQL queries
#
MicrosoftGraphActivityLogs detect Invoke-SearchSharePointAndOneDrive
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where (RequestUri has_all("https://graph.microsoft.com/v1.0/drives/","/items/","/content") or RequestUri == "https://graph.microsoft.com/v1.0/search/query")
| project-reorder TimeGenerated, RequestUri, ResponseStatusCode

# Unified Audit Log detect Invoke-SearchSharePointAndOneDrive
OfficeActivity
| (where Operation == "SearchQueryInitiatedSharePoint" and ScenarioName contains "PowerShell") or (where FileDownloaded == "SearchQueryInitiatedSharePoint" and UserAgent contains "PowerShell")

The SearchQuerInitiatedSharepoint is part of Microsoft Advanced Auditing and with the right license needs to be manually enabled using the following command: Set-Mailbox <user> -AuditOwner @{Add="SearchQueryInitiated"}

Invoke-ImmersiveFileReader

The Invoke-ImmersiveFileReader module enables the opening of restricted files using the immersive reader. What makes it interesting from a detection perspective is that while most modules typically generate an event in the MicrosoftGraphActivityLogs, this particular module does not trigger such an event. Fortunately, we find the corresponding event logged in the Unified Audit Log.

An event is triggered with the “Accessed file” operation.

Evidence

KQL query
OfficeActivity
| where Operation == "FileDownloaded"
| where UserAgent contains PowerShell

Invoke-SearchMailbox

The Invoke-SearchMailbox module is used to search for specific terms within the current user’s mailbox. It provides the capability to download messages along with their attachments. When utilizing the Invoke-SearchMailbox module, you can specify a search term to scan all emails within the mailbox containing that term. In our example, we used ‘test’ as the search term, resulting in the discovery of a total of 25 emails matching this criteria.

The initial event recorded in the MicrosoftGraphActivityLogs logging shows a request sent to https://graph.microsoft.com/v1.0/search/query similar to the Invoke-SearchSharePointAndOneDrive module.

After Invoke-SearchMailbox retrieves the emails, it displays their subject, date, sender, and a preview of the message in the PowerShell window. The script then prompts you to decide whether to download these emails and their attachments. If you select ‘Yes’ the following evidence can be identified in the logs.

Upon reviewing the MicrosoftGraphActivityLogs log table, we notice 25 events related to the execution of the following Graph API call. Remember, we found 25 emails containing the search term ‘test’?

https://graph.microsoft.com/v1.0/me/messages/[messageId], as illustrated in the screenshot below:

Examine the ResponseStatusCode field to determine whether the action was successful.

The module also triggers an event with the infamous MailItemsAccessed operation in the Unified Audit Log. This event is triggered when an email or email folder is being accessed.

Next, we as defenders would like to know what emails were accessed by the attacker. We often use the PowerShell Graph API module to find emails belonging to a specific Message Id.

You can do this by running the following two PowerShell commando’s:

Import-Module Microsoft.Graph.Mail

Get-MgUserMessage -UserId [userid] -Filter “internetMessageId eq ‘<{MessageID}>’”

Evidence

KQL query
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where (RequestUri == ("https://graph.microsoft.com/v1.0/search/query") or RequestUri contains "https://graph.microsoft.com/v1.0/me/messages/")
| project-reorder TimeGenerated, RequestUri, ResponseStatusCode

#Detects MailItemsAccessed needs to be correlated with user or service principal in the UAL
OfficeActivity
| where Operation == MailItemsAccessed

Invoke-SearchTeams

The Invoke-SearchTeams module can be used to search through all Teams messages in all channels that are readable by the current user. For example searching for “Password” results in four hits in our test environment.

Upon inspecting the Microsoft Graph Activity Logs, we pinpointed four Graph API requests directed to:

https://graph.microsoft.com/v1.0/me/messages/

These four requests align with the four events uncovered during the search for the term “Password.”

Evidence

KQL query
MicrosoftGraphActivityLogs
| where UserAgent contains “PowerShell”
| where RequestUri contains “https://graph.microsoft.com/v1.0/me/messages/"
| project-reorder TimeGenerated, RequestUri, ResponseStatusCode

Invoke-SearchUserAttributes

This module in GraphRunner allows you to specify a search term to scan Entra ID user attributes. As you can observe in the request call illustrated below, the module retrieves an extensive range of properties from all user objects. The call is made for all users within a tenant. Here’s an example of such a call:

https://graph.microsoft.com/v1.0/users/{ID}?$select=accountEnabled,ageGroup,assignedLicenses,businessPhones,city,companyName,consentProvidedForMinor,country,createdDateTime,creationType,department,displayName,mail,employeeId,employeeHireDate,employeeOrgData,employeeType,onPremisesExtensionAttributes,externalUserStateChangeDateTime,faxNumber,givenName,imAddresses,identities,externalUserState,jobTitle,surname,lastPasswordChangeDateTime,legalAgeGroupClassification,mailNickname,mobilePhone,id,officeLocation,onPremisesSamAccountName,onPremisesDistinguishedName,onPremisesDomainName,onPremisesImmutableId,onPremisesLastSyncDateTime,onPremisesProvisioningErrors,onPremisesSecurityIdentifier,onPremisesSyncEnabled,onPremisesUserPrincipalName,otherMails,passwordPolicies,passwordProfile,preferredDataLocation,preferredLanguage,proxyAddresses,Comment,Info,Password,Information,Description,login,signin,credential,cred,credentials,data,signInSessionsValidFromDateTime,sponsors,state,streetAddress,usageLocation,userPrincipalName,userType,postalCode&$expand=manager

Evidence

KQL query
MicrosoftGraphActivityLogs
| where UserAgent contains “PowerShell”
| where RequestUri has_all(“https://graph.microsoft.com/v1.0/users/","accountEnabled,ageGroup,assignedLicenses,businessPhones,city,companyName,consentProvidedForMinor,country,createdDateTime,creationType,department,displayName,mail,employeeId,employeeHireDate,employeeOrgData,employeeType,onPremisesExtensionAttributes,externalUserStateChangeDateTime,faxNumber,givenName,imAddresses,identities,externalUserState,jobTitle,surname,lastPasswordChangeDateTime,legalAgeGroupClassification,mailNickname,mobilePhone,id,officeLocation,onPremisesSamAccountName,onPremisesDistinguishedName,onPremisesDomainName,onPremisesImmutableId,onPremisesLastSyncDateTime,onPremisesProvisioningErrors,onPremisesSecurityIdentifier,onPremisesSyncEnabled,onPremisesUserPrincipalName,otherMails,passwordPolicies,passwordProfile,preferredDataLocation,preferredLanguage,proxyAddresses,Comment,Info,Password,Information,Description,login,signin,credential,cred,credentials,data,signInSessionsValidFromDateTime,sponsors,state,streetAddress,usageLocation,userPrincipalName,userType,postalCode&$expand=manager")
| project-reorder TimeGenerated, RequestUri, ResponseStatusCode

Get-Inbox

If you want to view the most recent messages from a mailbox, you can utilize the Get-Inbox module. By default, the module retrieves the last 25 messages from the inbox, but you can specify a different number using the -TotalMessages parameter.

In our example, we executed the Get-Inbox with the userid parameter set to FortunaHodan@bonacu.onmicrosoft.com without specifying the TotalMessages parameter. As a result, it returned the most recent 25 messages from that account. The event displayed below was recorded in the MicrosoftGraphActivityLogs:

By examining the RequestUri in this event, we can identify the user whose emails were requested the emails and the number of emails being sought, as indicated by the ‘$top=[number]’ parameter.

The Get-Inbox module initiates one or more MailItemsAccessed operations in the Unified Audit Log. MailItemsAccessed aggregates activity that occurs within 2 minutes. Each event contains an OperationCount that can be used to identify the number of individual emails and the event also contains the InternetMessageID, for each accessed email. The example below shows several unique emails being accessed aggregated into a single MailItemsAccessed event in the UAL.

Evidence

KQL query
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri has_all("@","https://graph.microsoft.com/v1.0/users/","mailFolders/Inbox/messages","$top=")
| project-reorder TimeGenerated, RequestUri, ResponseStatusCode

#Detects MailItemsAccessed needs to be correlated with user or service principal in the UAL
OfficeActivity
| where Operation == MailItemsAccessed

Get-TeamsChat

The Get-TeamsChat module facilitates the downloading of complete Teams chat conversations. It prompts the user to download either all conversations for a specific user or individual conversations using a chat ID. To utilize this module, ensure you have a token scoped to Chat.ReadBasic, Chat.Read, or Chat.ReadWrite.

In our example, the module successfully identified three Teams Chat conversations, offering the option to either download all of them or select individual chat conversations.

In the MicrosoftGraphActivityLogs it triggers the following event:

https://graph.microsoft.com/v1.0/me/chats?$expand=members,lastMessagePreview&orderby=lastMessagePreview/createdDateTime%20desc

Opting to download the chats results in corresponding calls to:

https://graph.microsoft.com/v1.0/chats/{ID}/messages

Evidence

KQL query
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri has_all("@","https://graph.microsoft.com/v1.0/users/","mailFolders/Inbox/messages","$top=")
| project-reorder TimeGenerated, RequestUri, ResponseStatusCode

Supplemental Modules

GraphRunner offers the following 10 supplemental modules:

  1. Invoke-AutoOAuthFlow — Automates the OAuth flow completion to obtain access and refresh keys when a user grants consent to an app registration.
  2. Invoke-DeleteOAuthApp — Delete an OAuth App.
  3. Invoke-DeleteGroup — Delete a group.
  4. Invoke-RemoveGroupMember — Module for removing users/members from groups.
  5. Invoke-DriveFileDownload — Has the ability to download single files from SharePoint and OneDrive as the current user.
  6. Invoke-CheckAccess — Check if tokens are valid.
  7. Invoke-HTTPServer — A basic web server to use for accessing the email viewer that is output from Invoke-SearchMailbox.
  8. Invoke-BruteClientIDAccess — Test different client_id’s against MSGraph to determine permissions.
  9. Invoke-ImportTokens — Import tokens from other tools for use in GraphRunner.
  10. Get-UserObjectID — Retrieves an Object ID for a user.

Invoke-AutoOAuthFlow

When establishing persistence within an account under our control, it is possible to streamline the process by directing the browser to localhost. The Invoke-AutoOAuthFlow module spins up a web server that actively listens for this request and concludes the OAuth flow using the provided app registration credentials. The execution of this module does not trigger events, as the web server exclusively responds to localhost requests.

Invoke-DeleteOAuthApp

The Invoke-DeleteOAuthApp module can be used to delete an OAuth application. Executing this action triggers an event in the MicrosoftGraphActivityLogs. During our tests, we successfully deleted the application identified by the ID: bb019e82–2b52–4de6-b138-d5d28e997153. The RequestUri field reflects the request to:

https://graph.microsoft.com/v1.0/applications/bb019e82-2b52-4de6-b138-d5d28e997153.

A closer look at the RequestMethod reveals a DELETE event, confirming the deletion of the application associated with the aforementioned ID.

In addition to MicrosoftGraphActivityLogs, two events were triggered in the Entra ID Audit Log.

The Unified Audit Log mirrors the identical pair of events observed in the Entra Audit Log.

Evidence

KQL queries

# MicrosoftGraphActivityLogs detects Graph Activity related to the deletion of an OAuth application.
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestMethod == "DELETE"
| where RequestUri contains "https://graph.microsoft.com/v1.0/applications/"
| project-reorder TimeGenerated, RequestUri, RequestMethod, ResponseStatusCode

# Audit Logs detects deletion of an OAuth application.
AuditLogs
| where AdditionalDetails[0].value contains "PowerShell"
| where (OperationName == "Remove service principal" or OperationName == "Delete application")
| extend ApplicationName = TargetResources[0].displayName
| project TimeGenerated, OperationName,Result,ApplicationName

# Unified Audit Log detects deletion of an OAuth application.
OfficeActivity
| where AdditionalDetails[0].value contains "PowerShell"
| where (OperationName == "Delete application." or OperationName == "Remove service principal.")

Invoke-DeleteGroup

The Invoke-DeleteGroup module allows you to delete an Azure group. Executing this action will generate an event with a DELETE request directed at:

https://graph.microsoft.com/v1.0/groups

Furthermore, a single event was triggered in the Entra Audit Log, signifying the deletion of a group. Examine the Result field to verify the success of the action.

In the Unified Audit Logs, an ‘Delete group.’ event is triggered, indicating the deletion of a group. Examination of the Target field is performed to identify the name of the group.

Evidence

KQL queries
# MicrosoftGraphActivityLogs detect Graph Activity related to the deletion of an Azure group
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestMethod == "DELETE"
| where RequestUri == "https://graph.microsoft.com/v1.0/groups/"
| project-reorder TimeGenerated, RequestUri, RequestMethod, ResponseStatusCode

# Unified Audit Log detect deletion of an Azure Group
OfficeActivity
| where Operation == "Delete group."
| where AdditionalDetails[0].value contains "PowerShell"

# AuditLogs detect deletion of an Azure Group
AuditLogs
| where AdditionalDetails[0].value contains "PowerShell"
| where OperationName == "Delete group"
| extend GroupID =TargetResources[0].id
| project TimeGenerated, OperationName,Result,GroupID

Invoke-RemoveGroupMember

The Invoke-RemoveGroupMember module allows you to delete a member from a group. Executing this action will generate an event signifying a DELETE request directed at:

https://graph.microsoft.com/v1.0/groups/{GroupID}/members/[USER}/$ref

Based on the RequestUri you can determine the group of which a user was deleted and the userId belonging to this user.

Furthermore, a single event was triggered in the Entra ID Audit Log, signifying the removal of a member from a group. The same event is triggered in the Unified Audit Log.

Evidence

KQL queries
# MicrosoftGraphActivityLogs detects Graph Activity related to the deletion of a member from a group
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri has_all ("https://graph.microsoft.com/v1.0/groups/","/members/","/$ref")
| project-reorder TimeGenerated, RequestUri, RequestMethod, ResponseStatusCode

# AuditLogs detects activity related to the deletion of a member from a group
AuditLogs
| where AdditionalDetails[0].value contains "PowerShell"
| where OperationName == "Remove member from group"
| extend DeletedUser = TargetResources[0].userPrincipalName
| project TimeGenerated, OperationName,Result,DeletedUser

# Unified Audit Log detects activity related to the deletion of a member from a group
OfficeActivity
| where Operation == "Remove member from group."
| where AdditionalDetails[0].value contains "PowerShell"

Invoke-DriveFileDownload

The module Invoke-DriveFileDownload allows you to download single files from SharePoint and OneDrive as the current user.

Upon downloading a file, a singular event is triggered in the MicrosoftGraphActivityLogs. This event initiates a Graph API call to:

https://graph.microsoft.com/v1.0/drives/b!qotyds6mREGtVQY2s0Lf9kN9y1RktrJEsMntUZCyUOvkpETKEd_sQpddbDUic6ZB//items/01SCQACXWS765FVHENDRAIHUDONCQULCQV/content

Through this call, you can extract valuable information, including the ID of the drive and the ID of the items involved. This provides a comprehensive overview of the file retried.

In the Unified Audit Log an event is triggered with the “Downloaded file” operation as shown in the screenshot below.

Evidence

KQL queries
#Detects activity where a SharePoint or OneDrive file is downloaded.
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri has_all("https://graph.microsoft.com/v1.0/drives/","/items/","/content")
| project-reorder TimeGenerated, RequestUri, ResponseStatusCode

#Detects activity where a SharePoint or OneDrive file is downloaded.
OfficeActivity
| where Operation == FileDownloaded
| where UserAgent contains "PowerShell"

Invoke-CheckAccess

Executing the Invoke-CheckAccess module verifies the validity of tokens. This action initiates a single event, triggering a Graph API call to: https://graph.microsoft.com/v1.0/me

Considering that this Graph API call is frequently utilized by various modules, it may not serve as a dependable detection source.

KQL query
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri == "https://graph.microsoft.com/v1.0/me"
| project-reorder TimeGenerated, RequestUri, ResponseStatusCode

Invoke-HTTPServer

This module operates as a web server designed exclusively for accessing output from the Invoke-SearchMailbox module, it does not generate any events.

Invoke-BruteClientIDAccess

This module tests various client_ids against Microsoft Graph to assess permissions. This module does not trigger any events.

Invoke-ImportTokens

The Invoke-ImportTokens module allows you to import tokens from other tools for use in GraphRunner. Running this module does not trigger events.

Get-UserObjectID

Executing Get-UserObjectID allows you to obtain the Object ID associated with a user. By specifying the User Principal Name (UPN) during the module’s runtime, you can retrieve the corresponding object ID.

Upon running this module, an event is triggered for the UPN you’re obtaining the Object ID for, in this example FortunaHodan

https://graph.microsoft.com/v1.0/users/FortunaHodan@bonacu.onmicrosoft.com

Evidence

KQL query
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri has_all("@","https://graph.microsoft.com/v1.0/users/")
| project-reorder TimeGenerated, RequestUri, ResponseStatusCode

Prevention

Let’s say you want to prevent GraphRunner from even doing anything we talked about in the last two blogs. That’s going to be a challenge, but here are some ideas to make it less easy for attackers to abuse this toolkit.

Token protection
A tool like GraphRunner requires a token to function, this token can be acquired in different ways, your browser or through phishing. Ultimately, the token will be used by the attacker on a different device. Microsoft now offers token protection as part of Conditional Access. With this feature the token binds to the device, rendering it useless from another device. However, this isn’t supported for all Microsoft services and endpoints. yet.

Block application sign-ins
Once you have identified that an enterprise application is used for GraphRunner authentication we can disable sign-ins using the Microsoft Entra Admin Center under Enterprise Application and then Properties.

Conditional Access App Control
With App Control, you can achieve more visibility into cloud apps that are used and more importantly limit and block access and activities performed by apps in your cloud.

Conclusion

We’ve had a lot of fun writing this blog series on GraphRunner. It remains challenging for defenders to gain insight into malicious activity through the Graph API, why is that? One of the reasons is that Microsoft services uses the Graph continuously so the amount of data you have to comb through to figure out what’s malicious or anomalous is challenging. Second is that detecting tools like GraphRunner can’t just be done by putting all our indicators in a Sentinel query. It requires correlation with the user or service principal responsible for the call. We think that’s what makes our work so fun, but don’t think that this blog series is a silver-bullet in finding all Graph API abuse…

Happy hunting and until the next one!

To finish of this series we want to give you an overview which modules generate log entries and in which log source.

List of Indicators

We’ve updated the indicators of compromise list which now covers all modules of GraphRunner. We can guarantee that all these trigger as part of GraphRunner, but there might be more, please let us know or even better create a PR on GitHub!