Detection rules › Kusto

Suspicious application consent for offline access

Status
available
Severity
low
Time window
14d
Source
github.com/Azure/Azure-Sentinel

This will alert when a user consents to provide a previously-unknown Azure application with offline access via OAuth. Offline access will provide the Azure App with access to the listed resources without requiring two-factor authentication. Consent to applications with offline access and read capabilities should be rare, especially as the knownApplications list is expanded. Public contributions to expand this filter are welcome! For further information on AuditLogs please see https://docs.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities.

MITRE ATT&CK coverage

TacticTechniques
Credential AccessT1528 Steal Application Access Token

Event coverage

Rules detecting the same action

Other rules on this platform that filter on the same API call or operation.

Rule body kusto

id: 3533f74c-9207-4047-96e2-0eb9383be587
name: Suspicious application consent for offline access
description: |
  'This will alert when a user consents to provide a previously-unknown Azure application with offline access via OAuth.
  Offline access will provide the Azure App with access to the listed resources without requiring two-factor authentication.
  Consent to applications with offline access and read capabilities should be rare, especially as the knownApplications list is expanded. Public contributions to expand this filter are welcome!
  For further information on AuditLogs please see https://docs.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities.'
severity: Low
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AuditLogs
queryFrequency: 1d
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
status: Available
tactics:
  - CredentialAccess
relevantTechniques:
  - T1528
query: |
  let detectionTime = 1d;
  let joinLookback = 14d;
  AuditLogs
  | where TimeGenerated > ago(detectionTime)
  | where LoggedByService =~ "Core Directory"
  | where Category =~ "ApplicationManagement"
  | where OperationName =~ "Consent to application"
  | where TargetResources has "offline"
  | mv-apply TargetResource=TargetResources on 
    (
        where TargetResource.type =~ "ServicePrincipal"
        | extend ModifiedProperties = TargetResource.modifiedProperties,
                 AppDisplayName = tostring(TargetResource.displayName),
                 AppClientId = tolower(tostring(TargetResource.id))
    )
  | where AppClientId !in ((externaldata(knownAppClientId:string, knownAppDisplayName:string)[@"https://raw.githubusercontent.com/Azure/Azure-Sentinel/master/Sample%20Data/Feeds/Microsoft.OAuth.KnownApplications.csv"] with (format="csv")))
  | mv-apply Properties=ModifiedProperties on 
    (
        where Properties.displayName =~ "ConsentAction.Permissions"
        | extend ConsentFull = tostring(Properties.newValue)
        | extend ConsentFull = trim(@'"',tostring(ConsentFull))
    )
  | parse ConsentFull with * "ConsentType: " GrantConsentType ", Scope: " GrantScope1 "]" *
  | where ConsentFull has "offline_access" and ConsentFull has_any ("Files.Read", "Mail.Read", "Notes.Read", "ChannelMessage.Read", "Chat.Read", "TeamsActivity.Read", "Group.Read", "EWS.AccessAsUser.All", "EAS.AccessAsUser.All")
  | where GrantConsentType != "AllPrincipals" // NOTE: we are ignoring if OAuth application was granted to all users via an admin - but admin due diligence should be audited occasionally
  | extend GrantInitiatedByAppName = tostring(InitiatedBy.app.displayName)
  | extend GrantInitiatedByAppServicePrincipalId = tostring(InitiatedBy.app.servicePrincipalId)
  | extend GrantInitiatedByUserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
  | extend GrantInitiatedByAadUserId = tostring(InitiatedBy.user.id)
  | extend GrantIpAddress = iff(isnotempty(InitiatedBy.user.ipAddress), tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress))
  | extend GrantInitiatedBy = iff(isnotempty(GrantInitiatedByUserPrincipalName), GrantInitiatedByUserPrincipalName, GrantInitiatedByAppName)
  | extend GrantUserAgent = tostring(iff(AdditionalDetails[0].key =~ "User-Agent", AdditionalDetails[0].value, ""))
  | project TimeGenerated, GrantConsentType, GrantScope1, GrantInitiatedBy, AppDisplayName, GrantInitiatedByUserPrincipalName, GrantInitiatedByAadUserId, GrantInitiatedByAppName, GrantInitiatedByAppServicePrincipalId, GrantIpAddress, GrantUserAgent, AppClientId, OperationName, ConsentFull, CorrelationId
  | join kind = leftouter (AuditLogs
  | where TimeGenerated > ago(joinLookback)
  | where LoggedByService =~ "Core Directory"
  | where Category =~ "ApplicationManagement"
  | where OperationName =~ "Add service principal"
  | mv-apply TargetResource=TargetResources on 
    (
        where TargetResource.type =~ "ServicePrincipal"
        | extend ModifiedProperties = TargetResource.modifiedProperties,
                 AppClientId = tolower(TargetResource.id)
    )
  | mv-apply ModifiedProperties=TargetResource.modifiedProperties on 
     (
        where ModifiedProperties.displayName =~ "AppAddress" and ModifiedProperties.newValue has "AddressType"
        | extend AppReplyURLs = ModifiedProperties.newValue
     )
   | distinct AppClientId, tostring(AppReplyURLs)
  )
  on AppClientId
  | join kind = innerunique (AuditLogs
  | where TimeGenerated > ago(joinLookback)
  | where LoggedByService =~ "Core Directory"
  | where Category =~ "ApplicationManagement"
  | where OperationName =~ "Add OAuth2PermissionGrant" or OperationName =~ "Add delegated permission grant"
   | mv-apply TargetResource=TargetResources on 
    (
        where TargetResource.type =~ "ServicePrincipal" and array_length(TargetResource.modifiedProperties) > 0 and isnotnull(TargetResource.displayName)
        | extend GrantAuthentication = tostring(TargetResource.displayName)
    )
  | extend GrantOperation = OperationName
  | project GrantAuthentication, GrantOperation, CorrelationId
  ) on CorrelationId
  | project TimeGenerated, GrantConsentType, GrantScope1, GrantInitiatedBy, AppDisplayName, AppReplyURLs, GrantInitiatedByUserPrincipalName, GrantInitiatedByAadUserId, GrantInitiatedByAppName, GrantInitiatedByAppServicePrincipalId, GrantIpAddress, GrantUserAgent, AppClientId, GrantAuthentication, OperationName, GrantOperation, CorrelationId, ConsentFull
  | extend Name = tostring(split(GrantInitiatedByUserPrincipalName,'@',0)[0]), UPNSuffix = tostring(split(GrantInitiatedByUserPrincipalName,'@',1)[0])
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: GrantInitiatedByUserPrincipalName
      - identifier: Name
        columnName: Name
      - identifier: UPNSuffix
        columnName: UPNSuffix
  - entityType: Account
    fieldMappings:
      - identifier: AadUserId
        columnName: GrantInitiatedByAadUserId
  - entityType: Account
    fieldMappings:
      - identifier: AadUserId
        columnName: GrantInitiatedByAppServicePrincipalId
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: GrantIpAddress
version: 1.0.2
kind: Scheduled

