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.
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
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
| Sentinel Table | Notes |
|---|---|
AuditLogs | Ensure this data connector is enabled |
Here are 4 specific false positive scenarios for the “Break-glass account role or group membership changed” detection rule, including suggested filters and exclusions:
Scheduled Automated Compliance Audit
ComplianceAuditor role to the break-glass account to verify audit log permissions before immediately removing it. This triggers the rule despite no permanent structural change.svc-compliance-audit) and filter on the event timestamp to exclude changes occurring within a 15-minute window of the scheduled job start time.Identity Governance Tool Reconciliation
InitiatedBy attribute matching the specific service principal of the governance tool (e.g., SailPoint-Connector) and exclude changes where the ChangeType is “Membership Refresh” rather than “Role Addition/Removal.”Hybrid Identity Synchronization (Azure AD Connect)
SourceSystem is identified as “Azure AD Connect” and the change