Detection rules › Kusto
New External User Granted Admin Role
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
| Tactic | Techniques |
|---|---|
| Persistence |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| Azure | Invite external user |
| Azure | Redeem external user invite |
Rules detecting the same action
These rules filter on the same operation.
- [Entra ID] Application Assigned Administrator Permissions Immediately After Obtaining Role Management Permissions (Kusto)
- [Entra ID] Authentication Method Changed for Privileged Account (Kusto)
- [Entra ID] Mass Privileged Role Change Activity Detected (Kusto)
- [Entra ID] Privilege Elevation Request Denied (Kusto)
- [Entra ID] Privileged Role Assigned to a New User (Kusto)
- [Entra ID] Privileged Role Assigned to User (Kusto)
- Account Created and Deleted in Short Timeframe (Kusto)
- Account Created And Deleted Within A Close Time Frame (Sigma)
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.
| Field | Kind | Values | Search |
|---|---|---|---|
AADOperationType | in |
| field:"AADOperationType" kind:in |
ActivityDisplayName | match |
| field:"azure_ad::activity_display_name" kind:match |
Category | eq |
| field:"Category" kind:eq |
Initiator | ne |
| field:"Initiator" kind:ne |
InviteInitiator | is_not_null | field:"InviteInitiator" kind:is_not_null | |
OperationName | eq |
| field:"OperationName" kind:eq |
Result | eq |
| field:"Result" kind:eq value:"success" |
RoleName | match |
| field:"RoleName" kind:match |
Target | is_not_null | field:"Target" kind:is_not_null | |
delta | le |
| field:"delta" kind:le value:"60" |
displayName_ | eq |
| field:"displayName_" kind:eq value:"Role.DisplayName" |
userPrincipalName | match |
| field:"userPrincipalName" kind:match value:"EXT" |
Output fields
These fields are emitted when the rule matches.
| Field | Source |
|---|---|
Initiator | summarize |
OperationName | summarize |
Result | summarize |
RoleName | summarize |
Target | summarize |
TimeGenerated | summarize |