<# .SYNOPSIS azureblog.co.uk Attack Path Explorer: collects the Microsoft Entra data needed to find every route an attacker could take to Global Administrator. .DESCRIPTION Reads, with Microsoft Graph: - Entra role definitions, active role assignments and PIM-eligible role assignments - The users, groups and service principals that hold those roles - Owners and members of every group that holds a role - Every app with Microsoft Graph application permissions, its owners and whether it has secrets or certificates - Whether each of those users has registered MFA (needs Entra ID P1 and AuditLog.Read.All; skipped with a warning if not available) Read-only. Nothing in the tenant is changed. It saves a JSON file that you load into https://azureblog.co.uk/tools/attack-paths/ . The file is read in your browser and never uploaded. It contains names, sign-in names and app names, so treat it as sensitive and delete it when done. .REQUIREMENTS PowerShell 7+ (pwsh). Install-Module Microsoft.Graph.Authentication -Scope CurrentUser Graph consent: Directory.Read.All, RoleManagement.Read.Directory, Application.Read.All, AuditLog.Read.All (optional, for MFA registration) A Global Reader can run it. .EXAMPLE .\Get-AzureBlogAttackPaths.ps1 .\Get-AzureBlogAttackPaths.ps1 -TenantId -OutputFolder C:\Reports #> #Requires -Version 7.0 [CmdletBinding()] param( [string]$TenantId, [string]$OutputFolder = (Get-Location).Path, [switch]$SkipMfa ) $ErrorActionPreference = 'Stop' $warnings = [System.Collections.Generic.List[string]]::new() function Warn($m) { Write-Warning $m; $warnings.Add($m) } function Invoke-GraphAll([string]$Uri) { $out = [System.Collections.Generic.List[object]]::new() while ($Uri) { $r = Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject if ($r.value) { $out.AddRange([object[]]$r.value) } $Uri = $r.'@odata.nextLink' } , $out } function Get-Safe([string]$Uri) { try { Invoke-GraphAll $Uri } catch { Warn "Couldn't read $Uri : $($_.Exception.Message)"; , @() } } $scopes = @('Directory.Read.All', 'RoleManagement.Read.Directory', 'Application.Read.All') if (-not $SkipMfa) { $scopes += 'AuditLog.Read.All' } $connect = @{ Scopes = $scopes; NoWelcome = $true } if ($TenantId) { $connect.TenantId = $TenantId } Connect-MgGraph @connect $ctx = Get-MgContext $org = (Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/organization?$select=id,displayName' -OutputType PSObject).value | Select-Object -First 1 Write-Host "Connected to $($org.displayName) as $($ctx.Account)" -ForegroundColor Cyan $G = 'https://graph.microsoft.com/v1.0' $principals = @{} $groups = @{} $sps = @{} function Add-Principal($o) { if (-not $o -or -not $o.id -or $principals.ContainsKey($o.id)) { return } $t = switch -Wildcard ($o.'@odata.type') { '*user' { 'user' } '*group' { 'group' } '*servicePrincipal' { 'sp' } default { 'other' } } $principals[$o.id] = [ordered]@{ type = $t name = $o.displayName upn = $o.userPrincipalName guest = ($o.userType -eq 'Guest') synced = [bool]$o.onPremisesSyncEnabled enabled = if ($null -ne $o.accountEnabled) { [bool]$o.accountEnabled } else { $true } roleAssignable = [bool]$o.isAssignableToRole appId = $o.appId appOwnerOrgId = $o.appOwnerOrganizationId } } function Resolve-Ids([string[]]$Ids) { $Ids = $Ids | Where-Object { $_ -and -not $principals.ContainsKey($_) } | Select-Object -Unique for ($i = 0; $i -lt $Ids.Count; $i += 1000) { $chunk = $Ids[$i..([Math]::Min($i + 999, $Ids.Count - 1))] $body = @{ ids = @($chunk); types = @('user', 'group', 'servicePrincipal') } | ConvertTo-Json $r = Invoke-MgGraphRequest -Method POST -Uri "$G/directoryObjects/getByIds" -Body $body -ContentType 'application/json' -OutputType PSObject foreach ($o in $r.value) { Add-Principal $o } } } $userSel = 'id,displayName,userPrincipalName,userType,onPremisesSyncEnabled,accountEnabled' Write-Host 'Reading Entra roles...' $roleDefs = Invoke-GraphAll "$G/roleManagement/directory/roleDefinitions?`$select=id,displayName,templateId,isBuiltIn" $roles = foreach ($d in $roleDefs) { [ordered]@{ id = $d.id; name = $d.displayName; templateId = $d.templateId } } $assignments = [System.Collections.Generic.List[object]]::new() foreach ($a in (Invoke-GraphAll "$G/roleManagement/directory/roleAssignments?`$select=principalId,roleDefinitionId,directoryScopeId")) { $assignments.Add([ordered]@{ principalId = $a.principalId; roleId = $a.roleDefinitionId; kind = 'active'; scope = $a.directoryScopeId }) } foreach ($a in (Get-Safe "$G/roleManagement/directory/roleEligibilitySchedules?`$select=principalId,roleDefinitionId,directoryScopeId")) { $assignments.Add([ordered]@{ principalId = $a.principalId; roleId = $a.roleDefinitionId; kind = 'eligible'; scope = $a.directoryScopeId }) } Resolve-Ids ($assignments | ForEach-Object principalId) Write-Host 'Reading groups that hold roles...' foreach ($gid in @($principals.Keys | Where-Object { $principals[$_].type -eq 'group' })) { $owners = Get-Safe "$G/groups/$gid/owners?`$select=$userSel" $members = Get-Safe "$G/groups/$gid/transitiveMembers?`$select=$userSel" foreach ($o in @($owners) + @($members)) { Add-Principal $o } $groups[$gid] = [ordered]@{ owners = @($owners | ForEach-Object id); members = @($members | ForEach-Object id) } } Write-Host 'Reading apps with Microsoft Graph application permissions...' $graphSp = Invoke-MgGraphRequest -Method GET -Uri "$G/servicePrincipals(appId='00000003-0000-0000-c000-000000000000')?`$select=id,appRoles" -OutputType PSObject $roleName = @{}; foreach ($r in $graphSp.appRoles) { $roleName[$r.id] = $r.value } $grants = Invoke-GraphAll "$G/servicePrincipals/$($graphSp.id)/appRoleAssignedTo?`$select=principalId,appRoleId,principalType" $permsBySp = @{} foreach ($g in $grants) { if ($g.principalType -ne 'ServicePrincipal') { continue } if (-not $permsBySp.ContainsKey($g.principalId)) { $permsBySp[$g.principalId] = [System.Collections.Generic.List[string]]::new() } if ($roleName[$g.appRoleId]) { $permsBySp[$g.principalId].Add($roleName[$g.appRoleId]) } } $spIds = @($permsBySp.Keys) + @($principals.Keys | Where-Object { $principals[$_].type -eq 'sp' }) | Select-Object -Unique $n = 0 foreach ($id in $spIds) { $n++; Write-Progress -Activity 'Apps' -Status "$n of $($spIds.Count)" -PercentComplete ($n / [Math]::Max(1, $spIds.Count) * 100) try { $sp = Invoke-MgGraphRequest -Method GET -Uri "$G/servicePrincipals/$id`?`$select=id,appId,displayName,appOwnerOrganizationId,servicePrincipalType,passwordCredentials,keyCredentials,accountEnabled" -OutputType PSObject } catch { Warn "Couldn't read service principal $id"; continue } $sp | Add-Member -NotePropertyName '@odata.type' -NotePropertyValue '#microsoft.graph.servicePrincipal' -Force Add-Principal $sp $spOwners = Get-Safe "$G/servicePrincipals/$id/owners?`$select=$userSel" foreach ($o in $spOwners) { Add-Principal $o } $app = $null; $appOwners = @() if ($sp.appOwnerOrganizationId -eq $org.id) { try { $app = Invoke-MgGraphRequest -Method GET -Uri "$G/applications(appId='$($sp.appId)')?`$select=id,passwordCredentials,keyCredentials" -OutputType PSObject $appOwners = Get-Safe "$G/applications/$($app.id)/owners?`$select=$userSel" foreach ($o in $appOwners) { Add-Principal $o } } catch { } } $creds = @($sp.passwordCredentials).Count + @($sp.keyCredentials).Count if ($app) { $creds += @($app.passwordCredentials).Count + @($app.keyCredentials).Count } $sps[$id] = [ordered]@{ perms = @($permsBySp[$id] | Select-Object -Unique) owners = @($spOwners | ForEach-Object id) appOwners = @($appOwners | ForEach-Object id) appObjectId = if ($app) { $app.id } else { $null } credentials = $creds type = $sp.servicePrincipalType } } Write-Progress -Activity 'Apps' -Completed if (-not $SkipMfa) { Write-Host 'Reading MFA registration...' try { $reg = Invoke-GraphAll "$G/reports/authenticationMethods/userRegistrationDetails?`$select=id,isMfaRegistered" $mfa = @{}; foreach ($r in $reg) { $mfa[$r.id] = [bool]$r.isMfaRegistered } foreach ($id in @($principals.Keys)) { if ($principals[$id].type -eq 'user' -and $mfa.ContainsKey($id)) { $principals[$id].mfa = $mfa[$id] } } } catch { Warn "Couldn't read MFA registration (needs Entra ID P1 and AuditLog.Read.All): $($_.Exception.Message)" } } $result = [ordered]@{ tool = 'azureblog-attack-paths' version = 1 generated = (Get-Date).ToUniversalTime().ToString('o') tenant = [ordered]@{ id = $org.id; name = $org.displayName } roles = @($roles) assignments = @($assignments) principals = $principals groups = $groups sps = $sps warnings = @($warnings) } $file = Join-Path $OutputFolder ("azureblog-attack-paths-{0}.json" -f (Get-Date -Format 'yyyy-MM-dd')) $result | ConvertTo-Json -Depth 8 -Compress | Set-Content -Path $file -Encoding utf8 Write-Host "`nSaved $file" -ForegroundColor Green Write-Host "Load it at https://azureblog.co.uk/tools/attack-paths/ . It's read in your browser and never uploaded." if ($warnings.Count) { Write-Host "$($warnings.Count) warning(s). They're included in the file." -ForegroundColor Yellow }