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 | T1098.001 Account Manipulation: Additional Cloud Credentials |
Event coverage
Rules detecting the same action
Other rules on this platform that filter on the same API call or operation.
- Admin promotion after Role Management Application Permission Grant (Kusto)
- Authentication Method Changed for Privileged Account (Kusto)
- Authentication Methods Changed for Privileged Account (Kusto)
- Bulk Changes to Privileged Account Permissions (Kusto)
- Change to Authentication Method (Sigma)
- Changes to PIM Settings (Kusto)
- Detect PIM Alert Disabling activity (Kusto)
- Guest Users Invited To Tenant By Non Approved Inviters (Sigma)
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.
| Field | Kind | Values |
|---|---|---|
AADOperationType | in |
|
ActivityDisplayName | match |
|
Category | eq |
|
Initiator | ne |
|
InviteInitiator | is_not_null | |
OperationName | eq |
|
Result | eq |
|
RoleName | match |
|
Target | is_not_null | |
delta | le |
|
displayName_ | eq |
|
userPrincipalName | match |
|
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.
| Field | Source |
|---|---|
Initiator | summarize |
OperationName | summarize |
Result | summarize |
RoleName | summarize |
Target | summarize |
TimeGenerated | summarize |