Skip to content

Hunt 002 — Privilege Escalation

Objective: Identify unauthorized or anomalous privilege escalation across Azure, Entra ID, and AWS through PIM abuse, unexpected role assignments, service principal escalation, and token theft patterns.

Frequency: Weekly

Prerequisites:

  • AuditLogs flowing from Entra ID to cirius-logging-law-central
  • SignInLogs flowing (conditional access + sign-in events)
  • AzureActivity forwarded from PROD (d477c9f8) and DDE (ff1c5d68) subscriptions
  • AWSCloudTrail forwarded via log archive account (038901680748)
  • Twingate_CL forwarding to LAW

Queries

Q1 — PIM Role Activations Outside Business Hours

Detects PIM activations that occur outside Mon–Fri 07:00–19:00 Pacific. Legitimate use should be rare. Any after-hours activation warrants review.

kql
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName in ("Add member to role in PIM completed (permanent)",
                          "Add member to role in PIM completed (timebound)",
                          "Add eligible member to role in PIM completed (permanent)",
                          "Add eligible member to role in PIM completed (timebound)")
| extend Hour = hourofday(TimeGenerated)
| extend DayOfWeek = dayofweek(TimeGenerated)
| extend IsBusinessHours = (DayOfWeek between (1d .. 5d)) and (Hour >= 14 or Hour < 2)
// 14:00-02:00 UTC = 07:00-19:00 Pacific standard; adjust for DST
| where not(IsBusinessHours)
| extend ActorUPN = tostring(InitiatedBy.user.userPrincipalName)
| extend TargetUPN = tostring(TargetResources[0].userPrincipalName)
| extend RoleName = tostring(TargetResources[0].displayName)
| project TimeGenerated, ActorUPN, TargetUPN, RoleName, OperationName, Result
| order by TimeGenerated desc

Look for: Any activations between 19:00 and 07:00 Pacific, or on weekends. Rory activating Global Admin at 02:00 on a Saturday is a hit. Kevin or Greg activating outside their known working patterns is a hit.


Q2 — Sudden New Permanent Role Assignments (Non-PIM)

Detects direct role assignments that bypass PIM eligibility — a sign of either misconfiguration or an attacker trying to make access persist after a PIM window closes.

kql
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName in ("Add member to role",
                          "Add member to role completed")
| where Category == "RoleManagement"
| extend ActorUPN = tostring(InitiatedBy.user.userPrincipalName)
| extend ActorAppId = tostring(InitiatedBy.app.appId)
| extend TargetUPN = tostring(TargetResources[0].userPrincipalName)
| extend RoleName = tostring(AdditionalDetails[0].value)
| extend AssignmentType = tostring(AdditionalDetails[1].value)
| where AssignmentType != "Eligible"   // permanent assignments only
| project TimeGenerated, ActorUPN, ActorAppId, TargetUPN, RoleName, AssignmentType, Result
| order by TimeGenerated desc

Look for: Any permanent assignment to Global Administrator, Privileged Role Administrator, Security Administrator, or User Access Administrator. Assignments made by a service principal (ActorUPN empty, ActorAppId populated) are especially suspicious — SPs should not be assigning human roles.


Q3 — Service Principal New Role Assignments

Service principals assigning themselves or other SPs elevated roles is a key escalation pattern. This query finds SP-initiated role assignments in the Azure control plane.

kql
AzureActivity
| where TimeGenerated > ago(7d)
| where OperationNameValue in ("MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS/WRITE",
                               "MICROSOFT.AUTHORIZATION/ROLEDEFINITIONS/WRITE")
| where ActivityStatusValue == "Success"
| extend CallerType = iff(Caller contains "@", "User", "ServicePrincipal")
| where CallerType == "ServicePrincipal"
| extend Properties_d = parse_json(Properties)
| extend RoleDefinitionId = tostring(Properties_d.roleDefinitionId)
| extend Scope = tostring(Properties_d.scope)
| project TimeGenerated, Caller, CallerType, OperationNameValue, RoleDefinitionId, Scope, ResourceGroup, SubscriptionId
| order by TimeGenerated desc