Stages and Predicates

Parameters

let detectionTime = 1d;
let joinLookback = 14d;

Stage 1: source

AuditLogs

Stage 2: where

| where TimeGenerated > ago(detectionTime)

Stage 3: where

| where LoggedByService =~ "Core Directory"

Stage 4: where

| where Category =~ "ApplicationManagement"

Stage 5: where

| where OperationName =~ "Consent to application"

Stage 6: where

| where TargetResources has "offline"

Stage 7: kusto:mv-apply

| mv-apply TargetResource=TargetResources on 
  (
      where TargetResource.type =~ "ServicePrincipal"
      | extend ModifiedProperties = TargetResource.modifiedProperties,
               AppDisplayName = tostring(TargetResource.displayName),
               AppClientId = tolower(tostring(TargetResource.id))
  )

Stage 8: where

| where AppClientId !in ((externaldata(knownAppClientId:string, knownAppDisplayName:string)[@"https://raw.githubusercontent.com/Azure/Azure-Sentinel/master/Sample%20Data/Feeds/Microsoft.OAuth.KnownApplications.csv"] with (format="csv")))

Stage 9: kusto:mv-apply

| mv-apply Properties=ModifiedProperties on 
  (
      where Properties.displayName =~ "ConsentAction.Permissions"
      | extend ConsentFull = tostring(Properties.newValue)
      | extend ConsentFull = trim(@'"',tostring(ConsentFull))
  )

Stage 10: parse

| parse ConsentFull with * "ConsentType: " GrantConsentType ", Scope: " GrantScope1 "]" *

Stage 11: where

| where ConsentFull has "offline_access" and ConsentFull has_any ("Files.Read", "Mail.Read", "Notes.Read", "ChannelMessage.Read", "Chat.Read", "TeamsActivity.Read", "Group.Read", "EWS.AccessAsUser.All", "EAS.AccessAsUser.All")

Stage 12: where

| where GrantConsentType != "AllPrincipals"

Stage 13: extend (7 consecutive steps)

