Detection rules › Kusto

New External User Granted Admin Role

Status
available
Severity
medium
Time window
1d
Group by
Initiator, OperationName, Result, RoleName, Target, TimeGenerated
Source
github.com/Azure/Azure-Sentinel

This query will detect instances where a newly invited external user is granted an administrative role. By default this query will alert on any granted administrative role, however this can be modified using the roles variable if false positives occur in your environment. The maximum delta between invite and escalation to admin is 60 minues, this can be configured using the deltaBetweenInviteEscalation variable.

MITRE ATT&CK coverage

Telemetry coverage

Rules detecting the same action

These rules filter on the same operation.

Rule body

id: d7424fd9-abb3-4ded-a723-eebe023aaa0b
name: New External User Granted Admin Role
description: |
  'This query will detect instances where a newly invited external user is granted an administrative role.
  By default this query will alert on any granted administrative role, however this can be modified using the roles variable if false positives occur in your environment. The maximum delta between invite and escalation to admin is 60 minues, this can be configured using the deltaBetweenInviteEscalation variable.'
severity: Medium
status: Available
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AuditLogs
queryFrequency: 1d
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - Persistence
relevantTechniques:
  - T1098.001
query: |
  // Administrative roles to look for, default is all admin roles
  let roles = dynamic(["Administrator", "Admin"]);
  // The maximum distances between and invite and acceptance
  let maxTimeBetweenInviteAccept = 30min;
  // The delta (minutes) between the invite being sent and the account being escalated
  let deltaBetweenInviteEscalation = 60;
  // Collect external user invitations
  let invite = AuditLogs
  | where Category =~ "UserManagement"
  | where OperationName =~ "Invite external user"
  | extend Target = tostring(TargetResources[0].["userPrincipalName"])
  | extend InviteInitiator = tostring(InitiatedBy.["user"].["userPrincipalName"])
  | where isnotempty(InviteInitiator);
  // Collect redeem events
  let redeem = AuditLogs
  | where Category =~ "UserManagement"
  | where OperationName =~ "Redeem external user invite"
  | where Result =~ "success"
  | extend Target = tostring(TargetResources[0].["displayName"]) | extend Target = tostring(extract(@"UPN\:\s(.+)\,\sEmail",1,Target))
  | where isnotempty(Target);
  // Union the inivtation and redeem data then run the sequence_detect kusto plugin
  invite
  | union redeem
  | order by TimeGenerated
  | project TimeGenerated, Target, InviteInitiator, OperationName, TenantId
  | evaluate sequence_detect(TimeGenerated, maxTimeBetweenInviteAccept, maxTimeBetweenInviteAccept, invite=(OperationName has "Invite external user"), redeem=(OperationName has "Redeem external user invite"), Target)
  | join kind=innerunique (
  AuditLogs
  | where Category =~ "RoleManagement"
  | where AADOperationType in~ ("Assign", "AssignEligibleRole")
  | where ActivityDisplayName has_any ("Add eligible member to role", "Add member to role")
  | mv-expand TargetResources
  // Limit to external accounts
  | where TargetResources.userPrincipalName has "EXT"
  | mv-expand TargetResources.modifiedProperties
  | extend displayName_ = tostring(TargetResources_modifiedProperties.displayName)
  | where displayName_ =~ "Role.DisplayName"
  | extend RoleName = tostring(parse_json(tostring(TargetResources_modifiedProperties.newValue)))
  // Perform check for admin roles
  | where RoleName has_any(roles)
  | extend InitiatingApp = tostring(parse_json(tostring(InitiatedBy.app)).displayName)
  | extend Initiator = iif(isnotempty(InitiatingApp), InitiatingApp, tostring(parse_json(tostring(InitiatedBy.user)).userPrincipalName))
  | where Initiator != "MS-PIM" and Initiator != "MS-PIM-Fairfax"
  | extend Target = tostring(TargetResources.userPrincipalName)
  | summarize by TimeGenerated, OperationName,  RoleName, Target, Initiator, Result
  ) on Target
  // Calculate delta between the invite and the account escalation
  | extend delta = datetime_diff("minute", TimeGenerated, invite_TimeGenerated)
  | where delta <= deltaBetweenInviteEscalation
  | project InvitationTime=invite_TimeGenerated, RedeemTime=redeem_TimeGenerated, GrantTime=TimeGenerated, ExternalUser=Target, RoleGranted=RoleName, AdminInitiator=Initiator, MinsBetweenInviteAndEscalation=delta
  | extend ExternalUserName = tostring(split(ExternalUser, '@', 0)[0]), ExternalUserUPNSuffix = tostring(split(ExternalUser, '@', 1)[0])
  | extend AdminInitiatorName = tostring(split(AdminInitiator, '@', 0)[0]), AdminInitiatorUPNSuffix = tostring(split(AdminInitiator, '@', 1)[0])
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: ExternalUserName
      - identifier: UPNSuffix
        columnName: ExternalUserUPNSuffix
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: AdminInitiatorName
      - identifier: UPNSuffix
        columnName: AdminInitiatorUPNSuffix
version: 1.0.4
kind: Scheduled

Stages and Predicates

Parameters

let roles = dynamic(["Administrator", "Admin"]);
let maxTimeBetweenInviteAccept = 30min;
let deltaBetweenInviteEscalation = 60;

