Break-glass account role or group membership changed


Description

Identifies role and group membership changes on an account designated as an emergency break-glass account, whose access is meant to stay fixed to the single role documented in the tenant's emergency-access runbook.

Query · kql

let starttime = todatetime('{{StartTimeISO}}');
let endtime = todatetime('{{EndTimeISO}}');
let BreakGlassAccounts = (
    _GetWatchlist('BreakGlassAccounts')
    | project AccountUPN = tolower(tostring(SearchKey))
);
// Role and group names are extracted from modifiedProperties as an enrichment step only.
// A base row is always kept even when the property lookup below finds nothing, so a
// removal event whose modifiedProperties happen to be shaped differently than expected
// still surfaces instead of silently disappearing.
// materialize() is required here: RoleChangesBase is referenced twice below (once to
// build the name lookup, once as the left side of the join back to it). Without pinning
// it to a single computed result, the two references would each re-evaluate new_guid()
// independently, so the RowId used to join would never match itself.
// The break-glass account and the role name are both read from whichever TargetResources
// entry describes the user, not from a fixed array index, for the same reason as the
// group section below.
let RoleChangesBase = materialize(
    AuditLogs
    | where TimeGenerated between (starttime .. endtime)
    | where Category =~ "RoleManagement"
    | where OperationName in~ ("Add member to role", "Add member to role.", "Remove member from role", "Remove member from role.")
    | where Result =~ "success"
    | mv-apply TargetResource = TargetResources on (
          where TargetResource.type =~ "User"
          | extend TargetUpn = tolower(tostring(TargetResource.userPrincipalName)),
                   RoleProps = TargetResource.modifiedProperties
      )
    | where TargetUpn in (BreakGlassAccounts)
    | extend RowId      = new_guid()
    | extend ChangeType = iff(OperationName has "Add", "RoleAdded", "RoleRemoved")
    | extend ActorUpn = tostring(InitiatedBy.user.userPrincipalName)
    | extend ActorApp = tostring(InitiatedBy.app.displayName)
    | extend Actor    = iff(isnotempty(ActorUpn), ActorUpn, ActorApp)
    | extend ActorIp  = iff(
          isnotempty(tostring(InitiatedBy.user.ipAddress)),
          tostring(InitiatedBy.user.ipAddress),
          tostring(InitiatedBy.app.ipAddress))
);
let RoleNames =
    RoleChangesBase
    | project RowId, RoleProps
    | mv-expand ModProp = RoleProps
    | where tostring(ModProp.displayName) =~ "Role.DisplayName"
    | project RowId, ChangedObject = trim('"', tostring(coalesce(ModProp.newValue, ModProp.oldValue)));
let RoleChanges =
    RoleChangesBase
    | join kind=leftouter RoleNames on RowId
    | extend ChangedObject = iff(isempty(ChangedObject), "(role name unavailable)", ChangedObject)
    | project TimeGenerated, OperationName, ChangeType, ChangedObject, TargetUpn, Actor, ActorIp, CorrelationId;
// Same reasoning as RoleChangesBase above: materialize() pins the result, including
// RowId, so the two references below see identical values instead of two independent
// new_guid() evaluations that would never match each other.
let GroupChangesBase = materialize(
    AuditLogs
    | where TimeGenerated between (starttime .. endtime)
    | where Category =~ "GroupManagement"
    | where OperationName in~ ("Add member to group", "Add owner to group", "Remove member from group", "Remove owner from group")
    | where Result =~ "success"
    | extend ActorUpn = tostring(InitiatedBy.user.userPrincipalName)
    | extend ActorApp = tostring(InitiatedBy.app.displayName)
    | extend Actor    = iff(isnotempty(ActorUpn), ActorUpn, ActorApp)
    | extend ActorIp  = iff(
          isnotempty(tostring(InitiatedBy.user.ipAddress)),
          tostring(InitiatedBy.user.ipAddress),
          tostring(InitiatedBy.app.ipAddress))
    | extend ChangeType = case(
          OperationName has_cs "Add" and OperationName has_cs "owner", "GroupOwnerAdded",
          OperationName has_cs "Add", "GroupMemberAdded",
          OperationName has_cs "owner", "GroupOwnerRemoved",
          "GroupMemberRemoved")
    // The break-glass account is identified from the same TargetResources entry that
    // carries its own modifiedProperties, not from a fixed array index, since an audit
    // event can carry multiple resources and their ordering is not guaranteed.
    | mv-apply TargetResource = TargetResources on (
          where TargetResource.type =~ "User"
          | extend TargetUpn  = tolower(tostring(TargetResource.userPrincipalName)),
                   Properties = TargetResource.modifiedProperties
      )
    | where TargetUpn in (BreakGlassAccounts)
    | extend RowId = new_guid()
);
let GroupNames =
    GroupChangesBase
    | project RowId, Properties
    | mv-expand Property = Properties
    | where tostring(Property.displayName) =~ "Group.DisplayName"
    | project RowId, ChangedObject = trim('"', tostring(coalesce(Property.newValue, Property.oldValue)));
