Adi Malyanker | Security Researcher

Key findings

  • An Application Consent attack, also known as an Illicit Consent Grant attack, is a type of phishing attack in which a malicious actor gains access to an application and then exploits permissions that have been granted to that app.
  • Semperis researcher Adi Malyanker has discovered that under certain circumstances, a malicious actor could use the Directory.ReadWrite.All permission or the DelegatedPermissionGrant.ReadWrite.All permission within Microsoft Azure to escalate privileges, access sensitive resources and data, launch a Denial of Service (DoS) attack—or even take over an Entra ID tenant.
  • Any organization that uses applications in Entra ID could be vulnerable to this attack, dubbed a Hidden Consent Grant attack.
  • To Semperis’ knowledge, no such attack has been documented.
  • Semperis researchers rate this vulnerability as potentially SEVERE due to the possibility of an Entra ID tenant takeover.

Within Microsoft Azure, the Directory.ReadWrite.All permission holds significant implications. This permission enables a multitude of actions, including user editing and access to all data within the directory.

Sound risky? Some have argued that when employed in isolation, the permission poses no inherent risk. However, my research indicates that an attacker could use the Directory.ReadWrite.All permission (or DelegatedPermissionGrant.ReadWrite.All permission) to cause severe damage in a Microsoft Entra ID (formerly known as Azure AD) or hybrid Microsoft Active Directory/Entra ID environment.

What are application permissions?

In Entra ID, application permissions grant a specific application the ability to perform certain actions or access certain resources within Entra ID or other Microsoft services. Unlike delegated permissions, which users grant to authorized applications, application permissions are typically granted by an administrator during the application registration process.

The application then uses these permissions to perform tasks on behalf of the organization or its users, without requiring individual or ongoing user consent (Figure 1).

Figure 1. Delegated permissions versus application permissions

Application permissions are often used when the application needs to access resources or perform actions that do not depend on a specific user’s identity or permissions. Examples of application permissions include:

  • Reading user profiles
  • Managing groups
  • Accessing organizational data

Carefully managing and assigning application permissions is essential. Organizations should ensure that applications have the appropriate level of access while maintaining security and compliance standards within the Entra ID environment.

What is Directory.ReadWrite.All?

While researching application permissions, I encountered the Directory.ReadWrite.All permission. According to Microsoft’s documentation:

Directory.ReadWrite.All grants access that is broadly equivalent to a global tenant admin. Apps that are granted Directory.ReadWrite.All can manage the full range of directory resources, and they can manage authorization for other apps and users to access resources across the organization. This includes directory resources like users, groups, applications, and devices, and nondirectory resources in Exchange, SharePoint, Teams, and other services.”

Given this permission’s name, association with Entra ID, and implication in directory management, I suspected that the permission was potentially hazardous.

But at first, after examining the actions that Directory.ReadWrite.All permits, I could not find any noteworthy threats. Others seem to have drawn a similar conclusion. For example, an excellent article published by SpectreOps claimed that this permission, though powerful when combined with other permissions, was unlikely to pose a significant risk on its own.

Still, I decided to take a closer look.

I began testing what the Directory.ReadWrite.All permission could do at a high level. What could a malicious actor accomplish using only this application permission?
The good news is that I was unable to use the permission to do the following:

  • Add secrets to applications
  • Add owners
  • Add members or owners with a role to a security group
  • Grant an app role
  • Reset a user’s password

Those API calls returned a response like the one that Figure 2 shows.

Figure 2. Response to an unsuccessful API call

However, I was able to use Directory.ReadWrite.All to do the following:

  • Add a new member or owner without any assigned role to a security group
  • Add users to the directory
  • Edit admin consents
  • Limited edits to administrative units

Note: More than 400 API calls are dedicated to Teams, devices, and OneDrive. Those calls are beyond the scope of this research.

Actions such as adding new users and members, who have no assigned role, to groups could be quite useful to attackers attempting to gain initial access to a directory or resources. That possibility is concerning enough, but I wondered whether more was possible.

Could I use the Directory.ReadWrite.All permission to turn myself into a Global Administrator?

Can Directory.ReadWrite.All lead to a potential attack vector?

Before we get to the fun stuff, a note: The access token that helps you log in to Azure contains a special field called scope, or scp. This field notes the permissions that are granted to the user (Figure 3).

Figure 3. The scp field

I checked this value throughout my research to determine whether my attempts to elevate permissions were successful.

