Back
Id43df1e4c-61f8-4ab8-bc86-65eca7ecab9a
RulenameEnd-user consent to app with mailbox and offline access delegated scopes
DescriptionIdentifies 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.
SeverityHigh
TacticsCredentialAccess
TechniquesT1528
Required data connectorsAzureActiveDirectory
KindScheduled
Query frequency1d
Query period1d
Trigger threshold0
Trigger operatorgt
Source Urihttps://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Microsoft%20Entra%20ID/Analytic%20Rules/EndUserConsentMailboxOfflineAccess.yaml
Version1.0.0
Arm template43df1e4c-61f8-4ab8-bc86-65eca7ecab9a.json
Deploy To Azure
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"
    }
  ]
}