Detection rules › Kusto
Suspicious application consent similar to PwnAuth
This will alert when a user consents to provide a previously-unknown Azure application with the same OAuth permissions used by the FireEye PwnAuth toolkit (https://github.com/fireeye/PwnAuth). The default permissions/scope for the PwnAuth toolkit are user.read, offline_access, mail.readwrite, mail.send, and files.read.all. Consent to applications with these permissions 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
| Tactic | Techniques |
|---|---|
| Credential Access | T1528 Steal Application Access Token |
| Lateral Movement | T1550 Use Alternate Authentication Material |
Event coverage
Rules detecting the same action
Other rules on this platform that filter on the same API call or operation.
- Admin promotion after Role Management Application Permission Grant (Kusto)
- Application ID URI Changed (Kusto)
- Application Redirect URL Update (Kusto)
- Changes to Application Logout URL (Kusto)
- Changes to Application Ownership (Kusto)
- full_access_as_app Granted To Application (Kusto)
- Mail.Read Permissions Granted to Application (Kusto)
- Microsoft Entra ID Role Management Permission Grant (Kusto)
Rule body kusto
id: 39198934-62a0-4781-8416-a81265c03fd6
name: Suspicious application consent similar to PwnAuth
description: |
'This will alert when a user consents to provide a previously-unknown Azure application with the same OAuth permissions used by the FireEye PwnAuth toolkit (https://github.com/fireeye/PwnAuth).
The default permissions/scope for the PwnAuth toolkit are user.read, offline_access, mail.readwrite, mail.send, and files.read.all.
Consent to applications with these permissions 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: Medium
requiredDataConnectors:
- connectorId: AzureActiveDirectory
dataTypes:
- AuditLogs
queryFrequency: 1d
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
status: Available
tactics:
- CredentialAccess
- DefenseEvasion
relevantTechniques:
- T1528
- T1550
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 AppDisplayName = tostring(TargetResource.displayName),
AppClientId = tostring(TargetResource.id),
props = TargetResource.modifiedProperties
)
| 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 ConsentFull = props on
(
where ConsentFull.displayName =~ "ConsentAction.Permissions"
)
| parse ConsentFull with * "ConsentType: " GrantConsentType ", Scope: " GrantScope1 "]" *
| where ConsentFull has_all ("user.read", "offline_access", "mail.readwrite", "mail.send", "files.read.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)
| mv-apply AdditionalDetail = AdditionalDetails on
(
where AdditionalDetail.key =~ "User-Agent"
| extend GrantUserAgent = AdditionalDetail.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 props = TargetResource.modifiedProperties,
AppClientId = tostring(TargetResource.id)
)
| mv-apply Property = props on
(
where Property.displayName =~ "AppAddress" and Property.newValue has "AddressType"
| extend AppReplyURLs = trim('"',tostring(Property.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 timestamp = TimeGenerated, 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 AppDisplayName = tostring(TargetResource.displayName),
AppClientId = tostring(TargetResource.id),
props = TargetResource.modifiedProperties
)
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 ConsentFull = props on
(
where ConsentFull.displayName =~ "ConsentAction.Permissions"
)
Stage 10: parse
| parse ConsentFull with * "ConsentType: " GrantConsentType ", Scope: " GrantScope1 "]" *
Stage 11: where
| where ConsentFull has_all ("user.read", "offline_access", "mail.readwrite", "mail.send", "files.read.all")
Stage 12: where
| where GrantConsentType != "AllPrincipals"
Stage 13: extend (6 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)
Stage 14: kusto:mv-apply
| mv-apply AdditionalDetail = AdditionalDetails on
(
where AdditionalDetail.key =~ "User-Agent"
| extend GrantUserAgent = AdditionalDetail.value
)
Stage 15: project
| project TimeGenerated, GrantConsentType, GrantScope1, GrantInitiatedBy, AppDisplayName, GrantInitiatedByUserPrincipalName, GrantInitiatedByAadUserId, GrantInitiatedByAppName, GrantInitiatedByAppServicePrincipalId, GrantIpAddress, GrantUserAgent, AppClientId, OperationName, ConsentFull, CorrelationId
Stage 16: 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 props = TargetResource.modifiedProperties,
AppClientId = tostring(TargetResource.id)
)
| mv-apply Property = props on
(
where Property.displayName =~ "AppAddress" and Property.newValue has "AddressType"
| extend AppReplyURLs = trim('"',tostring(Property.newValue))
)
| distinct AppClientId, tostring(AppReplyURLs)
)
on AppClientId
Stage 17: 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 18: project
| project TimeGenerated, GrantConsentType, GrantScope1, GrantInitiatedBy, AppDisplayName, AppReplyURLs, GrantInitiatedByUserPrincipalName, GrantInitiatedByAadUserId, GrantInitiatedByAppName, GrantInitiatedByAppServicePrincipalId, GrantIpAddress, GrantUserAgent, AppClientId, GrantAuthentication, OperationName, GrantOperation, CorrelationId, ConsentFull
Stage 19: extend
| extend timestamp = TimeGenerated, 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.
| Field | Kind | Values |
|---|---|---|
Category | eq |
|
ConsentFull | match |
|
GrantConsentType | ne |
|
LoggedByService | eq |
|
OperationName | eq |
|
TargetResources | match |
|
displayName | eq |
|
displayName | is_not_null | |
key | eq |
|
modifiedProperties | gt |
|
newValue | match |
|
type | eq |
|
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.
| Field | Source |
|---|---|
AppClientId | project |
AppDisplayName | project |
AppReplyURLs | project |
ConsentFull | project |
CorrelationId | project |
GrantAuthentication | project |
GrantConsentType | project |
GrantInitiatedBy | project |
GrantInitiatedByAadUserId | project |
GrantInitiatedByAppName | project |
GrantInitiatedByAppServicePrincipalId | project |
GrantInitiatedByUserPrincipalName | project |
GrantIpAddress | project |
GrantOperation | project |
GrantScope1 | project |
GrantUserAgent | project |
OperationName | project |
TimeGenerated | project |
Name | extend |
UPNSuffix | extend |
timestamp | extend |