Analytic rule catalog
End-user consent to app with mailbox and offline access delegated scopes
Back
| Id | 43df1e4c-61f8-4ab8-bc86-65eca7ecab9a |
| Rulename | End-user consent to app with mailbox and offline access delegated scopes |
| Description | Identifies an illicit consent grant where a non-admin user consents to an application requesting high-risk delegated mailbox permissions (Mail.Read, Mail.ReadWrite, Mail.Send or MailboxSettings.ReadWrite) together with offline_access. offline_access returns a long-lived refresh token, so a single successful end-user consent gives an attacker-controlled application durable, silent read/send access to the victim mailbox without re-prompting for MFA. This is a common precursor to business email compromise (BEC), mailbox exfiltration and internal phishing. This rule complements “Suspicious application consent for offline access”: it drops the known-application allowlist join and instead pins the high-fidelity scope combination, and it additionally covers mailbox write/send scopes (Mail.ReadWrite, Mail.Send, MailboxSettings.ReadWrite), not only read. For AuditLogs schema see https://learn.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities. |
| Severity | High |
| Tactics | CredentialAccess |
| Techniques | T1528 |
| Required data connectors | AzureActiveDirectory |
| Kind | Scheduled |
| Query frequency | 1d |
| Query period | 1d |
| Trigger threshold | 0 |
| Trigger operator | gt |
| Source Uri | https://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Microsoft%20Entra%20ID/Analytic%20Rules/EndUserConsentMailboxOfflineAccess.yaml |
| Version | 1.0.0 |
| Arm template | 43df1e4c-61f8-4ab8-bc86-65eca7ecab9a.json |
let detectionWindow = 1d;
AuditLogs
| where TimeGenerated > ago(detectionWindow)
| where LoggedByService =~ "Core Directory"
| where Category =~ "ApplicationManagement"
| where OperationName =~ "Consent to application"
| where TargetResources has "offline_access"
| mv-apply TargetResource = TargetResources on
(
where TargetResource.type =~ "ServicePrincipal"
| extend AppDisplayName = tostring(TargetResource.displayName),
ServicePrincipalId = tolower(tostring(TargetResource.id)),
ModifiedProperties = TargetResource.modifiedProperties
)
| mv-apply Property = ModifiedProperties on
(
where Property.displayName =~ "ConsentAction.Permissions"
| extend ConsentFull = trim(@'"', tostring(Property.newValue))
)
| where ConsentFull has "offline_access"
| where ConsentFull has_any ("Mail.Read", "Mail.ReadWrite", "Mail.Send", "MailboxSettings.ReadWrite")
| parse ConsentFull with * "ConsentType: " GrantConsentType ", Scope: " GrantScope "]" *
// Exact scope membership. has/has_any above is a cheap term-indexed pre-filter, but
// term matching tokenizes "Mail.Read" to mail/read and would over-match scopes like
// Mail.ReadBasic. Split the parsed scope string and require the exact scope names.
| extend GrantScopes = split(GrantScope, " ")
| where set_has_element(GrantScopes, "offline_access")
| where set_has_element(GrantScopes, "Mail.Read")
or set_has_element(GrantScopes, "Mail.ReadWrite")
or set_has_element(GrantScopes, "Mail.Send")
or set_has_element(GrantScopes, "MailboxSettings.ReadWrite")
// End-user (delegated) consent only. Tenant-wide admin consent for all users
// surfaces as ConsentType "AllPrincipals" and is scoped out here on purpose.
| where GrantConsentType != "AllPrincipals"
| extend GrantInitiatedByUserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
| extend GrantInitiatedByAadUserId = tostring(InitiatedBy.user.id)
| extend GrantIpAddress = iff(isnotempty(tostring(InitiatedBy.user.ipAddress)), tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress))
// AuditLogs does not guarantee the order of AdditionalDetails, so AdditionalDetails[0] silently
// misses the user agent whenever another key comes first. User agent is enrichment here and must
// never drop a consent event, so the array is padded before expanding it and the key is looked
// up by name in a property bag.
| extend AdditionalDetailPairs = iff(array_length(AdditionalDetails) > 0, AdditionalDetails, pack_array(bag_pack("key", "None", "value", "")))
| mv-apply AdditionalDetail = AdditionalDetailPairs on (summarize AdditionalDetailsBag = make_bag(bag_pack(tolower(tostring(AdditionalDetail.key)), tostring(AdditionalDetail.value))))
| extend GrantUserAgent = tostring(AdditionalDetailsBag["user-agent"])
| extend Name = tostring(split(GrantInitiatedByUserPrincipalName, '@')[0]), UPNSuffix = tostring(split(GrantInitiatedByUserPrincipalName, '@')[1])
| project TimeGenerated, GrantConsentType, GrantScope, GrantInitiatedByUserPrincipalName, GrantInitiatedByAadUserId, AppDisplayName, ServicePrincipalId, GrantIpAddress, GrantUserAgent, OperationName, ConsentFull, CorrelationId, Name, UPNSuffix
tactics:
- CredentialAccess
name: End-user consent to app with mailbox and offline access delegated scopes
version: 1.0.0
queryFrequency: 1d
requiredDataConnectors:
- connectorId: AzureActiveDirectory
dataTypes:
- AuditLogs
id: 43df1e4c-61f8-4ab8-bc86-65eca7ecab9a
triggerThreshold: 0
entityMappings:
- fieldMappings:
- identifier: Name
columnName: Name
- identifier: UPNSuffix
columnName: UPNSuffix
- identifier: AadUserId
columnName: GrantInitiatedByAadUserId
entityType: Account
- fieldMappings:
- identifier: Name
columnName: AppDisplayName
entityType: CloudApplication
- fieldMappings:
- identifier: Address
columnName: GrantIpAddress
entityType: IP
queryPeriod: 1d
severity: High
relevantTechniques:
- T1528
description: |
Identifies an illicit consent grant where a non-admin user consents to an
application requesting high-risk delegated mailbox permissions
(Mail.Read, Mail.ReadWrite, Mail.Send or MailboxSettings.ReadWrite) together
with offline_access. offline_access returns a long-lived refresh token, so a
single successful end-user consent gives an attacker-controlled application
durable, silent read/send access to the victim mailbox without re-prompting
for MFA. This is a common precursor to business email compromise (BEC),
mailbox exfiltration and internal phishing. This rule complements
"Suspicious application consent for offline access": it drops the
known-application allowlist join and instead pins the high-fidelity scope
combination, and it additionally covers mailbox write/send scopes
(Mail.ReadWrite, Mail.Send, MailboxSettings.ReadWrite), not only read.
For AuditLogs schema see https://learn.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities.
triggerOperator: gt
status: Available
OriginalUri: https://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Microsoft%20Entra%20ID/Analytic%20Rules/EndUserConsentMailboxOfflineAccess.yaml
kind: Scheduled
query: |
let detectionWindow = 1d;
AuditLogs
| where TimeGenerated > ago(detectionWindow)
| where LoggedByService =~ "Core Directory"
| where Category =~ "ApplicationManagement"
| where OperationName =~ "Consent to application"
| where TargetResources has "offline_access"
| mv-apply TargetResource = TargetResources on
(
where TargetResource.type =~ "ServicePrincipal"
| extend AppDisplayName = tostring(TargetResource.displayName),
ServicePrincipalId = tolower(tostring(TargetResource.id)),
ModifiedProperties = TargetResource.modifiedProperties
)
| mv-apply Property = ModifiedProperties on
(
where Property.displayName =~ "ConsentAction.Permissions"
| extend ConsentFull = trim(@'"', tostring(Property.newValue))
)
| where ConsentFull has "offline_access"
| where ConsentFull has_any ("Mail.Read", "Mail.ReadWrite", "Mail.Send", "MailboxSettings.ReadWrite")
| parse ConsentFull with * "ConsentType: " GrantConsentType ", Scope: " GrantScope "]" *
// Exact scope membership. has/has_any above is a cheap term-indexed pre-filter, but
// term matching tokenizes "Mail.Read" to mail/read and would over-match scopes like
// Mail.ReadBasic. Split the parsed scope string and require the exact scope names.
| extend GrantScopes = split(GrantScope, " ")
| where set_has_element(GrantScopes, "offline_access")
| where set_has_element(GrantScopes, "Mail.Read")
or set_has_element(GrantScopes, "Mail.ReadWrite")
or set_has_element(GrantScopes, "Mail.Send")
or set_has_element(GrantScopes, "MailboxSettings.ReadWrite")
// End-user (delegated) consent only. Tenant-wide admin consent for all users
// surfaces as ConsentType "AllPrincipals" and is scoped out here on purpose.
| where GrantConsentType != "AllPrincipals"
| extend GrantInitiatedByUserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
| extend GrantInitiatedByAadUserId = tostring(InitiatedBy.user.id)
| extend GrantIpAddress = iff(isnotempty(tostring(InitiatedBy.user.ipAddress)), tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress))
// AuditLogs does not guarantee the order of AdditionalDetails, so AdditionalDetails[0] silently
// misses the user agent whenever another key comes first. User agent is enrichment here and must
// never drop a consent event, so the array is padded before expanding it and the key is looked
// up by name in a property bag.
| extend AdditionalDetailPairs = iff(array_length(AdditionalDetails) > 0, AdditionalDetails, pack_array(bag_pack("key", "None", "value", "")))
| mv-apply AdditionalDetail = AdditionalDetailPairs on (summarize AdditionalDetailsBag = make_bag(bag_pack(tolower(tostring(AdditionalDetail.key)), tostring(AdditionalDetail.value))))
| extend GrantUserAgent = tostring(AdditionalDetailsBag["user-agent"])
| extend Name = tostring(split(GrantInitiatedByUserPrincipalName, '@')[0]), UPNSuffix = tostring(split(GrantInitiatedByUserPrincipalName, '@')[1])
| project TimeGenerated, GrantConsentType, GrantScope, GrantInitiatedByUserPrincipalName, GrantInitiatedByAadUserId, AppDisplayName, ServicePrincipalId, GrantIpAddress, GrantUserAgent, OperationName, ConsentFull, CorrelationId, Name, UPNSuffix
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"workspace": {
"type": "String"
}
},
"resources": [
{
"apiVersion": "2024-01-01-preview",
"id": "[concat(resourceId('Microsoft.OperationalInsights/workspaces/providers', parameters('workspace'), 'Microsoft.SecurityInsights'),'/alertRules/43df1e4c-61f8-4ab8-bc86-65eca7ecab9a')]",
"kind": "Scheduled",
"name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/43df1e4c-61f8-4ab8-bc86-65eca7ecab9a')]",
"properties": {
"alertRuleTemplateName": "43df1e4c-61f8-4ab8-bc86-65eca7ecab9a",
"customDetails": null,
"description": "Identifies an illicit consent grant where a non-admin user consents to an\napplication requesting high-risk delegated mailbox permissions\n(Mail.Read, Mail.ReadWrite, Mail.Send or MailboxSettings.ReadWrite) together\nwith offline_access. offline_access returns a long-lived refresh token, so a\nsingle successful end-user consent gives an attacker-controlled application\ndurable, silent read/send access to the victim mailbox without re-prompting\nfor MFA. This is a common precursor to business email compromise (BEC),\nmailbox exfiltration and internal phishing. This rule complements\n\"Suspicious application consent for offline access\": it drops the\nknown-application allowlist join and instead pins the high-fidelity scope\ncombination, and it additionally covers mailbox write/send scopes\n(Mail.ReadWrite, Mail.Send, MailboxSettings.ReadWrite), not only read.\nFor AuditLogs schema see https://learn.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities.\n",
"displayName": "End-user consent to app with mailbox and offline access delegated scopes",
"enabled": true,
"entityMappings": [
{
"entityType": "Account",
"fieldMappings": [
{
"columnName": "Name",
"identifier": "Name"
},
{
"columnName": "UPNSuffix",
"identifier": "UPNSuffix"
},
{
"columnName": "GrantInitiatedByAadUserId",
"identifier": "AadUserId"
}
]
},
{
"entityType": "CloudApplication",
"fieldMappings": [
{
"columnName": "AppDisplayName",
"identifier": "Name"
}
]
},
{
"entityType": "IP",
"fieldMappings": [
{
"columnName": "GrantIpAddress",
"identifier": "Address"
}
]
}
],
"OriginalUri": "https://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Microsoft%20Entra%20ID/Analytic%20Rules/EndUserConsentMailboxOfflineAccess.yaml",
"query": "let detectionWindow = 1d;\nAuditLogs\n| where TimeGenerated > ago(detectionWindow)\n| where LoggedByService =~ \"Core Directory\"\n| where Category =~ \"ApplicationManagement\"\n| where OperationName =~ \"Consent to application\"\n| where TargetResources has \"offline_access\"\n| mv-apply TargetResource = TargetResources on\n (\n where TargetResource.type =~ \"ServicePrincipal\"\n | extend AppDisplayName = tostring(TargetResource.displayName),\n ServicePrincipalId = tolower(tostring(TargetResource.id)),\n ModifiedProperties = TargetResource.modifiedProperties\n )\n| mv-apply Property = ModifiedProperties on\n (\n where Property.displayName =~ \"ConsentAction.Permissions\"\n | extend ConsentFull = trim(@'\"', tostring(Property.newValue))\n )\n| where ConsentFull has \"offline_access\"\n| where ConsentFull has_any (\"Mail.Read\", \"Mail.ReadWrite\", \"Mail.Send\", \"MailboxSettings.ReadWrite\")\n| parse ConsentFull with * \"ConsentType: \" GrantConsentType \", Scope: \" GrantScope \"]\" *\n// Exact scope membership. has/has_any above is a cheap term-indexed pre-filter, but\n// term matching tokenizes \"Mail.Read\" to mail/read and would over-match scopes like\n// Mail.ReadBasic. Split the parsed scope string and require the exact scope names.\n| extend GrantScopes = split(GrantScope, \" \")\n| where set_has_element(GrantScopes, \"offline_access\")\n| where set_has_element(GrantScopes, \"Mail.Read\")\n or set_has_element(GrantScopes, \"Mail.ReadWrite\")\n or set_has_element(GrantScopes, \"Mail.Send\")\n or set_has_element(GrantScopes, \"MailboxSettings.ReadWrite\")\n// End-user (delegated) consent only. Tenant-wide admin consent for all users\n// surfaces as ConsentType \"AllPrincipals\" and is scoped out here on purpose.\n| where GrantConsentType != \"AllPrincipals\"\n| extend GrantInitiatedByUserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)\n| extend GrantInitiatedByAadUserId = tostring(InitiatedBy.user.id)\n| extend GrantIpAddress = iff(isnotempty(tostring(InitiatedBy.user.ipAddress)), tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress))\n// AuditLogs does not guarantee the order of AdditionalDetails, so AdditionalDetails[0] silently\n// misses the user agent whenever another key comes first. User agent is enrichment here and must\n// never drop a consent event, so the array is padded before expanding it and the key is looked\n// up by name in a property bag.\n| extend AdditionalDetailPairs = iff(array_length(AdditionalDetails) > 0, AdditionalDetails, pack_array(bag_pack(\"key\", \"None\", \"value\", \"\")))\n| mv-apply AdditionalDetail = AdditionalDetailPairs on (summarize AdditionalDetailsBag = make_bag(bag_pack(tolower(tostring(AdditionalDetail.key)), tostring(AdditionalDetail.value))))\n| extend GrantUserAgent = tostring(AdditionalDetailsBag[\"user-agent\"])\n| extend Name = tostring(split(GrantInitiatedByUserPrincipalName, '@')[0]), UPNSuffix = tostring(split(GrantInitiatedByUserPrincipalName, '@')[1])\n| project TimeGenerated, GrantConsentType, GrantScope, GrantInitiatedByUserPrincipalName, GrantInitiatedByAadUserId, AppDisplayName, ServicePrincipalId, GrantIpAddress, GrantUserAgent, OperationName, ConsentFull, CorrelationId, Name, UPNSuffix\n",
"queryFrequency": "P1D",
"queryPeriod": "P1D",
"severity": "High",
"status": "Available",
"subTechniques": [],
"suppressionDuration": "PT1H",
"suppressionEnabled": false,
"tactics": [
"CredentialAccess"
],
"techniques": [
"T1528"
],
"templateVersion": "1.0.0",
"triggerOperator": "GreaterThan",
"triggerThreshold": 0
},
"type": "Microsoft.OperationalInsights/workspaces/providers/alertRules"
}
]
}