Look for: Any SP assigning roles at subscription scope or higher (Scope containing /subscriptions/ without a child resource path). Custom role definition writes are also suspicious. Cross-reference Caller against known SPs: github-deploy-main, the SecOps Container App MI, and the monitoring job MI are expected. Anything else needs investigation.


Q4 — Impossible Travel Followed by Admin Action Within 1 Hour

Classic token theft pattern: attacker steals a token, signs in from a different geography, then immediately does something privileged. This query joins SignInLogs with AuditLogs.

kql
let SuspiciousSignIns = SignInLogs
| where TimeGenerated > ago(7d)
| where RiskLevelDuringSignIn in ("high", "medium")
    or tostring(LocationDetails.countryOrRegion) !in ("US", "")
| where UserType != "Guest"
| project SignInTime = TimeGenerated, UserPrincipalName, IPAddress,
          Country = tostring(LocationDetails.countryOrRegion),
          City = tostring(LocationDetails.city),
          RiskLevel = RiskLevelDuringSignIn,
          CorrelationId;
let AdminActions = AuditLogs
| where TimeGenerated > ago(7d)
| where Category in ("RoleManagement", "UserManagement", "GroupManagement", "Policy",
                     "ApplicationManagement")
| where Result == "success"
| extend ActorUPN = tostring(InitiatedBy.user.userPrincipalName)
| project AdminActionTime = TimeGenerated, ActorUPN, OperationName, Category;
SuspiciousSignIns
| join kind=inner (AdminActions) on $left.UserPrincipalName == $right.ActorUPN
| where AdminActionTime between (SignInTime .. (SignInTime + 1h))
| project SignInTime, AdminActionTime, UserPrincipalName, IPAddress, Country, City,
          RiskLevel, OperationName, Category
| order by SignInTime desc

Look for: Any sign-in from outside the US (or high-risk flagged by Entra) followed by an admin action within 60 minutes. A legitimate admin traveling and doing work is possible but rare for Cirius. Any foreign country hit is a near-automatic investigation trigger.


Q5 — WAM Token Abuse — Refresh Token Reuse from Multiple IPs

Web Account Manager token theft shows as the same refresh token being used from multiple IP addresses in a short window. This query looks for the same CorrelationId (or same session) appearing from 2+ distinct IPs.

kql
SignInLogs
| where TimeGenerated > ago(7d)
| where AuthenticationDetails contains "Refreshed"
    or TokenIssuerType == "AzureAD"
| where ResultType == 0
| summarize
    IPCount = dcount(IPAddress),
    IPs = make_set(IPAddress, 10),
    Countries = make_set(tostring(LocationDetails.countryOrRegion), 5),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated)
    by UserPrincipalName, CorrelationId
| where IPCount > 1
| where array_length(Countries) > 1 or IPCount >= 3
| project FirstSeen, LastSeen, UserPrincipalName, IPCount, IPs, Countries, CorrelationId
| order by IPCount desc

Look for: Same CorrelationId used from 2+ countries, or same session appearing from 3+ distinct IPs. Single user behind a VPN exit node that changes IP is a benign explanation — check if the IPs resolve to the same ASN. Cross-country reuse within minutes is a confirmed hit.


Q6 — AWS Privilege Escalation via IAM Policy Attach

Attacker with iam:AttachUserPolicy or iam:AttachRolePolicy can grant themselves AdministratorAccess. This query finds all IAM policy attachments outside known CI/CD activity.

kql
AWSCloudTrail
| where TimeGenerated > ago(7d)
| where EventName in ("AttachUserPolicy", "AttachRolePolicy", "AttachGroupPolicy",
                      "PutUserPolicy", "PutRolePolicy", "CreatePolicy",
                      "SetDefaultPolicyVersion", "AddUserToGroup")
| where ErrorCode == ""   // successful actions only
| extend Actor = tostring(parse_json(UserIdentity).arn)
| extend PolicyArn = tostring(parse_json(RequestParameters).policyArn)
| extend TargetEntity = coalesce(
    tostring(parse_json(RequestParameters).userName),
    tostring(parse_json(RequestParameters).roleName),
    tostring(parse_json(RequestParameters).groupName))
| where not(Actor has "github-deploy-main")   // expected CI/CD role
| project TimeGenerated, EventName, Actor, TargetEntity, PolicyArn,
          AWSRegion, RecipientAccountId