To test my suspicions about Directory.ReadWrite.All, I chose a Microsoft Graph API, oauth2PermissionGrant, that can create a delegated permission grant on a resource for a chosen entity (Figure 4).

Figure 4: oauth2PermissionGrant API call to grant a delegated permission

To make this API call, I needed to grant an application the Directory.ReadWrite.All permission (Figure 5). I decided on postman-test (Figure 6).

Figure 5. Choosing an application with the Directory.ReadWrite.All permission
Figure 6: Granting the Directory.ReadWrite.All permission to postman-test

I also created a weak user account, Evil User (Figure 7), to represent an attacker (that simulates a malicious user who is a member of the tenant and a legitimate user obtained by an attacker). I did not grant this user any roles or permissions (Figure 8).

Figure 7: Creating user Evil User
Figure 8: Evil User has no assigned roles

To invoke the API calls, I connected the postman-test service principle with Postman. Let’s review the API’s properties and requirements (Figure 9):

  • clientId identifies the application (in this instance, Graph Explorer) that is authorized to act on behalf of a signed-in user. The clientId value acts as a substitute for the user’s identity.
  • consentType indicates whether authorization is granted for the client application to impersonate all users or only a specific user.
  • resourceId specifies the resource (in this instance, Microsoft Graph) that the clientId can access with the granted permissions.
  • scope defines the specific actions (e.g., device.Read.All, Director.ReadWrite.All) that the clientId can perform on the resourceId.
Figure 9. API properties and requirements

To summarize the relationships between these properties: One or more users grants the clientId an approved set of permissions, as defined by the scope. The clientId can then use those permissions to access the specified resourceId.

Now, back to my API call exploration. First, I needed the Graph Explorer object ID. I could either navigate in the apps portal in Azure or use the Graph API (Figure 10) to get this ID.

Figure 10: Getting the object ID

To start, I used Graph Explorer as both the clientId and resourceId.

As Evil User, I tried to read all the groups in the directory. As you can see from the 403 response in Figure 11, this action was not allowed because of insufficient privileges.

Figure 11. Trying (and failing) to read all groups in the directory

Next, I tried to add a delegated permission—GroupMember.Read.All—to Graph Explorer for the entire directory (Figure 12). If I succeeded, every user in the directory (including Evil User) should be granted this permission.

Figure 12. Adding a delegated permission

I verified that the addition of this permission was successful. Note that in the Graph Explorer app, the permission showed as granted for the entire directory (Figure 13).

Figure 13. Verifying privilege addition

Now, I should be able to use the API call to get all the groups (Figure 14).

Figure 14. Attempting to retrieve groups

Weird…Graph Explorer had the GroupMember.Read.All permission with admin consent, but I’m still getting a 403 response. Why?

Remember the definition of delegated permissions? Because Graph Explorer used the signed-in user’s permissions, it could read members only if the signed-in user had the correct role or privilege level to use that permission, which Evil User did not. My attempt was a dead end.

But wait. Are you familiar with the Illicit Consent Grant attack?

What is an Illicit Consent Grant attack?

In simple terms, an Illicit Consent Grant attack, also known as an Application Consent attack, occurs when someone is tricked into granting a third-party (i.e., external) application excessive rights to their data or the configuration of their Office 365 applications. Figure 15 illustrates how this attack works.

Figure 15. Illicit Consent Grant

In short, an attacker creates an Azure-registered application. Then:

  1. The application requests access to sensitive data such as contact information, emails, or documents.
  2. The end user grants the application consent. Consent can be obtained through phishing attacks or by injecting illicit code into a trusted website.
  3. The attacker’s app receives consent approval and an access token.
  4. The malicious app—and the attacker—now have account-level access to the user’s data.

What would happen if an attacker launched an Illicit Consent Grant attack, using a malicious app that had been granted the Directory.ReadWrite.All permission? It was time to retry my experiment.

Meet the Hidden Consent Grant attack

This time, I acted as an attacker attempting to compromise a user in the target directory through an app with the Directory.ReadWrite.All permission. The app would start with low privileges and then escalate its permissions after being granted admin consent (Figure 16).

Figure 16. Attack flow

This attack flow would be possible using one app, but I decided to use two apps for added stealth (by separating between the app with Directory.ReadWrite.All permission and the app which its permissions will be updated). For this experiment:

  1. I created an account for a user with limited privileges: Harmless Attacker (harmless_attacker). This experiment assumes that an attacker has already gained access to this account through a previous phishing, other attack or membership in the tenant.
  2. I created a privileged user account, Privileged User (privileged_user), to represent a global admin.
  3. I used Harmless Attacker to create an app with Directory.ReadWrite.All permission (in this case, postman-test, which I used to grant delegated permissions to 365stealer).
  4. I used Harmless Attacker to create another app, called 365stealer. This app has the settings shown in Figure 17. Note that the app’s redirect URI leads to an attacker-controlled endpoint.
