← Back to SOC feed Coverage →

Break-glass account role or group membership changed

kql MEDIUM Azure-Sentinel
T1098.003
AuditLogs
huntingmicrosoftofficial
This rule was pulled from an open-source repository and enriched with AI. Validate in a test environment before deploying to production.
View original rule at Azure-Sentinel →
Retrieved: 2026-08-25T11:00:00Z · Confidence: medium

Hunt Hypothesis

This hunt detects adversaries attempting to escalate privileges or establish persistence by modifying the roles and group memberships of critical break-glass accounts, which are designed to remain static for emergency access. Proactively hunting for these changes in Azure Sentinel is essential because unauthorized modifications to these high-privilege accounts could compromise the integrity of the organization’s last-resort recovery capabilities during a security incident.

KQL 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

Analytic Rule Definition

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(S

Required Data Sources

Sentinel TableNotes
AuditLogsEnsure this data connector is enabled

MITRE ATT&CK Context

References

False Positive Guidance

Here are 4 specific false positive scenarios for the “Break-glass account role or group membership changed” detection rule, including suggested filters and exclusions:

Original source: https://github.com/Azure/Azure-Sentinel/blob/main/Hunting Queries/AuditLogs/BreakGlassAccountRoleOrGroupMembershipChanged.yaml