A Defenders Guide to GraphRunner — Part I

October 31, 2023

There’s a new sheriff in Microsoft cloud post-exploitation town it’s called GraphRunner and developed by Beau Bullock(X) & Steve Borosh(X) from Black Hills Information Security. We suspect/fear this toolset will become very popular with threat actors (and red-teamers).

GraphRunner is a post-exploitation toolset for working with a Microsoft Entra ID (Azure AD) account. It contains various tools for accessing and manipulating data within the tenant.

In this two part blog series we want to show you how you can prevent and detect that GraphRunner was being used in your environment. We believe this will be useful for security analyst, detection engineers and incident responders. The blog includes Indicators of Compromise (IoC) and KQL queries that you can use in your environment, thanks to (Bert-Jan) for the support.

Structure

In the first part of the series we will cover the following components of GraphRunner:

  • Authentication
  • Recon & Enumeration
  • Persistence

In the second part we will cover the other modules and provide some advice on how to prevent and protect against tools and attack like this.

Only interested in the IoCs? Scroll to the bottom of this post and check the KQL queries on our GitHub.

Authentication

The first step to authenticate to an environment, GraphRunner offers the following options:

  1. Get-GraphTokens — Authenticate as a user to Microsoft Graph.
  2. Get-AzureAppTokens — Complete OAuth flow as an app to obtain access tokens.
  3. Invoke-RefreshGraphTokens — Use a refresh token to obtain new access tokens.
  4. Invoke-RefreshAzureAppTokens — Use a refresh token and app credentials to refresh a token.
  5. Invoke-AutoTokenRefresh — Refresh tokens automatically at a specified interval.

Get-GraphTokens

Authentication within the Get-GraphTokens module is accomplished through the device-code login method, enabling authentication from a browser session to the Microsoft Graph API. As defenders, we can leverage two aspects of this behavior to detect authentication by GraphRunner.

The authentication occurs against the Microsoft Graph resource using a device code, we can proactively search for such patterns in the Entra ID Sign-In logs. As demonstrated in the screenshot below, we can observe this in the Sign-In logs.

The login can also be identified in the Unified Audit Log (UAL). However, the “Authentication Protocol” field is absent in the UAL, which makes it more challenging to locate the GraphRunner login, since you cannot filter on the Authentication Protocol field.

Evidence

KQL query

SigninLogs
| where AuthenticationProtocol == "deviceCode"
| where ResourceDisplayName == "Microsoft Graph"

Get-AzureAppTokens

Another way to get a token is through Azure AD/Entra ID applications. This module uses the OAuth flow to request an access token. From a detection perspective we can look at Service Principal (SP) logins. Either by accessing the Entra ID Portal and under Sign-In logs selecting the Service Principal tab.

The challenge is that in most environments there are lots of SPs generating sign in events. In order to identify suspicious activity you’ll need to know which SP was abused by the attacker to identify that a sign in occurred which resulted in a token that can be used by GraphRunner.

Evidence

KQL query
AADServicePrincipalSignInLogs
| where ServicePrincipalName == "sp-name-that-is-abused" or AppId == "ID-of-abused-app"

Token refresh modules

For the following modules:

  • Invoke-RefreshGraphTokens
  • Invoke-RefreshAzureAppTokens
  • Invoke-AutoTokenRefresh

we have been unable to consistently get log entries or other evidence of abuse in the logs. If you’re reading this and you have a good way to detect this module we’d love to hear it.

Recon & Enumeration

GraphRunner offers 10 specific modules for recon & enumeration:

  1. Invoke-GraphRecon — Collects tenant information, including app permissions and user settings.
  2. Invoke-GraphRunner — Automates multiple reconnaissance modules, streamlining data collection.
  3. Invoke-GraphOpenInboxFinder — Verifies if users’ inboxes are readable, aiding in identifying potential email-related security concerns.
  4. Get-SharePointSiteURLs — Retrieves SharePoint site URLs accessible to the current user, providing insight into document storage and access.
  5. Get-DynamicGroups — Helps identify dynamic groups and analyze their membership rules for exploitability.
  6. Get-UpdatableGroups — Identifies groups that a user can potentially modify using the “Estimate Access” API.
  7. Invoke-DumpApps — Uncovers malicious app registrations, including permission scopes and user consents.
  8. Get-SecurityGroups — Retrieves information about security groups and their members.
  9. Get-AzureADUsers — Gathers data on all users within the user directory, essential for user profiling and access assessment.
  10. Invoke-DumpCAPS — Gets conditional access policies.
  11. Get-TenantID — Retrieves the tenant GUID from the domain name.

We will explore each of these modules in detail, gaining insights into the specific events they trigger. We’ll also discuss how these events can be utilized for effective detection and monitoring.