Figure 17. 365stealer app settings

Harmless Attacker could not add privileged permissions to the 365stealer app during creation, so at first, the app permissions looked like those shown in Figure 18.

Figure 18. Initial 365stealer permissions

Next, I used an attacker-controlled endpoint to set up an HTTPS server, listening on port 443 (Figure 19).

Figure 19. HTTPS server setup

Then, I used Harmless Attacker to construct a phishing page (Figure 20). In a real-life scenario, Harmless Attacker would attempt to trick Privileged User into accessing this page through an email or a malicious file.

Figure 20. Phishing page

The phishing page contained a hyperlink to a redirected Microsoft login page, where Privileged User will be encouraged to log in to the malicious app (365stealer). According to the redirect_uri part, the tokens will be sent to the HTTPS server that the attacker deployed (Figure 21).

Figure 21. Redirecting the user to the malicious HTTPS server

After a successful login, the user will be presented with a consent challenge (Figure 22).

Figure 22. Consent challenge

Nothing about these permissions or consent challenge appears harmful (no sensitive permissions are required), so the user will accept the consent. In our case, they will then be presented with a page like the one shown in Figure 23, close their browser, and go on with their day.

Figure 23. Nothing to see here!

Note: Even though the page is marked as insecure, there are ways to bypass that. However, that is beyond the scope of this article. You can also see the unverified note in the consent. I’ll address ways to hide that later in the article.

Meanwhile, unbeknownst to the user, the attacker will receive the access token and use it to call the /me API (Figure 24).

Figure 24. Access token

This access token contains the permissions that the user agreed to in the consent challenge. A look at the token’s scp field confirms this (Figure 25).

Figure 25. Decoded access token