let invite and let redeem are inlined into the numbered stages below.

Stages 1 to 6 define let invite (the rule's main pipeline source); stages 7 to 24 combine it with further branches.

Stage 1: source

AuditLogs

Stage 2: where

| where Category =~ "UserManagement"

Stage 3: where

| where OperationName =~ "Invite external user"

Stage 4: extend

| extend Target = tostring(TargetResources[0].["userPrincipalName"])

Stage 5: extend

| extend InviteInitiator = tostring(InitiatedBy.["user"].["userPrincipalName"])

Stage 6: where

| where isnotempty(InviteInitiator)

Stage 7: union

invite
| union redeem

Stage 8: source

AuditLogs

Stage 9: where

| where Category =~ "UserManagement"

Stage 10: where

| where OperationName =~ "Redeem external user invite"

Stage 11: where

| where Result =~ "success"

Stage 12: extend

| extend Target = tostring(TargetResources[0].["displayName"])

Stage 13: extend

| extend Target = tostring(extract(@"UPN\:\s(.+)\,\sEmail",1,Target))

Stage 14: where

| where isnotempty(Target)

Stage 15: sort

| order by TimeGenerated

Stage 16: project

| project TimeGenerated, Target, InviteInitiator, OperationName, TenantId

Stage 17: evaluate

| evaluate sequence_detect(TimeGenerated, maxTimeBetweenInviteAccept, maxTimeBetweenInviteAccept, invite=(OperationName has "Invite external user"), redeem=(OperationName has "Redeem external user invite"), Target)

Stage 18: join

| join kind=innerunique (
AuditLogs
| where Category =~ "RoleManagement"
| where AADOperationType in~ ("Assign", "AssignEligibleRole")
| where ActivityDisplayName has_any ("Add eligible member to role", "Add member to role")
| mv-expand TargetResources
| where TargetResources.userPrincipalName has "EXT"
| mv-expand TargetResources.modifiedProperties
| extend displayName_ = tostring(TargetResources_modifiedProperties.displayName)
| where displayName_ =~ "Role.DisplayName"
| extend RoleName = tostring(parse_json(tostring(TargetResources_modifiedProperties.newValue)))
| where RoleName has_any(roles)
| extend InitiatingApp = tostring(parse_json(tostring(InitiatedBy.app)).displayName)
| extend Initiator = iif(isnotempty(InitiatingApp), InitiatingApp, tostring(parse_json(tostring(InitiatedBy.user)).userPrincipalName))
| where Initiator != "MS-PIM" and Initiator != "MS-PIM-Fairfax"
| extend Target = tostring(TargetResources.userPrincipalName)
| summarize by TimeGenerated, OperationName,  RoleName, Target, Initiator, Result
) on Target

Stage 19: extend

| extend delta = datetime_diff("minute", TimeGenerated, invite_TimeGenerated)

Stage 20: where

| where delta <= deltaBetweenInviteEscalation

Stage 21: project

| project InvitationTime=invite_TimeGenerated, RedeemTime=redeem_TimeGenerated, GrantTime=TimeGenerated, ExternalUser=Target, RoleGranted=RoleName, AdminInitiator=Initiator, MinsBetweenInviteAndEscalation=delta

Stage 22: extend

| extend ExternalUserName = tostring(split(ExternalUser, '@', 0)[0]), ExternalUserUPNSuffix = tostring(split(ExternalUser, '@', 1)[0])

Stage 23: extend

| extend AdminInitiatorName = tostring(split(AdminInitiator, '@', 0)[0]), AdminInitiatorUPNSuffix = tostring(split(AdminInitiator, '@', 1)[0])

Stage 24: summarize aggregation inside the join branch

summarize by TimeGenerated, OperationName, RoleName, Target, Initiator, Result

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
AADOperationTypein
  • Assign
  • AssignEligibleRole
field:"AADOperationType" kind:in
ActivityDisplayNamematch
  • Add eligible member to role transforms: term
  • Add member to role transforms: term
field:"azure_ad::activity_display_name" kind:match
Categoryeq
  • RoleManagement
  • UserManagement
field:"Category" kind:eq
Initiatorne
  • MS-PIM
  • MS-PIM-Fairfax
field:"Initiator" kind:ne
InviteInitiatoris_not_null
  • (no value, null check)
field:"InviteInitiator" kind:is_not_null
OperationNameeq
  • Invite external user
  • Redeem external user invite
field:"OperationName" kind:eq
Resulteq
  • success
field:"Result" kind:eq value:"success"
RoleNamematch
  • Admin transforms: term
  • Administrator transforms: term
field:"RoleName" kind:match
Targetis_not_null
  • (no value, null check)
field:"Target" kind:is_not_null
deltale
  • 60
field:"delta" kind:le value:"60"
displayName_eq
  • Role.DisplayName
field:"displayName_" kind:eq value:"Role.DisplayName"
userPrincipalNamematch
  • EXT transforms: term
field:"userPrincipalName" kind:match value:"EXT"

Output fields

These fields are emitted when the rule matches.

FieldSource
Initiatorsummarize
OperationNamesummarize
Resultsummarize
RoleNamesummarize
Targetsummarize
TimeGeneratedsummarize