Invoke-GraphRecon

The Invoke-GraphRecon module collects various tenant information, encompassing primary contact details, directory sync configurations, and user settings. This information includes permissions related to app creation, group creation, and app consent.

Additionally, the Invoke-GraphRecon module features a “PermissionEnum” switch. When activated, this switch leverages an undocumented “Estimate Access” API to systematically test nearly 400 actions. This process helps determine the specific actions permitted for the current user.

Fortunately, from a defender’s standpoint, this undocumented API does leave traces in the MicrosoftGraphActivityLogs. Following the execution of the Invoke-GraphRecon module, we see a lot of events. We tested this extensively however the number of events keeps changing, so we can’t give you an exact number. When using the PermissionEnum feature you’ll see a very specific call to the following (beta) endpoint.

https://graph.microsoft.com/beta/roleManagement/directory/estimateAccess

Running the module without the ‘PermissionEnum’ switch triggers only one event, which is a call to:

https://graph.microsoft.com/beta/policies/authorizationPolicy

Evidence

KQL query
#Detect Graph API calls made by the Invoke-GraphRecon module, because they’re all made at the same time.
let InvokeGraphReconCalls = dynamic(["https://graph.microsoft.com/v1.0/search/query", "https://graph.microsoft.com/v1.0/servicePrincipals/", "https://graph.microsoft.com/v1.0/users/", "https://graph.microsoft.com/v1.0/organization", "https://graph.microsoft.com/v1.0/applications", "https://graph.microsoft.com/v1.0/servicePrincipals?$skiptoken="]);
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri in~ (InvokeGraphReconCalls) or RequestUri has_all("
https://graph.microsoft.com/v1.0/servicePrincipals(appId=", "appRoleAssignedTo")
| extend RequestedAppId = extract(@"appId=’(.*?)’", 1, RequestUri)
| sort by TimeGenerated asc
| extend timeDiffInSeconds = datetime_diff(‘second’, prev(TimeGenerated, 1), TimeGenerated)
| where timeDiffInSeconds == 0

Invoke-GraphRunner

This modules streamlines the execution of multiple reconnaissance and data acquisition modules. It will automate the execution of several modules, including:

  • Invoke-GraphRecon
  • Get-AzureADUsers
  • Get-SecurityGroups
  • Invoke-DumpCAPS
  • Invoke-DumpApps

While executing this module, a lot of events are generated in the MicrosoftGraphActivityLogs. In our relatively small environment, the script encountered numerous errors during execution. Despite these errors, we recorded a substantial set of events in a matter of seconds. This indicates that even in a smaller setup with error handling, the number of events generated can be quite significant.

Evidence

KQL query
#Detect Graph API calls made by the Invoke-GraphRunner module
let InvokeGraphRunnerCalls = dynamic(["https://graph.microsoft.com/v1.0/search/query", "https://graph.microsoft.com/v1.0/servicePrincipals/", "https://graph.microsoft.com/v1.0/users/", "https://graph.microsoft.com/v1.0/organization", "https://graph.microsoft.com/v1.0/applications", "https://graph.microsoft.com/v1.0/servicePrincipals?$skiptoken="]);
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri in~ (InvokeGraphRunnerCalls) or RequestUri has_all("
https://graph.microsoft.com/v1.0/servicePrincipals(appId=", "appRoleAssignedTo")
| extend RequestedAppId = extract(@"appId=’(.*?)’", 1, RequestUri)
| sort by TimeGenerated asc
| extend timeDiffInSeconds = datetime_diff(‘second’, prev(TimeGenerated, 1), TimeGenerated)
| where timeDiffInSeconds == 0

Invoke-GraphOpenInboxFinder

The Invoke-GraphOpenInboxFinder module verifies for a given list of users if their inbox is readable. You can detect this activity in the MicrosoftGraphActivityLogs by searching for events where a request is made to the following endpoint:

https://graph.microsoft.com/v1.0/users/{User}/mailFolders/Inbox/messages

Evidence

KQL queries
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri has_all("
https://graph.microsoft.com/v1.0/users/","/mailFolders/Inbox/messages")
| extend RequestedUPN = tostring(extract(@"users/(.*?)/mailFolders", 1, RequestUri))
| project-reorder TimeGenerated, RequestUri, ResponseStatusCode, RequestedUPN

# To retrieve the mailbox(es) that were requested and open use the following query.
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri has_all("
https://graph.microsoft.com/v1.0/users/","/mailFolders/Inbox/messages")
| extend RequestedUPN = tostring(extract(@"users/(.*?)/mailFolders", 1, RequestUri))
| where ResponseStatusCode == 200
| summarize OpenMailboxes = make_set(RequestedUPN)

Get-SharePointSiteURLs

The Get-SharePointSiteURLs module retrieves a list of SharePoint site URLs visible to the current user. When you run this command, a request is made to:

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

It’s worth noting that this requestUri is utilized by multiple modules in GraphRunner, making it challenging to pinpoint which specific module was executed based solely on this information.

Evidence

KQL query
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri == "
https://graph.microsoft.com/v1.0/search/query"

Get-DynamicGroups

This module retrieves all dynamic groups, executing this module will generate a corresponding event in the MicrosoftGraphActivityLogs a request to:

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

Evidence

KQL query
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri == "
https://graph.microsoft.com/v1.0/groups"

Get-UpdatableGroups

The Get-UpdatableGroups module identifies groups that the current user might have the ability to modify. The module interacts with the “Estimate Access” API to assess whether the current user possesses the required permissions to update groups within the tenant.

This module follows a two-step process. Initially, it enumerates all the groups available, and subsequently, it uses the estimateAccess API for each group to evaluate whether the user has the necessary permissions to modify that group. You can see this process illustrated in the screenshot below taken from the MicrosoftGraphActivityLogs:

Evidence

KQL query
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri == "
https://graph.microsoft.com/beta/roleManagement/directory/estimateAccess" or RequestUri == "https://graph.microsoft.com/v1.0/groups"
| project-reorder TimeGenerated, RequestUri

Invoke-DumpApps

The Invoke-DumpApps module assists in identifying app registrations. It will dump a list of Azure app registrations from the tenant, including permission scopes and users that have consented to the apps. Additionally, it will list external apps that are not owned by the current tenant or by Microsoft’s main app tenant. This is a way to find third-party external apps that users may have consented to. The execution of this module results in a significant amount of events, as shown in the below screenshot.

Evidence

KQL query
let InvokeDumpAppsCalls = dynamic(["https://graph.microsoft.com/v1.0/users/", "https://graph.microsoft.com/v1.0/organization" ,"https://graph.microsoft.com/v1.0/applications","https://graph.microsoft.com/v1.0/servicePrincipals/",'https://graph.microsoft.com/v1.0/servicePrincipals?$skiptoken="']);
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri in~ (InvokeDumpAppsCalls) or RequestUri has_all("
https://graph.microsoft.com/v1.0/servicePrincipals(appId=", "appRoleAssignedTo")
| extend RequestedAppId = extract(@"appId=’(.*?)’", 1, RequestUri)

Get-SecurityGroups

The Get-SecurityGroups module retrieves all security groups and their respective members. Executing this module will trigger the events shown below in the MicrosoftGraphActivityLogs.The number of events generated corresponds to the quantity of security groups present in the environment.

Evidence

KQL query
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where (RequestUri == "
https://graph.microsoft.com/v1.0/groups?=securityEnabled%20eq%20true" or RequestUri has_all("https://graph.microsoft.com/v1.0/groups/","members"))
| extend GroupObjectId = tostring(extract(@"groups/(.*?)/members", 1, RequestUri))

Get-AzureADUsers

The Get-AzureADUsers module retrieves all users in the user directory. Executing this module generates an event indicating a call to:

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

Evidence

KQL query
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri == "
https://graph.microsoft.com/v1.0/users"

Invoke-DumpCAPS

The Invoke-DumpCAPS module retrieves all conditional access policies, but it does not trigger any events in the logging upon execution.

Get-TenantID

The Get-TenantID module retrieves the tenant GUID from the domain name, but it does not generate any log events when executed.

Persistence

GraphRunner offers four specific modules for establishing and maintaining persistence:

  1. Invoke-InjectOAuthApp: This module automates the deployment of an app registration in a Microsoft tenant. It assigns various permissions without requiring administrative consent, making it a potent tool for attackers.
  2. Invoke-SecurityGroupCloner: With this module, users can clone a security group while adding themselves or others to the newly created group.
  3. Invoke-InviteGuest: This module allows the invitation of guest users to the Azure tenant.
  4. Invoke-AddGroupMember: Used for adding members to an Entra ID group.

Invoke-InjectOAuthApp

The Invoke-InjectOAuthApp module is designed to automate the deployment of an app registration in a Microsoft tenant. When configuring the -scope parameter as ‘op backdoor,’ the tool creates an application and assigns a wide range of common permissions to it, which includes access to Mail, Files, Teams, and various other resources. Notably, none of these permissions require administrative consent.

This module triggers three events in the Azure Audit Logs when executed:

  • Add application
  • Update application — Certificates and secrets management
  • Update application

The most important event entry is ‘Add application’. In this event you can find the newly created application ID and name. Another crucial field to examine is the ‘Address’ field, which holds the Reply URL. This field contains the malicious URL to which the user’s session will be redirected.

The Invoke-InjectOAuthApp module generates a notable volume of events within the MicrosoftGraphActivityLogs, which is visually represented in the below screenshot:

Evidence

KQL queries
# Detect app registration
let ApplicationOperations = dynamic(["Add application", "Update application — Certificates and secrets management", "Update application"]);
AuditLogs
| where AdditionalDetails[0].value contains "PowerShell"
| where OperationName in (ApplicationOperations)

# Detect app registration in Graph Activity Logs
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where (RequestUri has_all("
https://graph.microsoft.com/v1.0/applications/", "addPassword") or
RequestUri == "
https://graph.microsoft.com/v1.0/applications" or
RequestUri == "
https://graph.microsoft.com/v1.0/servicePrincipals")
| extend ApplicationId = tostring(extract(@"applications/(.*?)/addPassword", 1, RequestUri))

Invoke-SecurityGroupCloner

This module clones a group while incorporating a user into it. The process involves several steps:

  1. Initially, the module identifies all available groups.
  2. Subsequently, it queries each group individually to gather information about its members.
  3. Afterward, you can select a group to clone, and the module will replicate it.
  4. Finally, it offers the option to add you or other members to the newly cloned group.

In our example, we had five groups available. We decided to clone one of them and added the current user. This series of actions resulted in the following chain of events:

In addition there were two events triggered in the Entra Audit log. These events indicate the creation of a new group followed by the addition of a member to the newly created group.

Evidence

KQL queries
# Detect creation of group
AuditLogs
| where AdditionalDetails[0].value contains "PowerShell"
| where (OperationName == "Add member to group" or OperationName == "Add group")

# Detect Graph Activity related to security groups cloning
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri has_all("
https://graph.microsoft.com/v1.0/groups/", "/members/$ref")
or RequestUri has_all("
https://graph.microsoft.com/v1.0/groups", "/members")
or RequestUri == "
https://graph.microsoft.com/v1.0/groups?=securityEnabled%20eq%20true"
or RequestUri == "
https://graph.microsoft.com/v1.0/me"
| extend GroupObjectId = tostring(extract(@"groups/(.*?)/members", 1, RequestUri))

Invoke-InviteGuest

With the ‘Invoke-InviteGuest’ module we can invite guest users in a tenant. This action triggers specific events that are logged in the Entra ID Audit Log. As shown in the image below, two events are recorded, indicating the creation of a new user account and the invitation of an external user account.

Within these two events, valuable information such as the username and external email address can be discovered.

Additionally, two events are generated in the MicrosoftGraphActivityLogs, which are shown in the screenshot below:

Evidence

KQL queries

#Detect invitation and adding of user
AuditLogs
| where (OperationName == "Invite external user" or (OperationName == "Add user" and AdditionalDetails[0].value == "Microsoft Azure Graph Client Library 1.0"))
| extend UserUPN = TargetResources[0].userPrincipalName

#Detect Microsoft Graph activity
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where (RequestUri == "https://graph.microsoft.com/v1.0/invitations" or RequestUri == "https://graph.microsoft.com/v1.0/organization")

Invoke-AddGroupMember

The Invoke-AddGroupMember module allows you to add a member to an Azure group. Executing this module results in the generation of a single event entry in the MicrosoftGraphActivityLogs.

https://graph.microsoft.com/v1.0/groups/{ID}/members/$ref

Execution of this module also generates a log entry in both the Entra ID Audit Logs and the Unified Audit Log, indicating the addition of a member to a group.

Evidence

KQL queries
#Detect Entra ID activity where user is added to group
AuditLogs
| where AdditionalDetails[0].value contains "PowerShell"
| where OperationName == "Add member to group"
| extend UserUPN = TargetResources[0].userPrincipalName, GroupID = TargetResources[1].id

#Detect UAL activity where user is added to group
OfficeActivity
| where Operation == "Add member to group."

#Detect Microsoft Graph activity
MicrosoftGraphActivityLogs
| where UserAgent contains "PowerShell"
| where RequestUri has_all("
https://graph.microsoft.com/v1.0/groups/","/members/$ref")
| extend GroupObjectId = tostring(extract(@"groups/(.*?)/members", 1, RequestUri))

Conclusion

There’s a few things we want to highlight as we come to the conclusion of this blog. First is that most of the evidence generated by GraphRunner modules is only detectable in the MicrosoftGraphActivityLogs which is a very noisy log and as such tuning will be required to make sure you get value out of the queries and indicators. Second is the fact that some modules will not generate logs at all, because some activities are made outside of the Graph API such as the Tenant ID lookup or modules using legacy APIs that do not log. Lastly we want to show you in one overview which modules generate log entries and in which log source. We hope to see you for the next part and if you have any feedback, please let us know.

List of Indicators

Below are the indicators of compromise and the log source you need for detection. 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!