let GroupChanges =
    GroupChangesBase
    | join kind=leftouter GroupNames on RowId
    | extend ChangedObject = iff(isempty(ChangedObject), "(group name unavailable)", ChangedObject)
    | project TimeGenerated, OperationName, ChangeType, ChangedObject, TargetUpn, Actor, ActorIp, CorrelationId;
union RoleChanges, GroupChanges
| extend AccountName      = tostring(split(TargetUpn, "@")[0])
| extend AccountUPNSuffix = tostring(split(TargetUpn, "@")[1])
| project
    TimeGenerated,
    OperationName,
    ChangeType,
    ChangedObject,
    TargetUpn,
    AccountName,
    AccountUPNSuffix,
    Actor,
    ActorIp,
    CorrelationId
| sort by TimeGenerated desc
Raw source Break-glass account role or group membership changed · KQL
Esc
Published by Azure/Azure-Sentinel ↗, licensed under MIT ↗. Reproduced here unmodified.
id: 4f529b32-a4ed-40d0-b40b-7ac337a705be
name: Break-glass account role or group membership changed
description: |
  Identifies role and group membership changes on an account designated as an emergency
  break-glass account, whose access is meant to stay fixed to the single role documented
  in the tenant's emergency-access runbook.
description-detailed: |
  A break-glass account earns its trust from being boring: it is provisioned once with a
  single static directory role, usually Global Administrator, no group memberships, and no
  further changes until the next scheduled test. That predictability is exactly what lets an
  analyst treat every membership change on the account as significant rather than trying to
  separate signal from routine administrative noise.
  Two paths lead to the same outcome and both are covered here. A direct role assignment or
  removal shows up as an "Add member to role" or "Remove member from role" event with the
  break-glass account as the target. A group-mediated change, where the account is added to
  or removed from a group, including a role-assignable group, shows up as a group membership
  event instead. In both paths the account and the changed object are read from whichever
  TargetResources entry describes the user rather than from a fixed array index, since an
  audit event can carry multiple resources and their ordering is not guaranteed; this mirrors
  the extraction used elsewhere in this repository for the same operations. An
  attacker who quietly folds a break-glass account into a role-assignable group inherits
  whatever role that group holds without ever generating a role-assignment event against the
  account directly, which is precisely why both paths need to be watched together.
  This query depends on the same watchlist as the two companion break-glass hunting queries:
  a watchlist named `BreakGlassAccounts` whose `SearchKey` column holds the user
  principal names of the tenant's designated emergency access accounts.
  Every match should be checked against the change calendar for a documented emergency-access
  review or test. A change with no corresponding record, an unfamiliar actor, or one that
  lands close in time to a sign-in or credential change on the same account (see the two
  companion hunting queries) should be treated as high priority.
  References:
  - https://learn.microsoft.com/entra/identity/role-based-access-control/security-emergency-access
  - https://learn.microsoft.com/entra/identity/role-based-access-control/groups-concept
  - https://attack.mitre.org/techniques/T1098/003/
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AuditLogs
tactics:
  - Persistence
  - PrivilegeEscalation
relevantTechniques:
  - T1098.003
