Detection rules › Kusto

Service Principal Assigned App Role With Sensitive Access

Severity
medium
Time window
1d
Group by
AddedPermissions, CorrelationId, InitiatedBy, InitiatingAadUserId, InitiatingAppName, InitiatingAppServicePrincipalId, InitiatingIPAddress, InitiatingUserPrincipalName, ServicePrincipalDisplayName, ServicePrincipalObjectID
Author
Microsoft Security Research
Source
github.com/Azure/Azure-Sentinel

Detects a Service Principal being assigned an app role that has sensitive access such as Mail.Read. A threat actor who compromises a Service Principal may assign it an app role to allow it to access sensitive data, or to perform other actions. Ensure that any assignment to a Service Principal is valid and appropriate. Ref: https://docs.microsoft.com/azure/active-directory/fundamentals/security-operations-applications#application-granted-highly-privileged-permissions

MITRE ATT&CK coverage

TacticTechniques
Privilege EscalationT1078.004 Valid Accounts: Cloud Accounts

Event coverage

Rule body kusto

id: dd78a122-d377-415a-afe9-f22e08d2112c
name: Service Principal Assigned App Role With Sensitive Access
description: |
  'Detects a Service Principal being assigned an app role that has sensitive access such as Mail.Read.
    A threat actor who compromises a Service Principal may assign it an app role to allow it to access sensitive data, or to perform other actions.
    Ensure that any assignment to a Service Principal is valid and appropriate.
    Ref: https://docs.microsoft.com/azure/active-directory/fundamentals/security-operations-applications#application-granted-highly-privileged-permissions'
severity: Medium
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AuditLogs
queryFrequency: 1d
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - PrivilegeEscalation
relevantTechniques:
  - T1078.004
tags:
  - AADSecOpsGuide