| extend GrantInitiatedByAppName = tostring(InitiatedBy.app.displayName)
| extend GrantInitiatedByAppServicePrincipalId = tostring(InitiatedBy.app.servicePrincipalId)
| extend GrantInitiatedByUserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
| extend GrantInitiatedByAadUserId = tostring(InitiatedBy.user.id)
| extend GrantIpAddress = iff(isnotempty(InitiatedBy.user.ipAddress), tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress))
| extend GrantInitiatedBy = iff(isnotempty(GrantInitiatedByUserPrincipalName), GrantInitiatedByUserPrincipalName, GrantInitiatedByAppName)
| extend GrantUserAgent = tostring(iff(AdditionalDetails[0].key =~ "User-Agent", AdditionalDetails[0].value, ""))

Stage 14: project

| project TimeGenerated, GrantConsentType, GrantScope1, GrantInitiatedBy, AppDisplayName, GrantInitiatedByUserPrincipalName, GrantInitiatedByAadUserId, GrantInitiatedByAppName, GrantInitiatedByAppServicePrincipalId, GrantIpAddress, GrantUserAgent, AppClientId, OperationName, ConsentFull, CorrelationId

Stage 15: join

| join kind = leftouter (AuditLogs
| where TimeGenerated > ago(joinLookback)
| where LoggedByService =~ "Core Directory"
| where Category =~ "ApplicationManagement"
| where OperationName =~ "Add service principal"
| mv-apply TargetResource=TargetResources on 
  (
      where TargetResource.type =~ "ServicePrincipal"
      | extend ModifiedProperties = TargetResource.modifiedProperties,
               AppClientId = tolower(TargetResource.id)
  )
| mv-apply ModifiedProperties=TargetResource.modifiedProperties on 
   (
      where ModifiedProperties.displayName =~ "AppAddress" and ModifiedProperties.newValue has "AddressType"
      | extend AppReplyURLs = ModifiedProperties.newValue
   )
 | distinct AppClientId, tostring(AppReplyURLs)
)
on AppClientId

Stage 16: join

| join kind = innerunique (AuditLogs
| where TimeGenerated > ago(joinLookback)
| where LoggedByService =~ "Core Directory"
| where Category =~ "ApplicationManagement"
| where OperationName =~ "Add OAuth2PermissionGrant" or OperationName =~ "Add delegated permission grant"
 | mv-apply TargetResource=TargetResources on 
  (
      where TargetResource.type =~ "ServicePrincipal" and array_length(TargetResource.modifiedProperties) > 0 and isnotnull(TargetResource.displayName)
      | extend GrantAuthentication = tostring(TargetResource.displayName)
  )
| extend GrantOperation = OperationName
| project GrantAuthentication, GrantOperation, CorrelationId
) on CorrelationId

Stage 17: project

| project TimeGenerated, GrantConsentType, GrantScope1, GrantInitiatedBy, AppDisplayName, AppReplyURLs, GrantInitiatedByUserPrincipalName, GrantInitiatedByAadUserId, GrantInitiatedByAppName, GrantInitiatedByAppServicePrincipalId, GrantIpAddress, GrantUserAgent, AppClientId, GrantAuthentication, OperationName, GrantOperation, CorrelationId, ConsentFull

Stage 18: extend

| extend Name = tostring(split(GrantInitiatedByUserPrincipalName,'@',0)[0]), UPNSuffix = tostring(split(GrantInitiatedByUserPrincipalName,'@',1)[0])

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
Categoryeq
  • ApplicationManagement
ConsentFullmatch
  • ChannelMessage.Read
  • Chat.Read
  • EAS.AccessAsUser.All
  • EWS.AccessAsUser.All
  • Files.Read
  • Group.Read
  • Mail.Read
  • Notes.Read
  • TeamsActivity.Read
  • offline_access transforms: term
GrantConsentTypene
  • AllPrincipals transforms: cased
LoggedByServiceeq
  • Core Directory
OperationNameeq
  • Add OAuth2PermissionGrant
  • Add delegated permission grant
  • Add service principal
  • Consent to application
TargetResourcesmatch
  • offline transforms: term
displayNameeq
  • AppAddress
  • ConsentAction.Permissions
displayNameis_not_null
  • (no value, null check)
modifiedPropertiesgt
  • 0 transforms: array_length
newValuematch
  • AddressType transforms: term
typeeq
  • ServicePrincipal

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
AppClientIdproject
AppDisplayNameproject
AppReplyURLsproject
ConsentFullproject
CorrelationIdproject
GrantAuthenticationproject
GrantConsentTypeproject
GrantInitiatedByproject
GrantInitiatedByAadUserIdproject
GrantInitiatedByAppNameproject
GrantInitiatedByAppServicePrincipalIdproject
GrantInitiatedByUserPrincipalNameproject
GrantIpAddressproject
GrantOperationproject
GrantScope1project
GrantUserAgentproject
OperationNameproject
TimeGeneratedproject
Nameextend
UPNSuffixextend