So, now the attacker has an access token (and a refresh token) of a privileged user. But this token can be used only with the app and delegated permissions listed in the scope. The attacker still lacks the RoleManagement.ReadWrite.Directory permission, so any attempt to call the directoryRoles API (through a request to https://graph.microsoft.com/v1.0/directoryRoles) will return an Access Denied response (Figure 26).

Figure 26. Access Denied

Again, it seems that the attacker’s potential to cause harm is very limited. How could an attacker escalate privileges and call other interesting APIs?

Here’s where Directory.ReadWrite.All comes in.

Could I take the oauth2PermissionGrant API from my original experiment, which requires only the Directory.ReadWrite.All permission, and use it to grant the delegated RoleManagement.ReadWrite.Directory permission to the 365stealer app?

To find out, I first use the oauth2PermissionGrant API to enumerate the clientId and resourceId used for 365stealer (Figure 27).

Figure 27. Enumerating 365stealer clientId and resourceId

Next, I set the consentType to “AllPrincipals” (Figure 28). This removes the requirement for admin consent when granting the permissions.

Figure 28. Setting consentType to “AllPrincipals”

Wait a moment. The RoleManagement.ReadWrite.Directory permission has been granted but is not included in the list of configured permissions (Figure 29).

Figure 29. Configured permissions

Aha! Harmless Attacker owns the 365stealer app. All I need to do is right-click RoleManagement.ReadWrite.Directory and select Add to configured permissions (Figure 30). Even without this step, the permission should still be available to me.

Figure 30. Adding RoleManagement.ReadWrite.Directory to configured permissions

The permission is now configured (Figure 31).

Figure 31. Verifying configuration

Now, I simply call the /refresh endpoint in the HTTP server to refresh the Privileged User account’s access token and update its permissions (Figure 32).

Figure 32. Refreshing the user’s access token

A look at the token’s scope verifies the addition of the RoleManagement.ReadWrite.Directory permission (Figure 33).

Figure 33. Verifying the addition of the RoleManagement.ReadWrite.Directory permission

With this permission in the access token, and I can now call GET https://graph.microsoft.com/v1.0/directoryRoles (Figure 34).

Figure 34. Enumerating directory roles

And now I can call POST https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments to assign Harmless Attacker the Global Admin role (Figure 35).

Figure 35. Assigning the Global Admin role to Harmless Attacker

Done! A look at the attacker account’s active assignments verifies the privilege escalation (Figure 36). My attempt at launching what I’ve named a Hidden Consent Grant was successful.

Figure 36. Verifying privilege escalation

Hidden Consent Grant: Now with added stealth!

Remember the consent challenge that the user was presented with, back in the early stages of the Hidden Consent Grant attack? I wondered if I could hide that challenge to make the attack even stealthier. Doing so would create a similar attack flow without requiring user action in the form of the victim’ passing any consent challenges, even when the app requests elevated permissions (Figure 37).

Figure 37. Attack flow without consent challenges

I started with the same permissions I used in the previous experiment. However, I granted the 365stealer app administrative consent (Figure 38). Remember that Directory.ReadWrite.All enables the attacker to do so without any user interaction.

Figure 38. 365stealer API permissions

Now to run the phishing flow and determine whether Privileged User is presented with a consent challenge when they click the link in the phishing page. (Spoiler alert: They are not.)

At this point, I’ve proved two points:

  • The phishing victim will not be presented with any consent challenge if the privileges required are already approved by the admin.
  • Even after the admin approves the consent challenge for adding the app’s permissions, the attacker can add more permissions by manually editing the permissions, without the victim knowing.

Spreading the Hidden Consent Grant

I wondered if I could turn all the apps in the directory to serve the phishing links. I knew that I could turn every app we owned into a phishing app by changing its redirect URL. But what about apps that do not meet this requirement?

In the rare case where an app also has the Application.ReadWrite.All application permission, the attacker can edit or add another redirect URL—the attacker’s listening server—to the app. This way, every app in the directory is turned into a malicious entity (Figure 39).

Figure 39. Required permissions to spread the attack in the tenant

The Graph API call shown in Figure 40 can help change the app’s redirect URI (Figure 41).

Figure 40. Graph API call to change the redirect URI
Figure 41. Redirect URIs

For this attack vector, the attacker still needs to know the app secret. Luckily for the attacker, Application.ReadWrite.All enables them to add a new secret to the app (Figure 42).

Figure 42. Adding a new secret

Note: There appears to be a discrepancy between Microsoft’s documentation and my own observations. Based on my testing, although Directory.ReadWrite.All is mentioned, additional permissions might be needed.

For this proof of concept, Semperis has built and published a new tool, HiddenConsentGrant, available here. This tool can be used to create a listening server, waiting for access token responses.

Detection and mitigations

How can you spot a Hidden Consent Grant attack?

  1. Users of Semperis Directory Services Protector (DSP) or Purple Knight will be able to use the Entra tenant is susceptible to Hidden Consent Grant Attack security indicator to check and report the possibility of a hidden consent grant attack on the tenant.
  2. Look for suspicious service principals with high privileges, such as Rolemanagement.ReadWrite.Directory, Application.ReadWrite.All, and AppRoleAssignment.ReadWrite.All. You can use the following API call to enumerate these permissions (Figure 43):
    GET graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=consentType eq %27AllPrincipals%27
    Figure 43. Enumerating privileges
  3. Determine whether an admin granted approval to the permissions.
  4. Verify that the permissions are actively used and required.
  5. Remove unnecessary privileges.
  6. In the audit logs, look for the Add delegated permission grant entry with the initiated actor Application (Figure 44).
    Figure 44. Reviewing the Add delegated permission grant log entry

    The Type value should be Application and the Activity Type should be Add delegated permission grant. You can use Graph API to get all the audit logs that have the Activity Type Add delegated permission grant:
    https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?$filter=activityDisplayName eq 'Add delegated permission grant'

    You can then search for “user”: null, which means that an application invoked the delegated permission grant (Figure 45).

    Figure 45. Searching for app-invoked delegated permission grants
  7. Monitor and revoke any unauthorized OAuth consent grants. The following API call can help:
    GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants/
    This call returns all the delegated permissions granted in the tenant, the type of consent, and the entity (Figure 46).
    Figure 46. Discovering unauthorized OAuth consent grants
  8. Lastly, avoid giving applications the Directory.ReadWrite.All or DelegatedPermissionGrant.ReadWrite.All as application permissions. (The DelegatedPermissionGrant.ReadWrite.All permission enables an attacker to launch the same attacks I describe in this article.) Whenever possible, configure more specific permissions for your apps.

Shine a light on Hidden Consent Grants

This article illustrates the proof of concept for several attack flows that abuse the Directory.ReadWrite.All role to potentially gain every permission an attacker might want. These attack flows could be deadly in the hands of a savvy attacker. Review and tighten your app permissions now and close this potential opening before threat actors can take advantage of it.