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
Raw source Bulk role assignments performed by the same actor in a short window · KQL
Esc
Published by Azure/Azure-Sentinel ↗, licensed under MIT ↗. Reproduced here unmodified.
id: 8d2cc40f-f0e0-49bf-8983-164f7be3975d
name: 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/
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AuditLogs
      - SigninLogs
tactics:
  - Persistence
  - PrivilegeEscalation
relevantTechniques:
  - T1098.003
query: |
  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
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: Actor
      - identifier: Name
        columnName: AccountName
      - identifier: UPNSuffix
        columnName: AccountUPNSuffix
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: ActorIp
version: 1.0.0
metadata:
    source:
        kind: Community
    author:
        name: descambiado
    support:
        tier: Community
    categories:
        domains: [ "Security - Threat Protection", "Identity" ]

Detection rules belong to the projects that publish them and remain under their own licenses. This site indexes and links to them; it claims no rights in them.