| order by TimeGenerated desc

Look for: Any AdministratorAccess or PowerUserAccess policy being attached by a non-CI/CD actor. Anything in the Identity account (414134953818) modifying root-level policies is critical. Any human IAM user (not a role) making policy changes outside a known change window is suspicious.


Q7 — Entra Break-Glass Account Activity (Zero Tolerance)

Break-glass accounts must never be used except in a declared emergency. Any sign-in or action by these accounts is an immediate critical escalation.

kql
let BreakGlassAccounts = dynamic([
    "cirius-breakglass@ciriusgroup.com",
    "cirius-breakglass@ciriusdde.com"
]);
union SignInLogs, AuditLogs
| where TimeGenerated > ago(7d)
| extend ActorUPN = coalesce(
    tostring(UserPrincipalName),
    tostring(InitiatedBy.user.userPrincipalName))
| where ActorUPN in~ (BreakGlassAccounts)
| project TimeGenerated, ActorUPN, OperationName, IPAddress,
          ResultType, ResultDescription, Category
| order by TimeGenerated desc

Look for: Any result at all. This table should be empty every run. One row = CRITICAL incident, no investigation phase, immediate escalation per kill-chain-incident-response.md.


Q8 — Twingate Admin Console Access from New Source

Twingate admin actions from an IP that hasn't been seen accessing it in the prior 30 days. Twingate admin access should come exclusively from Rory's known IPs.

kql
let KnownTwingateAdminIPs = Twingate_CL
| where TimeGenerated between (ago(37d) .. ago(7d))
| where event_type_s == "admin_action" or resource_name_s contains "admin"
| summarize KnownIPs = make_set(client_ip_s);
Twingate_CL
| where TimeGenerated > ago(7d)
| where event_type_s == "admin_action" or resource_name_s contains "admin"
| where client_ip_s !in (toscalar(KnownTwingateAdminIPs))
| project TimeGenerated, user_email_s, client_ip_s, resource_name_s,
          event_type_s, action_s, status_s
| order by TimeGenerated desc

Look for: Any admin action from an IP not in the prior 30-day baseline. Rory traveling on a new IP is possible — cross-reference with his known schedule. An unknown account performing admin actions is an immediate hit.


Triage Guidance

Expected / Benign:

  • PIM activations by Rory during business hours for known maintenance windows
  • github-deploy-main SP creating or modifying role assignments as part of Terraform deploys — cross-reference with open PRs and CM tickets
  • AWS policy updates during CI/CD runs (check CloudTrail UserAgent for aws-sdk from known CI runner IPs)
  • Multiple Entra sign-in IPs for a single user if they use mobile + desktop on the same session

Confirmed Hits (escalate immediately):

  • Any break-glass account sign-in or action (Q7)
  • After-hours PIM activation with no corresponding on-call record or Rory notification
  • SP assigning Global Admin or equivalent to any entity
  • Foreign country sign-in + admin action combo within 60 minutes
  • Same refresh token used from 2+ countries within a session

Borderline (investigate, don't escalate yet):

  • After-hours PIM activation from Rory's known IP — Slack him before escalating
  • New CI/CD role in AWS without a matching PR — check GitHub Actions runs for that timeframe
  • Twingate admin action from new IP on a day Rory said he was traveling

Response

If you find a confirmed hit:

  1. Revoke the session immediately — Entra: Revoke All Refresh Tokens for the affected user. AWS: attach a Deny-all inline policy to the actor IAM identity.
  2. Disable the account — Entra admin center → block sign-in. Do not delete (preserve forensic state).
  3. Open a CRITICAL incident in SecOps (secops.bedrockcybersecurity.org) with source_system = PROD or DDE.
  4. Follow kill-chain-incident-response.md from the Containment phase.
  5. Notify Arctic Wolf — they have act-first authority and can assist with forensics.
  6. Preserve AuditLogs and SignInLogs — export the relevant 7-day window to blob storage before any remediation changes the evidence set.
  7. For AWS hits: call iam:DetachRolePolicy and iam:PutRolePolicy (Deny) against the escalated identity, then engage AWS Support if root account is involved.

Internal use only — Cirius Group