Bulk role assignments performed by the same actor in a short window
Description
Identifies actors who perform three or more Entra ID directory role assignments within a ten-minute window, consistent with automated post-compromise persistence. Results are enriched with the actor's most recent sign-in country for analyst triage. Adjust the threshold variable for environments with routine bulk provisioning workflows. References: - https://learn.microsoft.com/azure/active-directory/roles/permissions-reference - https://learn.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities - https://attack.mitre.org/techniques/T1098/003/
Query · kql
let timeframe = 1d;
let window = 10m;
let threshold = 3;
// Role assignment events expanded to extract the target user per event
let RoleAssignments =
AuditLogs
| where TimeGenerated >= ago(timeframe)
| where OperationName =~ "Add member to role."
| where Result =~ "success"
| extend ActorUpn = tolower(tostring(InitiatedBy.user.userPrincipalName))
| extend ActorApp = tostring(InitiatedBy.app.displayName)
| extend ActorIp = iff(
isnotempty(tostring(InitiatedBy.user.ipAddress)),
tostring(InitiatedBy.user.ipAddress),
tostring(InitiatedBy.app.ipAddress))
| extend Actor = iff(isnotempty(ActorUpn), ActorUpn, ActorApp)
| mv-expand TargetResource = TargetResources
| where tostring(TargetResource.type) =~ "User"
| extend TargetUpn = tostring(TargetResource.userPrincipalName)
| where isnotempty(TargetUpn);
// Aggregate by actor and fixed ten-minute bucket, flag where count reaches threshold
// Note: bin() uses fixed time buckets; assignments at bucket boundaries may be undercounted.
// For sliding-window accuracy, consider using a range join approach.
let BulkActors =
RoleAssignments
| summarize
FirstAssignment = min(TimeGenerated),
LastAssignment = max(TimeGenerated),
AssignedUsers = make_set(TargetUpn),
AssignmentCount = count(),
ActorIp = any(ActorIp)
by Actor, bin(TimeGenerated, window)
| where AssignmentCount >= threshold
| extend WindowDurationSeconds = datetime_diff('second', LastAssignment, FirstAssignment);
// Enrich with actor most recent sign-in country for geographic context
let ActorSignIns =
SigninLogs
| where TimeGenerated >= ago(timeframe)
| where ResultType == 0
| extend ActorUpn = tolower(UserPrincipalName)
| summarize LastSignInTime = max(TimeGenerated) by ActorUpn, Location
| summarize arg_max(LastSignInTime, Location) by ActorUpn
| project ActorUpn, LastSignInCountry = Location;
BulkActors
| join kind=leftouter ActorSignIns on $left.Actor == $right.ActorUpn
| extend AccountName = iff(Actor has "@", tostring(split(Actor, "@")[0]), Actor)
| extend AccountUPNSuffix = iff(Actor has "@", tostring(split(Actor, "@")[1]), "")
| project
FirstAssignment,
LastAssignment,
WindowDurationSeconds,
Actor,
AccountName,
AccountUPNSuffix,
ActorIp,
AssignmentCount,
AssignedUsers,
LastSignInCountry
| sort by FirstAssignment desc