query: |
    // Add other permissions to this list as needed
    let permissions = dynamic([".All", "ReadWrite", "Mail.", "offline_access", "Files.Read", "Notes.Read", "ChannelMessage.Read", "Chat.Read", "TeamsActivity.Read",
    "Group.Read", "EWS.AccessAsUser.All", "EAS.AccessAsUser.All"]);
    let auditList = 
    AuditLogs
    | where OperationName =~ "Add app role assignment to service principal"
    | mv-expand TargetResources[0].modifiedProperties
    | extend TargetResources_0_modifiedProperties = column_ifexists("TargetResources_0_modifiedProperties", '')
    | where isnotempty(TargetResources_0_modifiedProperties)
    ;
    let detailsList = auditList
    | where TargetResources_0_modifiedProperties.displayName =~ "AppRole.Value" or TargetResources_0_modifiedProperties.displayName =~ "DelegatedPermissionGrant.Scope"
    | extend Permissions = split((parse_json(tostring(TargetResources_0_modifiedProperties.newValue))), " ")
    | where Permissions has_any (permissions)
    | summarize AddedPermissions=make_set(Permissions,200) by CorrelationId
    | join kind=inner auditList on CorrelationId
    | extend InitiatingAppName = tostring(InitiatedBy.app.displayName)
    | extend InitiatingAppServicePrincipalId = tostring(InitiatedBy.app.servicePrincipalId)
    | extend InitiatingUserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
    | extend InitiatingAadUserId = tostring(InitiatedBy.user.id)
    | extend InitiatingIPAddress = tostring(InitiatedBy.user.ipAddress)
    | extend InitiatedBy = tostring(iff(isnotempty(InitiatingUserPrincipalName),InitiatingUserPrincipalName, InitiatingAppName))
    | extend displayName = tostring(TargetResources_0_modifiedProperties.displayName), newValue = tostring(parse_json(tostring(TargetResources_0_modifiedProperties.newValue)))
    | where displayName == "ServicePrincipal.ObjectID" or displayName == "ServicePrincipal.DisplayName"
    | extend displayName = case(displayName == "ServicePrincipal.ObjectID", "ServicePrincipalObjectID", displayName == "ServicePrincipal.DisplayName", "ServicePrincipalDisplayName", displayName)
    | project TimeGenerated, CorrelationId, Id, AddedPermissions = tostring(AddedPermissions), InitiatingAadUserId, InitiatingAppName, InitiatingAppServicePrincipalId, InitiatingIPAddress, InitiatingUserPrincipalName, InitiatedBy, displayName, newValue
    ;
    detailsList | project Id, displayName, newValue
    | evaluate pivot(displayName, make_set(newValue))
    | join kind=inner detailsList on Id
    | extend ServicePrincipalObjectID = todynamic(column_ifexists("ServicePrincipalObjectID", "")), ServicePrincipalDisplayName = todynamic(column_ifexists("ServicePrincipalDisplayName", ""))
    | mv-expand ServicePrincipalObjectID, ServicePrincipalDisplayName
    | project-away Id1, displayName, newValue
    | extend ServicePrincipalObjectID = tostring(ServicePrincipalObjectID), ServicePrincipalDisplayName = tostring(ServicePrincipalDisplayName)
    | summarize FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated), EventIds = make_set(Id,200) by CorrelationId, AddedPermissions, InitiatingAadUserId, InitiatingAppName, InitiatingAppServicePrincipalId, InitiatingIPAddress, InitiatingUserPrincipalName, InitiatedBy, ServicePrincipalDisplayName, ServicePrincipalObjectID
    | extend InitiatingAccountName = tostring(split(InitiatingUserPrincipalName, "@")[0]), InitiatingAccountUPNSuffix = tostring(split(InitiatingUserPrincipalName, "@")[1])
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: InitiatingUserPrincipalName
      - identifier: Name
        columnName: InitiatingAccountName
      - identifier: UPNSuffix
        columnName: InitiatingAccountUPNSuffix
  - entityType: Account
    fieldMappings:
      - identifier: AadUserId
        columnName: InitiatingAadUserId
  - entityType: Account
    fieldMappings:
      - identifier: AadUserId
        columnName: InitiatingAppServicePrincipalId
      - identifier: ObjectGuid
        columnName: ServicePrincipalObjectID
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: InitiatingIPAddress
version: 2.0.0
kind: Scheduled
metadata:
    source:
        kind: Community
    author:
        name: Microsoft Security Research
    support:
        tier: Community
    categories:
        domains: [ "Security - Others", "Identity" ]

Stages and Predicates

Let binding: permissions

let permissions = dynamic([".All", "ReadWrite", "Mail.", "offline_access", "Files.Read", "Notes.Read", "ChannelMessage.Read", "Chat.Read", "TeamsActivity.Read",
"Group.Read", "EWS.AccessAsUser.All", "EAS.AccessAsUser.All"]);

