Detection rules › Kusto

Service Principal Assigned App Role With Sensitive Access

Severity
medium
Time window
1d
Group by
AddedPermissions, CorrelationId, Id, 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 Escalation

Telemetry coverage

Rules detecting the same action

These rules filter on the same operation.

Rule body

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 auditList and let detailsList are inlined into the numbered stages below.

Let binding: permissions used in Stage 8

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"]);

Stages 1 to 14 define let detailsList (the rule's main pipeline source); stages 15 to 23 run on it.

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)

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

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

These rows show field, operator, and value matches.

FieldKindValuesSearch
OperationNameeq
  • Add app role assignment to service principal
field:"OperationName" kind:eq value:"Add app role assignment to service principal"
Permissionsmatch
  • .All transforms: term
  • 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. transforms: term
  • Notes.Read transforms: term
  • ReadWrite transforms: term
  • TeamsActivity.Read transforms: term
  • offline_access transforms: term
field:"Permissions" kind:match
TargetResources_0_modifiedPropertiesis_not_null
  • (no value, null check)
field:"TargetResources_0_modifiedProperties" kind:is_not_null
displayNameeq
  • AppRole.Value
  • DelegatedPermissionGrant.Scope
  • ServicePrincipal.DisplayName
  • ServicePrincipal.ObjectID
field:"displayName" kind:eq

Output fields

These fields are emitted when the rule matches.

FieldSource
AddedPermissionssummarize
CorrelationIdsummarize
EventIdssummarize
FirstSeensummarize
InitiatedBysummarize
InitiatingAadUserIdsummarize
InitiatingAppNamesummarize
InitiatingAppServicePrincipalIdsummarize
InitiatingIPAddresssummarize
InitiatingUserPrincipalNamesummarize
LastSeensummarize
ServicePrincipalDisplayNamesummarize
ServicePrincipalObjectIDsummarize
InitiatingAccountNameextend
InitiatingAccountUPNSuffixextend