query: |
  let starttime = todatetime('{{StartTimeISO}}');
  let endtime = todatetime('{{EndTimeISO}}');
  let BreakGlassAccounts = (
      _GetWatchlist('BreakGlassAccounts')
      | project AccountUPN = tolower(tostring(SearchKey))
  );
  // Role and group names are extracted from modifiedProperties as an enrichment step only.
  // A base row is always kept even when the property lookup below finds nothing, so a
  // removal event whose modifiedProperties happen to be shaped differently than expected
  // still surfaces instead of silently disappearing.
  // materialize() is required here: RoleChangesBase is referenced twice below (once to
  // build the name lookup, once as the left side of the join back to it). Without pinning
  // it to a single computed result, the two references would each re-evaluate new_guid()
  // independently, so the RowId used to join would never match itself.
  // The break-glass account and the role name are both read from whichever TargetResources
  // entry describes the user, not from a fixed array index, for the same reason as the
  // group section below.
  let RoleChangesBase = materialize(
      AuditLogs
      | where TimeGenerated between (starttime .. endtime)
      | where Category =~ "RoleManagement"
      | where OperationName in~ ("Add member to role", "Add member to role.", "Remove member from role", "Remove member from role.")
      | where Result =~ "success"
      | mv-apply TargetResource = TargetResources on (
            where TargetResource.type =~ "User"
            | extend TargetUpn = tolower(tostring(TargetResource.userPrincipalName)),
                     RoleProps = TargetResource.modifiedProperties
        )
      | where TargetUpn in (BreakGlassAccounts)
      | extend RowId      = new_guid()
      | extend ChangeType = iff(OperationName has "Add", "RoleAdded", "RoleRemoved")
      | extend ActorUpn = tostring(InitiatedBy.user.userPrincipalName)
      | extend ActorApp = tostring(InitiatedBy.app.displayName)
      | extend Actor    = iff(isnotempty(ActorUpn), ActorUpn, ActorApp)
      | extend ActorIp  = iff(
            isnotempty(tostring(InitiatedBy.user.ipAddress)),
            tostring(InitiatedBy.user.ipAddress),
            tostring(InitiatedBy.app.ipAddress))
  );
  let RoleNames =
      RoleChangesBase
      | project RowId, RoleProps
      | mv-expand ModProp = RoleProps
      | where tostring(ModProp.displayName) =~ "Role.DisplayName"
      | project RowId, ChangedObject = trim('"', tostring(coalesce(ModProp.newValue, ModProp.oldValue)));
  let RoleChanges =
      RoleChangesBase
      | join kind=leftouter RoleNames on RowId
      | extend ChangedObject = iff(isempty(ChangedObject), "(role name unavailable)", ChangedObject)
      | project TimeGenerated, OperationName, ChangeType, ChangedObject, TargetUpn, Actor, ActorIp, CorrelationId;
  // Same reasoning as RoleChangesBase above: materialize() pins the result, including
  // RowId, so the two references below see identical values instead of two independent
  // new_guid() evaluations that would never match each other.
  let GroupChangesBase = materialize(
      AuditLogs
      | where TimeGenerated between (starttime .. endtime)
      | where Category =~ "GroupManagement"
      | where OperationName in~ ("Add member to group", "Add owner to group", "Remove member from group", "Remove owner from group")
      | where Result =~ "success"
      | extend ActorUpn = tostring(InitiatedBy.user.userPrincipalName)
      | extend ActorApp = tostring(InitiatedBy.app.displayName)
      | extend Actor    = iff(isnotempty(ActorUpn), ActorUpn, ActorApp)
      | extend ActorIp  = iff(
            isnotempty(tostring(InitiatedBy.user.ipAddress)),
            tostring(InitiatedBy.user.ipAddress),
            tostring(InitiatedBy.app.ipAddress))
      | extend ChangeType = case(
            OperationName has_cs "Add" and OperationName has_cs "owner", "GroupOwnerAdded",
            OperationName has_cs "Add", "GroupMemberAdded",
            OperationName has_cs "owner", "GroupOwnerRemoved",
            "GroupMemberRemoved")
      // The break-glass account is identified from the same TargetResources entry that
      // carries its own modifiedProperties, not from a fixed array index, since an audit
      // event can carry multiple resources and their ordering is not guaranteed.
      | mv-apply TargetResource = TargetResources on (
            where TargetResource.type =~ "User"
            | extend TargetUpn  = tolower(tostring(TargetResource.userPrincipalName)),
                     Properties = TargetResource.modifiedProperties
        )
      | where TargetUpn in (BreakGlassAccounts)
      | extend RowId = new_guid()
  );
  let GroupNames =
      GroupChangesBase
      | project RowId, Properties
      | mv-expand Property = Properties
      | where tostring(Property.displayName) =~ "Group.DisplayName"
      | project RowId, ChangedObject = trim('"', tostring(coalesce(Property.newValue, Property.oldValue)));
  let GroupChanges =
      GroupChangesBase
      | join kind=leftouter GroupNames on RowId
      | extend ChangedObject = iff(isempty(ChangedObject), "(group name unavailable)", ChangedObject)
      | project TimeGenerated, OperationName, ChangeType, ChangedObject, TargetUpn, Actor, ActorIp, CorrelationId;
  union RoleChanges, GroupChanges
  | extend AccountName      = tostring(split(TargetUpn, "@")[0])
  | extend AccountUPNSuffix = tostring(split(TargetUpn, "@")[1])
  | project
      TimeGenerated,
      OperationName,
      ChangeType,
      ChangedObject,
      TargetUpn,
      AccountName,
      AccountUPNSuffix,
      Actor,
      ActorIp,
      CorrelationId
  | sort by TimeGenerated desc
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: TargetUpn
      - identifier: Name
        columnName: AccountName
      - identifier: UPNSuffix
        columnName: AccountUPNSuffix
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: ActorIp
customDetails:
  ChangeType: ChangeType
  ChangedObject: ChangedObject
  Actor: Actor
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.