The stages below define let detailsList (the rule's main pipeline source).

Stage 1: source

AuditLogs

Stage 2: where

| where OperationName =~ "Add app role assignment to service principal"

Stage 3: mv-expand

| mv-expand TargetResources[0].modifiedProperties

Stage 4: extend

| extend TargetResources_0_modifiedProperties = column_ifexists("TargetResources_0_modifiedProperties", '')

Stage 5: where

| where isnotempty(TargetResources_0_modifiedProperties)

Stage 6: where

| where TargetResources_0_modifiedProperties.displayName =~ "AppRole.Value" or TargetResources_0_modifiedProperties.displayName =~ "DelegatedPermissionGrant.Scope"

Stage 7: extend

| extend Permissions = split((parse_json(tostring(TargetResources_0_modifiedProperties.newValue))), " ")

Stage 8: where

| where Permissions has_any (permissions)

References permissions (defined above).

Stage 9: summarize

| summarize AddedPermissions=make_set(Permissions,200) by CorrelationId

Stage 10: join

| join kind=inner auditList on CorrelationId

Stage 11: extend (7 consecutive steps)

| extend InitiatingAppName = tostring(InitiatedBy.app.displayName)
| extend InitiatingAppServicePrincipalId = tostring(InitiatedBy.app.servicePrincipalId)
| extend InitiatingUserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
| extend InitiatingAadUserId = tostring(InitiatedBy.user.id)
| extend InitiatingIPAddress = tostring(InitiatedBy.user.ipAddress)
| extend InitiatedBy = tostring(iff(isnotempty(InitiatingUserPrincipalName),InitiatingUserPrincipalName, InitiatingAppName))
| extend displayName = tostring(TargetResources_0_modifiedProperties.displayName), newValue = tostring(parse_json(tostring(TargetResources_0_modifiedProperties.newValue)))

Stage 12: where

| where displayName == "ServicePrincipal.ObjectID" or displayName == "ServicePrincipal.DisplayName"

Stage 13: extend

| extend displayName = case(displayName == "ServicePrincipal.ObjectID", "ServicePrincipalObjectID", displayName == "ServicePrincipal.DisplayName", "ServicePrincipalDisplayName", displayName)
displayName =
ifdisplayName == "ServicePrincipal.ObjectID""ServicePrincipalObjectID"
elifdisplayName == "ServicePrincipal.DisplayName""ServicePrincipalDisplayName"
elsedisplayName

Stage 14: project

| project TimeGenerated, CorrelationId, Id, AddedPermissions = tostring(AddedPermissions), InitiatingAadUserId, InitiatingAppName, InitiatingAppServicePrincipalId, InitiatingIPAddress, InitiatingUserPrincipalName, InitiatedBy, displayName, newValue

The stages below run on detailsList (the outer pipeline).

Stage 15: project

detailsList
| project Id, displayName, newValue

Stage 16: evaluate

| evaluate pivot(displayName, make_set(newValue))

Stage 17: join

| join kind=inner detailsList on Id

Stage 18: extend

| extend ServicePrincipalObjectID = todynamic(column_ifexists("ServicePrincipalObjectID", "")), ServicePrincipalDisplayName = todynamic(column_ifexists("ServicePrincipalDisplayName", ""))

Stage 19: mv-expand

| mv-expand ServicePrincipalObjectID, ServicePrincipalDisplayName

Stage 20: project-away

| project-away Id1, displayName, newValue

Stage 21: extend

| extend ServicePrincipalObjectID = tostring(ServicePrincipalObjectID), ServicePrincipalDisplayName = tostring(ServicePrincipalDisplayName)

Stage 22: summarize

| summarize FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated), EventIds = make_set(Id,200) by CorrelationId, AddedPermissions, InitiatingAadUserId, InitiatingAppName, InitiatingAppServicePrincipalId, InitiatingIPAddress, InitiatingUserPrincipalName, InitiatedBy, ServicePrincipalDisplayName, ServicePrincipalObjectID

Stage 23: extend

| extend InitiatingAccountName = tostring(split(InitiatingUserPrincipalName, "@")[0]), InitiatingAccountUPNSuffix = tostring(split(InitiatingUserPrincipalName, "@")[1])

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
OperationNameeq
  • Add app role assignment to service principal
Permissionsmatch
  • .All
  • ChannelMessage.Read
  • Chat.Read
  • EAS.AccessAsUser.All
  • EWS.AccessAsUser.All
  • Files.Read
  • Group.Read
  • Mail.
  • Notes.Read
  • ReadWrite
  • TeamsActivity.Read
  • offline_access
TargetResources_0_modifiedPropertiesis_not_null
  • (no value, null check)
displayNameeq
  • AppRole.Value
  • DelegatedPermissionGrant.Scope
  • ServicePrincipal.DisplayName transforms: cased
  • ServicePrincipal.ObjectID transforms: cased

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
AddedPermissionssummarize
CorrelationIdsummarize
EventIdssummarize
FirstSeensummarize
InitiatedBysummarize
InitiatingAadUserIdsummarize
InitiatingAppNamesummarize
InitiatingAppServicePrincipalIdsummarize
InitiatingIPAddresssummarize
InitiatingUserPrincipalNamesummarize
LastSeensummarize
ServicePrincipalDisplayNamesummarize
ServicePrincipalObjectIDsummarize
InitiatingAccountNameextend
InitiatingAccountUPNSuffixextend