Detection rules › Kusto

Suspicious application consent for offline access

Status
available
Severity
low
Time window
14d
Group by
AppClientId, CorrelationId
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 Access

Telemetry coverage

Rules detecting the same action

These rules filter on the same operation.

Rule body

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

These rows show field, operator, and value matches.

FieldKindValuesSearch
Categoryeq
  • ApplicationManagement
field:"Category" kind:eq value:"ApplicationManagement"
ConsentFullmatch
  • ChannelMessage.Read transforms: term
  • Chat.Read transforms: term
  • EAS.AccessAsUser.All transforms: term
  • EWS.AccessAsUser.All transforms: term
  • Files.Read transforms: term
  • Group.Read transforms: term
  • Mail.Read transforms: term
  • Notes.Read transforms: term
  • TeamsActivity.Read transforms: term
  • offline_access transforms: term
field:"ConsentFull" kind:match
GrantConsentTypene
  • AllPrincipals
field:"GrantConsentType" kind:ne value:"AllPrincipals"
LoggedByServiceeq
  • Core Directory
field:"azure_ad::logged_by_service" kind:eq value:"Core Directory"
OperationNameeq
  • Add OAuth2PermissionGrant
  • Add delegated permission grant
  • Add service principal
  • Consent to application
field:"OperationName" kind:eq
TargetResourcesmatch
  • offline transforms: term
field:"azure_ad::target_resources" kind:match value:"offline"
displayNameeq
  • AppAddress
  • ConsentAction.Permissions
field:"displayName" kind:eq
displayNameis_not_null
  • (no value, null check)
field:"displayName" kind:is_not_null
newValuematch
  • AddressType transforms: term
field:"azure_ad::modified_properties_new" kind:match value:"AddressType"
typeeq
  • ServicePrincipal
field:"type" kind:eq value:"ServicePrincipal"

Output fields

These fields are emitted when the rule matches.

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