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

Event coverage

Rules detecting the same action

Other rules on this platform that filter on the same API call or operation.

Rule body kusto

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 binding: redeem

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

Derived from invite.

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

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)

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

Stage 7: union

invite
| union

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

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

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
AADOperationTypein
  • Assign
  • AssignEligibleRole
ActivityDisplayNamematch
  • Add eligible member to role
  • Add member to role
Categoryeq
  • RoleManagement
  • UserManagement
Initiatorne
  • MS-PIM transforms: cased
  • MS-PIM-Fairfax transforms: cased
InviteInitiatoris_not_null
  • (no value, null check)
OperationNameeq
  • Invite external user
  • Redeem external user invite
Resulteq
  • success
RoleNamematch
  • Admin
  • Administrator
Targetis_not_null
  • (no value, null check)
deltale
  • 60 transforms: cased
displayName_eq
  • Role.DisplayName
userPrincipalNamematch
  • EXT transforms: term

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
Initiatorsummarize
OperationNamesummarize
Resultsummarize
RoleNamesummarize
Targetsummarize
TimeGeneratedsummarize