Detection rules › Kusto
Rare application consent
This will alert when the "Consent to application" operation occurs by a user that has not done this operation before or rarely does this. This could indicate that permissions to access the listed Azure App were provided to a malicious actor. Consent to application, Add service principal and Add OAuth2PermissionGrant should typically be rare events. This may help detect the Oauth2 attack that can be initiated by this publicly available tool - https://github.com/fireeye/PwnAuth For further information on AuditLogs please see https://docs.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence | |
| Privilege Escalation |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| Azure | any: Entra ID audit event (any operation) |
Rules detecting the same action
These rules filter on the same operation.
- [Entra ID] Application Assigned Administrator Permissions Immediately After Obtaining Role Management Permissions (Kusto)
- [Entra ID] Application Granted Administrative Permission to Assign Microsoft Entra ID Roles (Kusto)
- Added Credentials to Existing Application (Sigma)
- Added Owner To Application (Sigma)
- Admin promotion after Role Management Application Permission Grant (Kusto)
- App Granted Microsoft Permissions (Sigma)
- App Granted Privileged Delegated Or App Permissions (Sigma)
- Application AppID Uri Configuration Changes (Sigma)
Rule body
id: 83ba3057-9ea3-4759-bf6a-933f2e5bc7ee
name: Rare application consent
description: |
'This will alert when the "Consent to application" operation occurs by a user that has not done this operation before or rarely does this.
This could indicate that permissions to access the listed Azure App were provided to a malicious actor.
Consent to application, Add service principal and Add OAuth2PermissionGrant should typically be rare events.
This may help detect the Oauth2 attack that can be initiated by this publicly available tool - https://github.com/fireeye/PwnAuth
For further information on AuditLogs please see https://docs.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities.'
severity: Medium
status: Available
requiredDataConnectors:
- connectorId: AzureActiveDirectory
dataTypes:
- AuditLogs
queryFrequency: 1d
queryPeriod: 7d
triggerOperator: gt
triggerThreshold: 3
tactics:
- Persistence
- PrivilegeEscalation
relevantTechniques:
- T1136
- T1068
query: |
let current = 1d;
let auditLookback = 7d;
// Setting threshold to 3 as a default, change as needed.
// Any operation that has been initiated by a user or app more than 3 times in the past 7 days will be excluded
let threshold = 3;
// Gather initial data from lookback period, excluding current, adjust current to more than a single day if no results
let AuditTrail = AuditLogs | where TimeGenerated >= ago(auditLookback) and TimeGenerated < ago(current)
// 2 other operations that can be part of malicious activity in this situation are
// "Add OAuth2PermissionGrant" and "Add service principal", extend the filter below to capture these too
| where OperationName has "Consent to application"
| extend InitiatedBy = iff(isnotempty(tostring(InitiatedBy.user.userPrincipalName)),
tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName))
| mv-apply TargetResource = TargetResources on
(
where TargetResource.type =~ "ServicePrincipal"
| extend TargetResourceName = tolower(tostring(TargetResource.displayName))
)
| summarize max(TimeGenerated), OperationCount = count() by OperationName, InitiatedBy, TargetResourceName
// only including operations initiated by a user or app that is above the threshold so we produce only rare and has not occurred in last 7 days
| where OperationCount > threshold;
// Gather current period of audit data
let RecentConsent = AuditLogs | where TimeGenerated >= ago(current)
| where OperationName has "Consent to application"
| extend IpAddress = case(
isnotempty(tostring(InitiatedBy.user.ipAddress)) and tostring(InitiatedBy.user.ipAddress) != 'null', tostring(InitiatedBy.user.ipAddress),
isnotempty(tostring(InitiatedBy.app.ipAddress)) and tostring(InitiatedBy.app.ipAddress) != 'null', tostring(InitiatedBy.app.ipAddress),
'Not Available')
| extend InitiatedBy = iff(isnotempty(tostring(InitiatedBy.user.userPrincipalName)),
tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName))
| mv-apply TargetResource = TargetResources on
(
where TargetResource.type =~ "ServicePrincipal"
| extend TargetResourceName = tolower(tostring(TargetResource.displayName)),
props = TargetResource.modifiedProperties
)
| parse props with * "ConsentType: " ConsentType "]" *
| mv-apply AdditionalDetail = AdditionalDetails on
(
where AdditionalDetail.key =~ "User-Agent"
| extend UserAgent = tostring(AdditionalDetail.value)
)
| project TimeGenerated, InitiatedBy, IpAddress, TargetResourceName, Category, OperationName, ConsentType, UserAgent, CorrelationId, Type;
// Exclude previously seen audit activity for "Consent to application" that was seen in the lookback period
// First for rare InitiatedBy
let RareConsentBy = RecentConsent | join kind= leftanti AuditTrail on OperationName, InitiatedBy
| extend Reason = "Previously unseen user consenting";
// Second for rare TargetResourceName
let RareConsentApp = RecentConsent | join kind= leftanti AuditTrail on OperationName, TargetResourceName
| extend Reason = "Previously unseen app granted consent";
RareConsentBy | union RareConsentApp
| summarize Reason = make_set(Reason,100) by TimeGenerated, InitiatedBy, IpAddress, TargetResourceName, Category, OperationName, ConsentType, UserAgent, CorrelationId, Type
| extend timestamp = TimeGenerated, Name = tolower(tostring(split(InitiatedBy,'@',0)[0])), UPNSuffix = tolower(tostring(split(InitiatedBy,'@',1)[0]))
entityMappings:
- entityType: Account
fieldMappings:
- identifier: FullName
columnName: InitiatedBy
- identifier: Name
columnName: Name
- identifier: UPNSuffix
columnName: UPNSuffix
- entityType: CloudApplication
fieldMappings:
- identifier: Name
columnName: TargetResourceName
- entityType: IP
fieldMappings:
- identifier: Address
columnName: IpAddress
version: 1.1.5
kind: Scheduled
Stages and Predicates
Parameters
let current = 1d;
let auditLookback = 7d;
let threshold = 3;
let RecentConsent, let RareConsentBy and let RareConsentApp are inlined into the numbered stages below.
Let binding: AuditTrail
let AuditTrail = AuditLogs | where TimeGenerated >= ago(auditLookback) and TimeGenerated < ago(current)
| where OperationName has "Consent to application"
| extend InitiatedBy = iff(isnotempty(tostring(InitiatedBy.user.userPrincipalName)),
tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName))
| mv-apply TargetResource = TargetResources on
(
where TargetResource.type =~ "ServicePrincipal"
| extend TargetResourceName = tolower(tostring(TargetResource.displayName))
)
| summarize max(TimeGenerated), OperationCount = count() by OperationName, InitiatedBy, TargetResourceName
| where OperationCount > threshold;
Stages 1 to 11 define let RareConsentBy (the rule's main pipeline source); stages 12 to 25 combine it with further branches.
Stage 1: source
AuditLogs
Stage 2: where
| where TimeGenerated >= ago(current)
Stage 3: where
| where OperationName has "Consent to application"
Stage 4: extend
| extend IpAddress = case(
isnotempty(tostring(InitiatedBy.user.ipAddress)) and tostring(InitiatedBy.user.ipAddress) != 'null', tostring(InitiatedBy.user.ipAddress),
isnotempty(tostring(InitiatedBy.app.ipAddress)) and tostring(InitiatedBy.app.ipAddress) != 'null', tostring(InitiatedBy.app.ipAddress),
'Not Available')
IpAddress =ipAddress != "null"tostring(InitiatedBy.user.ipAddress)ipAddress != "null"tostring(InitiatedBy.app.ipAddress)'Not Available'Stage 5: extend
| extend InitiatedBy = iff(isnotempty(tostring(InitiatedBy.user.userPrincipalName)),
tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName))
InitiatedBy =/* macro: isnotempty(tostring(InitiatedBy.user.userPrincipalName)) */tostring(InitiatedBy.user.userPrincipalName)tostring(InitiatedBy.app.displayName)Stage 6: kusto:mv-apply
| mv-apply TargetResource = TargetResources on
(
where TargetResource.type =~ "ServicePrincipal"
| extend TargetResourceName = tolower(tostring(TargetResource.displayName)),
props = TargetResource.modifiedProperties
)
Stage 7: parse
| parse props with * "ConsentType: " ConsentType "]" *
Stage 8: kusto:mv-apply
| mv-apply AdditionalDetail = AdditionalDetails on
(
where AdditionalDetail.key =~ "User-Agent"
| extend UserAgent = tostring(AdditionalDetail.value)
)
Stage 9: project
| project TimeGenerated, InitiatedBy, IpAddress, TargetResourceName, Category, OperationName, ConsentType, UserAgent, CorrelationId, Type
Stage 10: join (negated)
| join kind= leftanti AuditTrail on OperationName, InitiatedBy
Stage 11: extend
| extend Reason = "Previously unseen user consenting"
Stage 12: union
RareConsentBy
| union RareConsentApp
Stage 13: source
AuditLogs
Stage 14: where
| where TimeGenerated >= ago(current)
Stage 15: where
| where OperationName has "Consent to application"
Stage 16: extend
| extend IpAddress = case(
isnotempty(tostring(InitiatedBy.user.ipAddress)) and tostring(InitiatedBy.user.ipAddress) != 'null', tostring(InitiatedBy.user.ipAddress),
isnotempty(tostring(InitiatedBy.app.ipAddress)) and tostring(InitiatedBy.app.ipAddress) != 'null', tostring(InitiatedBy.app.ipAddress),
'Not Available')
IpAddress =ipAddress != "null"tostring(InitiatedBy.user.ipAddress)ipAddress != "null"tostring(InitiatedBy.app.ipAddress)'Not Available'Stage 17: extend
| extend InitiatedBy = iff(isnotempty(tostring(InitiatedBy.user.userPrincipalName)),
tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName))
InitiatedBy =/* macro: isnotempty(tostring(InitiatedBy.user.userPrincipalName)) */tostring(InitiatedBy.user.userPrincipalName)tostring(InitiatedBy.app.displayName)Stage 18: kusto:mv-apply
| mv-apply TargetResource = TargetResources on
(
where TargetResource.type =~ "ServicePrincipal"
| extend TargetResourceName = tolower(tostring(TargetResource.displayName)),
props = TargetResource.modifiedProperties
)
Stage 19: parse
| parse props with * "ConsentType: " ConsentType "]" *
Stage 20: kusto:mv-apply
| mv-apply AdditionalDetail = AdditionalDetails on
(
where AdditionalDetail.key =~ "User-Agent"
| extend UserAgent = tostring(AdditionalDetail.value)
)
Stage 21: project
| project TimeGenerated, InitiatedBy, IpAddress, TargetResourceName, Category, OperationName, ConsentType, UserAgent, CorrelationId, Type
Stage 22: join (negated)
| join kind= leftanti AuditTrail on OperationName, TargetResourceName
Stage 23: extend
| extend Reason = "Previously unseen app granted consent"
Stage 24: summarize
| summarize Reason = make_set(Reason,100) by TimeGenerated, InitiatedBy, IpAddress, TargetResourceName, Category, OperationName, ConsentType, UserAgent, CorrelationId, Type
Stage 25: extend
| extend timestamp = TimeGenerated, Name = tolower(tostring(split(InitiatedBy,'@',0)[0])), UPNSuffix = tolower(tostring(split(InitiatedBy,'@',1)[0]))
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
OperationCount | gt | 3 | excludes:OperationCount field:"OperationCount" value:"3" |
OperationName | match | Consent to application | excludes:OperationName field:"OperationName" value:"Consent to application" |
type | eq | ServicePrincipal | excludes:type field:"type" value:"ServicePrincipal" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
OperationName | match |
| field:"OperationName" kind:match value:"Consent to application" |
key | eq |
| field:"key" kind:eq value:"User-Agent" |
type | eq |
| field:"type" kind:eq value:"ServicePrincipal" |
Output fields
These fields are emitted when the rule matches.
| Field | Source |
|---|---|
Category | summarize |
ConsentType | summarize |
CorrelationId | summarize |
InitiatedBy | summarize |
IpAddress | summarize |
OperationName | summarize |
Reason | summarize |
TargetResourceName | summarize |
TimeGenerated | summarize |
Type | summarize |
UserAgent | summarize |
Name | extend |
UPNSuffix | extend |
timestamp | extend |