How Far Has It Got?
| # | Stage | ATT&CK | Question | Where to look |
|---|---|---|---|---|
| 1 | First sign-in | T1078.004 | When and how did the attacker first sign in: protocol, app, IP? | Entra sign-in logs, interactive and non-interactive: 30 days on P1/P2, 7 on Free, longer only in Log Analytics |
| 2 | MFA | T1539 T1550.004 | Did they pass MFA, and how: AiTM cookie, device code, a replayed PRT, an added method? | Sign-in authentication details, IncomingTokenType, SessionId; Identity Protection risk detections |
| 3 | Persistence | T1098.005 T1564.008 | Did they register a device, add an MFA method or TAP, consent an app, add app credentials, or create inbox rules, forwarding or delegation? | Entra audit log; Get-MgUserRegisteredDevice; OAuth grants and app-role assignments; Get-InboxRule -IncludeHidden; mailbox audit UpdateInboxRules |
| 4 | Data access | T1114.002 | What did they read, search, download or delete: mail, OneDrive, SharePoint, Teams? | Unified audit log, 180 days Standard or 1 year Premium (MailItemsAccessed with MailAccessType and IsThrottled, FileDownloaded) |
| 5 | Outbound | T1534 | Did they send mail or share files from the account, and who clicked? | Message trace (90 days, 10 per query); EmailEvents and UrlClickEvents; SharePoint sharing audit |
| 6 | Privilege | T1098.003 | Does the account hold an admin role, or did they gain one, directly, through a role-assignable group or in Azure RBAC? | Entra audit RoleManagement and GroupManagement; PIM audit; Azure Activity role writes |
| 7 | Other accounts | T1078.004 T1110.003 | Did other users sign in from the same IP, or click an internal phish from this account? | Sign-in logs by IP and user agent; risk detections on the same IP; UrlClickEvents and message-trace recipients |
| 8 | Tenant changes | T1556.009 T1484.002 | Did they change Conditional Access, federation, partners, consent policy, create users or apps, add credentials, change transport rules or switch off auditing? | Entra audit log over the window, any actor; Exchange admin audit |
| 9 | Still active | T1550.001 | Is any token still in use after containment? | Interactive and non-interactive sign-ins, and Graph activity, after the revocation time |
First 15 Minutes: Stop the Bleeding
AccountEnabled on a synced user. Disable in AD and force a delta sync. With password hash sync the attacker holds the on-premises password too, so the AD reset at Hour 4–24 is not optional; with pass-through authentication the AD disable is the only disable there is.Update-MgUser -UserId <upn> -AccountEnabled:$false
# Synced user: in AD, then on the Entra Connect server
Disable-ADAccount -Identity <sAMAccountName>
Start-ADSyncSyncCycle -PolicyType Delta
Revoke-MgUserSignInSession -UserId <upn>
Confirm-MgRiskyUserCompromised -UserIds (Get-MgUser -UserId <upn>).Id
Get-MgUserAuthenticationMethod -UserId <upn> | Select-Object Id, AdditionalProperties | Export-Csv .\mfa-<upn>.csv -NoTypeInformation
Remove-MgUserAuthenticationPhoneMethod -UserId <upn> -PhoneAuthenticationMethodId <id>
Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod -UserId <upn> -MicrosoftAuthenticatorAuthenticationMethodId <id>
Remove-MgUserAuthenticationSoftwareOathMethod -UserId <upn> -SoftwareOathAuthenticationMethodId <id>
Remove-MgUserAuthenticationEmailMethod -UserId <upn> -EmailAuthenticationMethodId <id>
Remove-MgUserAuthenticationFido2Method -UserId <upn> -Fido2AuthenticationMethodId <id>
Remove-MgUserAuthenticationTemporaryAccessPassMethod -UserId <upn> -TemporaryAccessPassAuthenticationMethodId <id>
$compromiseStart = Get-Date "<ISO time, UTC>"
Get-MgUserRegisteredDevice -UserId <upn> -All |
ForEach-Object { Get-MgDevice -DeviceId $_.Id } |
Where-Object { $_.RegistrationDateTime -gt $compromiseStart } |
ForEach-Object { Update-MgDevice -DeviceId $_.Id -AccountEnabled:$false }
oAuth2PermissionGrant.startTime (it is normally 0001-01-01), so a date filter on the grant object returns nothing: date the consent from the audit log. Export first. Disable the service principal before deleting anything: removing the grant alone leaves the app's existing refresh token working until it next needs a new one, and deleting the SP destroys the grant records you have not exported. The app registration itself lives in the attacker's tenant; what you control is the service principal in yours. If the app id is in the FOCI family, one refresh token also serves Office, Teams, Outlook, OneDrive, Azure CLI and Azure PowerShell: hunt those app ids from the attacker IP (Stage 9, Defender tab).$start = $compromiseStart.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
# Which apps were consented or assigned during the window, by whom, from where (Entra audit log: 30 d on P1/P2, 7 d Free)
Get-MgAuditLogDirectoryAudit -All -Filter "activityDateTime ge $start and (activityDisplayName eq 'Consent to application' or activityDisplayName eq 'Add delegated permission grant' or activityDisplayName eq 'Add app role assignment grant to user')" |
Where-Object { $_.InitiatedBy.User.UserPrincipalName -eq '<upn>' -or $_.TargetResources.UserPrincipalName -contains '<upn>' } |
Export-Csv .\consents-<upn>.csv -NoTypeInformation
# Export everything the user holds, then act on the apps the audit log named
Get-MgUserOauth2PermissionGrant -UserId <upn> -All | Export-Csv .\grants-<upn>.csv -NoTypeInformation
Get-MgUserAppRoleAssignment -UserId <upn> -All | Export-Csv .\approles-<upn>.csv -NoTypeInformation
$sp = Get-MgServicePrincipal -Filter "appId eq '<app id from the audit log>'"
Update-MgServicePrincipal -ServicePrincipalId $sp.Id -AccountEnabled:$false # no new tokens for anyone, now
Get-MgUserOauth2PermissionGrant -UserId <upn> -All | Where-Object ClientId -eq $sp.Id |
ForEach-Object { Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $_.Id }
Get-MgUserAppRoleAssignment -UserId <upn> -All | Where-Object ResourceId -eq $sp.Id |
ForEach-Object { Remove-MgUserAppRoleAssignment -UserId <upn> -AppRoleAssignmentId $_.Id }
# Everyone else who consented the same app (the Hour 1-4 tenant-wide list)
Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $sp.Id -All | Select-Object PrincipalId, ConsentType, Scope
UpdateInboxRules in the mailbox audit, not as New-InboxRule; hunt both. Export before removing: the rule names and conditions are the signature for the tenant-wide hunt, and the export is evidence. Check Full Access and Send As delegation on the mailbox, and transport rules at tenant level, in the same pass.Get-InboxRule -Mailbox <upn> -IncludeHidden | Export-Csv .\rules-<upn>.csv -NoTypeInformation
Get-InboxRule -Mailbox <upn> -IncludeHidden | Remove-InboxRule -Confirm:$false
Get-Mailbox -Identity <upn> | Format-List ForwardingSmtpAddress, ForwardingAddress, DeliverToMailboxAndForward
Set-Mailbox -Identity <upn> -ForwardingSmtpAddress $null -ForwardingAddress $null -DeliverToMailboxAndForward $false
Get-MailboxPermission -Identity <upn> | Where-Object { $_.User -notlike 'NT AUTHORITY\*' -and -not $_.IsInherited }
Get-RecipientPermission -Identity <upn> | Where-Object { $_.Trustee -notlike 'NT AUTHORITY\*' }
# Tenant level, any actor: transport rules and connectors changed in the window (Stage 8)
Get-TransportRule | Where-Object { $_.WhenChanged -gt $compromiseStart } | Format-List Name, State, RedirectMessageTo, BlindCopyTo, WhenChanged
Get-InboundConnector | Where-Object { $_.WhenChanged -gt $compromiseStart } | Format-List Name, Enabled, SenderDomains, WhenChanged
MicrosoftGraphActivityLogs diagnostic setting is on. Message trace: 90 days, 10 days per query. Put the mailbox on litigation hold before any purge so deleted phish and rule-created folders stay recoverable, and snapshot the device objects before anyone deletes them.# Entra sign-ins (v1.0: interactive; beta: non-interactive) and the tenant audit log, every page
Get-MgAuditLogSignIn -All -Filter "userPrincipalName eq '<upn>'" | Export-Csv .\signins-<upn>.csv -NoTypeInformation
Get-MgBetaAuditLogSignIn -All -Filter "userPrincipalName eq '<upn>' and signInEventTypes/any(t: t eq 'nonInteractiveUser')" | Export-Csv .\signins-ni-<upn>.csv -NoTypeInformation
Get-MgAuditLogDirectoryAudit -All -Filter "activityDateTime ge $start" | Export-Csv .\audit-tenant.csv -NoTypeInformation
# Unified audit log: ReturnLargeSet pages to 50,000; -ResultSize 5000 on its own stops at 5,000 without saying so
$sid = "IR-<n>-<upn>"
do {
$page = Search-UnifiedAuditLog -StartDate $compromiseStart -EndDate (Get-Date) -UserIds <upn> -SessionId $sid -SessionCommand ReturnLargeSet -ResultSize 5000
$page | Export-Csv .\ual-<upn>.csv -NoTypeInformation -Append
} while ($page)
# Message trace: 10 days per query, 90 days back; slice a longer window, or Start-HistoricalSearch for a report
Get-MessageTraceV2 -SenderAddress <upn> -StartDate $compromiseStart -EndDate (Get-Date) -ResultSize 5000 | Export-Csv .\trace-<upn>.csv -NoTypeInformation
# Hold and snapshot
Set-Mailbox -Identity <upn> -LitigationHoldEnabled $true -LitigationHoldDuration 365 -Comment "IR case #<n>"
Get-MgUserRegisteredDevice -UserId <upn> -All | ForEach-Object { Get-MgDevice -DeviceId $_.Id } | Export-Csv .\devices-<upn>.csv -NoTypeInformation
# Other accounts seen from the attacker's source during the window, interactive and non-interactive
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > datetime(<compromise_start>)
| where IPAddress in (<attacker_ips>) and ResultType == "0"
| summarize FirstSeen=min(TimeGenerated), Apps=make_set(AppDisplayName) by UserPrincipalName
Hour 1–4: Confirm Scope, Find Other Compromised Accounts
union SigninLogs, AADNonInteractiveUserSignInLogs
| where UserPrincipalName =~ "<upn>"
| where TimeGenerated > datetime(<revocation_time>)
| where ResultType == "0"
| project TimeGenerated, Type, IPAddress, AppDisplayName, IncomingTokenType, DeviceDetail
// Graph calls after the revocation (only if the MicrosoftGraphActivityLogs diagnostic setting is on)
MicrosoftGraphActivityLogs
| where UserId == "<object id>" and TimeGenerated > datetime(<revocation_time>)
| summarize Calls=count(), Last=max(TimeGenerated) by IPAddress, AppId, RequestMethod, RequestUri
# In Sentinel or Log Analytics
union SigninLogs, AADNonInteractiveUserSignInLogs
| where UserPrincipalName =~ "<upn>"
| where TimeGenerated > ago(30d)
| project TimeGenerated, Type, IPAddress, AppDisplayName, ResultType, AuthenticationProtocol,
IncomingTokenType, SessionId, ConditionalAccessStatus, Status
| order by TimeGenerated desc
# Outside Sentinel: the Get-MgAuditLogSignIn / Get-MgBetaAuditLogSignIn exports from T+12-15
MailItemsAccessed, MailAccessType Sync means whole folders, IsThrottled True means the log stopped counting at 1,000 in 24 hours and under-states the read, ClientInfoString names the tool. Graph API reads are not in the UAL as such; they are in MicrosoftGraphActivityLogs if the diagnostic setting was on, and nowhere otherwise.# From the T+12-15 export (ReturnLargeSet); the same with Search-UnifiedAuditLog -Operations MailItemsAccessed,FileDownloaded
Import-Csv .\ual-<upn>.csv | ForEach-Object { $_.AuditData | ConvertFrom-Json } |
Where-Object { $_.ClientIPAddress -in @('<ip1>','<ip2>') } |
Select-Object CreationTime, Operation, ClientIPAddress, ClientInfoString,
@{n='MailAccessType';e={ ($_.OperationProperties | Where-Object Name -eq 'MailAccessType').Value }},
@{n='IsThrottled';e={ ($_.OperationProperties | Where-Object Name -eq 'IsThrottled').Value }},
@{n='Folders';e={ ($_.Folders.Path) -join ';' }} |
Export-Csv .\access-by-attacker-ip.csv -NoTypeInformation
Mail.Read for the tenant is a quieter door than a new app). The Entra audit log is 30 days on P1/P2; after that the only record is the SP's own credential list.# Every principal holding a grant for the attacker's app, and every app-role assignment to it
Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $sp.Id -All | Select-Object PrincipalId, ConsentType, Scope
Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $sp.Id -All
# Admin-consented (AllPrincipals) grants anywhere in the tenant
Get-MgOauth2PermissionGrant -All -Filter "consentType eq 'AllPrincipals'" | Select-Object ClientId, ResourceId, Scope
# Service principals and app credentials created in the window, tenant-wide, any actor
Get-MgAuditLogDirectoryAudit -All -Filter "activityDateTime ge $start and (activityDisplayName eq 'Add service principal' or activityDisplayName eq 'Add service principal credentials' or activityDisplayName eq 'Update application – Certificates and secrets management')" |
Select-Object ActivityDateTime, ActivityDisplayName, @{n='Actor';e={$_.InitiatedBy.User.UserPrincipalName}}, @{n='Target';e={$_.TargetResources[0].DisplayName}}
# Secrets and certificates on every SP, newest first: anything added in the window on a privileged app
Get-MgServicePrincipal -All -Property Id,DisplayName,AppId,PasswordCredentials,KeyCredentials |
ForEach-Object { $n=$_.DisplayName; $_.PasswordCredentials + $_.KeyCredentials | ForEach-Object { [pscustomobject]@{App=$n; Added=$_.StartDateTime; Ends=$_.EndDateTime; Name=$_.DisplayName} } } |
Where-Object { $_.Added -gt $compromiseStart } | Sort-Object Added -Descending
EmailEvents
| where Timestamp > datetime(<compromise_start>) and SenderFromAddress =~ "<upn>" and EmailDirection == "Intra-org"
| join kind=inner (UrlClickEvents | where Timestamp > datetime(<compromise_start>)) on NetworkMessageId
| project ClickTime=Timestamp1, Clicker=AccountUpn, Url, ActionType, IsClickedThrough, Subject
| order by ClickTime asc
# Without Defender for Office 365: recipients from message trace (10 days per query, 90 days back)
Get-MessageTraceV2 -SenderAddress <upn> -StartDate $compromiseStart -EndDate (Get-Date) -ResultSize 5000 |
Group-Object RecipientAddress | Select-Object Name, Count | Sort-Object Count -Descending
# Role and group changes in the window, every page
Get-MgAuditLogDirectoryAudit -All -Filter "activityDateTime ge $start and (category eq 'RoleManagement' or category eq 'GroupManagement')" |
Where-Object { $_.ActivityDisplayName -like 'Add member to role*' -or $_.ActivityDisplayName -like 'Add eligible member to role*' -or $_.ActivityDisplayName -eq 'Add member to group' } |
Select-Object ActivityDateTime, ActivityDisplayName, @{n='Actor';e={$_.InitiatedBy.User.UserPrincipalName}}, @{n='Targets';e={$_.TargetResources.DisplayName -join ';'}}
# Is the group role-assignable?
Get-MgGroup -GroupId <group id> -Property DisplayName,IsAssignableToRole
# What the account holds right now, directly and via groups
Get-MgUserTransitiveMemberOf -UserId <upn> -All | ForEach-Object { $_.AdditionalProperties.displayName }
# Azure RBAC role writes by or to the account (Sentinel, Azure Activity connector)
AzureActivity
| where TimeGenerated > datetime(<compromise_start>) and OperationNameValue =~ "Microsoft.Authorization/roleAssignments/write"
| project TimeGenerated, Caller, CallerIpAddress, SubscriptionId, Properties
Hour 4–24: Evict the Attacker, Prevent Re-Entry
- Reset the password with a strong temporary credential, require change at next sign-in. Only NOW — after containment and investigation. Synced user: reset in AD (password hash sync writes the new hash to Entra on the next cycle; with password writeback the Entra reset flows back). With PHS the on-premises password was the attacker's too, so a cloud-only reset leaves it in their hands.
Update-MgUser -UserId <upn> -PasswordProfile @{ ForceChangePasswordNextSignIn = $true; Password = "<new-temp>" } # Synced: Set-ADAccountPassword -Identity <sAMAccountName> -Reset; Start-ADSyncSyncCycle -PolicyType Delta - Force MFA re-registration through a Temporary Access Pass: remove the remaining methods per type (T+4–6 cmdlets), issue a one-time TAP, hand it over by voice to a number you already had on file, and have the user enrol Authenticator or a passkey from a known-good device. A TAP issued by anyone else, or left usable more than once, is itself a persistence method.
New-MgUserAuthenticationTemporaryAccessPassMethod -UserId <upn> -BodyParameter @{ isUsableOnce = $true; lifetimeInMinutes = 60 } - Block the attacker IP range via named location in Conditional Access if the source is identifiable (already tenant-wide if you went broad at T+0), break-glass accounts excluded. Residential-proxy sources rotate; the user-risk policy fed by "Confirm compromised" is the durable control. Before the IP goes into any threat intel feed, an analyst checks it isn't shared infrastructure and gives it a date, a source and an expiry.
- Rotate any shared credentials the user could access — password manager exports, Azure Key Vault entries, service account secrets, SSH keys in scope.
- Revoke any further OAuth grants and app credentials found in the Hour 1–4 tenant-wide audit; disable the service principals first, delete them only after the grant exports are on file. Document the app IDs for the hunt.
- Hunt tenant-wide for the attacker's TTPs: same source IP, same device-code-flow pattern, same OAuth app ID, same inbox rule signature, same FOCI client ids from the same ASN.
- Re-enable the account, then revoke sessions once more. Only after MFA re-registration on a verified device. The second
Revoke-MgUserSignInSessionkills anything that authenticated between reset and re-enable and any token minted through a channel you missed; the user's new session is a minute's inconvenience. - Notify the user with clear instructions: what happened, what they need to do, what you've already done. Don't make the user feel blamed.
- Tighten tenant controls if a gap was exploited: block device-code flow if that was the vector, restrict OAuth user consent if that was the vector. Refresh-token lifetime is not a lever: those policies were retired in January 2021 and the 90-day inactivity default is fixed (Microsoft identity platform, configurable token lifetimes). The levers that exist are Conditional Access sign-in frequency, "persistent browser session" set to never persistent for the roles that matter, and Token Protection (CAE session control) so a stolen token is useless off the device it was issued to.
- Open a forensics ticket with the exports from T+12–15 and the retention windows written next to each: Entra 30 days on P1/P2 (7 on Free), UAL 180 days or one year, message trace 90 days, Graph activity only if it was being shipped.
Day 1–7: Stakeholder Communication, Regulatory, Recovery
- Internal stakeholder briefing (Day 1): legal, comms, exec sponsor, DPO. What happened, scope, contained, next steps. Written, not verbal — this becomes the timeline of record.
- Regulatory notification clock check (Day 1): GDPR — 72 hours to DPA if personal data risk; NIS2 — 24h early warning + 72h incident notification to national CSIRT for essential/important entities; DORA — specific timelines for financial sector; sector regulators may have additional. Dutch NCSC / national CSIRT for NL.
- Customer/partner notification decision (Day 2–3): if their data was accessed, when and how to tell them. Legal-led; security advises on technical content.
- Insurance notification (Day 1–2): cyber policy usually requires notification within 72h of incident discovery. Late notification can invalidate coverage.
- Lateral compromise sweep continues: any user who clicked an internal phishing link from the compromised account gets the same containment treatment. Repeat the T+0 playbook per user.
- Forensic collection from the compromised endpoint if available — the initial infection (infostealer? AiTM landing?) may live on the user's machine. The Forensics act on your EDR's tab under Hunt & Act by Platform pushes Magnet RESPONSE for RAM and triage files and Get-PersistenceSnapshot.ps1 for a zipped persistence snapshot; hand both to the forensic vendor or internal IR with their SHA256s.
- Exposure intelligence check: were the user's credentials in a recent infostealer log? Cross-reference SpyCloud / HIBP / vendor feeds. If yes, expand scope to any system where those credentials were reused.
- Daily status briefings to the exec sponsor until closure. One page, written, time-stamped.
Day 7–30: Post-Mortem and Posture Improvements
- Blameless post-mortem: timeline, decisions made, what worked, what didn't, what we'd do differently. Written, shared with the team.
- Root cause classification: was this credential phishing, AiTM proxy, device-code phishing, OAuth consent abuse, NHI key leak, password reuse from a third-party breach? The answer drives which posture playbook applies next.
- Hand off to posture playbooks:
- If email-vector → BEC Defense
- If vendor/third-party access → Supply Chain Security
- If config gap (Conditional Access, OAuth consent policy) → Configuration Drift
- If admin/cloud key → Cloud Security
- Time-to-detect & time-to-contain metrics: from first attacker action to your first response. Baseline this. Make it visible. Make it a target for the next incident.
- Detection rule updates: encode the attacker's signature into a permanent alert (the source IP pattern, the OAuth app ID, the device-code-flow event, the specific inbox-rule shape). Future occurrence triggers immediate response.
- Tabletop the recurrence: 90 days post-incident, run the same scenario as a tabletop. Validate the new controls hold.
- Board/audit report: incident, response, lessons, control improvements. Quarterly identity-posture cadence going forward.
- Update the runbook: every IR teaches you something the previous version of the runbook didn't cover. Add it.
Hunt & Act by Platform
ago(30d) or longer only works once the diagnostic setting ships them to Log Analytics. OfficeActivity follows the workspace retention, the UAL behind it 180 days (Audit Standard) or one year (Premium). Defender XDR without Sentinel: EntraIdSignInEvents (Entra ID P2; replaces AADSignInEventsBeta on 19 October 2026) and CloudAppEvents carry the same data under other column names.Hunt
Stage 1 · First sign-inSuccessful sign-ins for the account, interactive and non-interactive, earliest first per IP and client
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) // 30 d on P1/P2, 7 d on Free; longer only in Log Analytics
| where UserPrincipalName =~ "<upn>" and ResultType == "0"
| extend Kind = iff(Type == "SigninLogs", "interactive", "non-interactive")
| summarize FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated), SignIns=count(), Kinds=make_set(Kind)
by IPAddress, AutonomousSystemNumber, UserAgent, ClientAppUsed, AppDisplayName, AuthenticationProtocol
| order by FirstSeen asc
// AuthenticationProtocol == "deviceCode" on the first hit: the window starts at the code redemption, not at a password.
Stage 2 · MFAHow MFA was satisfied; PRT from an unmanaged device; one session on two IPs; what Identity Protection flagged
// a. How each authentication step was satisfied, from the attacker's IPs
SigninLogs
| where TimeGenerated > ago(30d)
| where UserPrincipalName =~ "<upn>" and ResultType == "0" and IPAddress in ("<ip>")
| mv-expand step = parse_json(AuthenticationDetails)
| project TimeGenerated, IPAddress, AppDisplayName, AuthenticationProtocol, AuthenticationRequirement,
IncomingTokenType, Method=tostring(step.authenticationMethod), Detail=tostring(step.authenticationStepResultDetail)
// AuthenticationProtocol == "deviceCode": device-code phishing.
// Detail == "MFA requirement satisfied by claim in the token" from a new IP: a replayed session (AiTM or stolen cookie).
// b. A primary refresh token presented from a device Entra does not manage: stolen or forged PRT
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>" and IncomingTokenType == "primaryRefreshToken"
| extend dd = parse_json(DeviceDetail)
| where tostring(dd.isManaged) != "true" or isempty(tostring(dd.deviceId))
| project TimeGenerated, IPAddress, AppDisplayName, DeviceId=tostring(dd.deviceId), TrustType=tostring(dd.trustType), SessionId
// c. One session id seen from more than one IP: the cookie moved
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>" and isnotempty(SessionId)
| summarize IPs=make_set(IPAddress), Sources=dcount(IPAddress), First=min(TimeGenerated), Last=max(TimeGenerated) by SessionId
| where Sources > 1
// d. What Identity Protection already saw (P2)
AADUserRiskEvents
| where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>"
| project TimeGenerated, RiskEventType, RiskLevel, RiskState, Activity, IpAddress
// e. Endpoint: Chromium started for remote debugging (cookie theft), on the victim's device
DeviceProcessEvents
| where Timestamp > ago(30d) and DeviceName =~ "<victim host>"
| where FileName in~ ("chrome.exe","msedge.exe") and ProcessCommandLine has "--remote-debugging-port"
| project Timestamp, DeviceName, AccountName, InitiatingProcessFileName, ProcessCommandLine
Stage 3 · PersistenceMFA methods, TAPs, device registrations, app consents and credentials, inbox rules, forwarding, delegation, transport rules
UpdateInboxRules (mailbox audit); only PowerShell logs New-InboxRule.// Entra: methods, TAPs, devices, consents, app role assignments, credentials added to apps
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ("User registered security info", "Admin registered security info",
"User deleted security info", "Register device", "Add registered owner to device",
"Add registered users to device", "Consent to application", "Add delegated permission grant",
"Add app role assignment grant to user", "Add app role assignment to service principal",
"Add service principal credentials")
or OperationName has "Certificates and secrets management"
| where tostring(InitiatedBy) has "<upn>" or tostring(TargetResources) has "<upn>"
| project TimeGenerated, OperationName, Result, ResultReason, Actor=tostring(InitiatedBy.user.userPrincipalName),
ActorIP=tostring(InitiatedBy.user.ipAddress), Target=tostring(TargetResources[0].displayName)
| order by TimeGenerated asc
// ResultReason has "temporary access pass": a TAP was issued; who issued it and from where, and was it redeemed from the attacker IP?
// Exchange: rules, forwarding, delegation, transport rules and connectors
OfficeActivity
| where TimeGenerated > ago(30d)
| where Operation in ("New-InboxRule", "Set-InboxRule", "UpdateInboxRules", "Set-Mailbox",
"Add-MailboxPermission", "Add-MailboxFolderPermission", "Add-RecipientPermission",
"New-TransportRule", "Set-TransportRule", "New-InboundConnector", "Set-InboundConnector")
| where UserId =~ "<upn>" or tostring(Parameters) has "<upn>"
| where Operation != "Set-Mailbox" or tostring(Parameters) has_any ("ForwardingSmtpAddress", "ForwardingAddress", "DeliverToMailboxAndForward")
| project TimeGenerated, Operation, UserId, ClientIP, Parameters
| order by TimeGenerated asc
Stage 4 · Data accessMail read and files downloaded, by client IP, with sync-versus-bind, throttling, client string, searches and deletions
MailAccessType == "Sync" means whole folders were pulled; IsThrottled == "True" means the log stopped counting after 1,000 accesses in 24 hours and under-states the read; ClientInfoString names the tool.OfficeActivity
| where TimeGenerated > ago(30d) and UserId =~ "<upn>"
| where Operation in ("MailItemsAccessed", "FileDownloaded", "FileSyncDownloadedFull", "FileAccessed",
"SearchQueryInitiatedExchange", "SearchQueryInitiatedSharePoint", "MoveToDeletedItems", "SoftDelete", "HardDelete")
| extend Props = tostring(OperationProperties)
| extend MailAccessType = extract(@'MailAccessType\W+Value\W+(\w+)', 1, Props),
IsThrottled = extract(@'IsThrottled\W+Value\W+(\w+)', 1, Props)
| summarize Events=count(), First=min(TimeGenerated), Last=max(TimeGenerated),
Sync=countif(MailAccessType == "Sync"), Throttled=countif(IsThrottled == "True"),
Clients=make_set(ClientInfoString, 5), Folders=make_set(tostring(parse_json(Folders)[0].Path), 10)
by Operation, OfficeWorkload, ClientIP
| order by First asc
// ClientInfoString "Client=REST;;;" with an app id, or an unfamiliar MAPI/OWA/IMAP string: the attacker's tool.
// SearchQueryInitiatedExchange needs Audit (Premium) and the SearchQueryInitiated owner action; it shows what they looked for.
// Deletions after reads: cleanup of sent phish, bounce notices or rule-created folders.
// Defender XDR without Sentinel:
// CloudAppEvents | where Timestamp > ago(30d) and AccountObjectId == "<object id>"
// | where ActionType in ("MailItemsAccessed","FileDownloaded") | summarize count() by ActionType, IPAddress
// An empty MailItemsAccessed result only means nothing was logged; check Get-Mailbox <upn> | fl AuditEnabled, DefaultAuditSet.
Stage 5 · OutboundMail sent from the account and who clicked it; files shared out of OneDrive and SharePoint
Outside Sentinel: Get-MessageTraceV2 -SenderAddress <upn> -StartDate <start> -EndDate <end> (10 days per query, 90 days back, 5,000 rows max) and Start-HistoricalSearch for a complete export.
// a. What the account sent, by direction
EmailEvents
| where Timestamp > ago(30d) and (SenderFromAddress =~ "<upn>" or SenderMailFromAddress =~ "<upn>")
| summarize Messages=count(), Recipients=dcount(RecipientEmailAddress), First=min(Timestamp), Last=max(Timestamp),
WithUrl=countif(UrlCount > 0), WithAttachment=countif(AttachmentCount > 0), Subjects=make_set(Subject, 10)
by EmailDirection
// b. Who clicked a link in internal mail from the account (Safe Links required)
EmailEvents
| where Timestamp > ago(30d) and SenderFromAddress =~ "<upn>" and EmailDirection == "Intra-org"
| join kind=inner (UrlClickEvents | where Timestamp > ago(30d)) on NetworkMessageId
| project ClickTime=Timestamp1, Clicker=AccountUpn, Url, ActionType, IsClickedThrough, Subject
| order by ClickTime asc
// ActionType "ClickAllowed", or any row with IsClickedThrough == true: that user entered the kit; contain them now.
// c. Sharing out of OneDrive and SharePoint
OfficeActivity
| where TimeGenerated > ago(30d) and UserId =~ "<upn>"
| where Operation in ("SharingSet", "SharingInvitationCreated", "AnonymousLinkCreated", "SecureLinkCreated",
"AddedToSecureLink", "CompanyLinkCreated", "SharingLinkCreated")
| project TimeGenerated, Operation, OfficeWorkload, ClientIP, Site_Url, SourceFileName, TargetUserOrGroupName, TargetUserOrGroupType
| order by TimeGenerated asc
Stage 6 · PrivilegeRole assignments to or by the account, including PIM, role-assignable groups and Azure RBAC
AuditLogs
| where TimeGenerated > ago(30d)
| where (Category == "RoleManagement" and (OperationName startswith "Add member to role" or OperationName startswith "Add eligible member to role"))
or (Category == "GroupManagement" and OperationName == "Add member to group")
| where tostring(TargetResources) has "<upn>" or tostring(InitiatedBy) has "<upn>"
| project TimeGenerated, Category, OperationName, Result, Actor=tostring(InitiatedBy.user.userPrincipalName), TargetResources
| order by TimeGenerated asc
// "Add member to group": check the group with Get-MgGroup -GroupId <id> -Property IsAssignableToRole, and whether it holds an Azure role.
// Azure RBAC writes by or to the account (AzureActivity via the Azure Activity connector)
AzureActivity
| where TimeGenerated > ago(30d)
| where OperationNameValue =~ "Microsoft.Authorization/roleAssignments/write" and ActivityStatusValue == "Success"
| where Caller =~ "<upn>" or tostring(Properties) has "<object id>"
| project TimeGenerated, Caller, CallerIpAddress, SubscriptionId, ResourceGroup, Properties
// Every directory change the account itself made:
// AuditLogs | where TimeGenerated > ago(30d) and tostring(InitiatedBy.user.userPrincipalName) =~ "<upn>" | summarize count() by OperationName
Stage 7 · Other accountsOther users signing in from the attacker's IPs or user agent, and flagged by Identity Protection on the same source
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| where IPAddress in ("<ip1>","<ip2>") // or: UserAgent == "<attacker user agent>"
| summarize Attempts=count(), Successes=countif(ResultType == "0"), First=min(TimeGenerated), Apps=make_set(AppDisplayName, 5)
by UserPrincipalName
| order by First asc
// Risk detections on the same source, tenant-wide
AADUserRiskEvents
| where TimeGenerated > ago(30d) and IpAddress in ("<ip1>","<ip2>")
| summarize Detections=make_set(RiskEventType), First=min(TimeGenerated) by UserPrincipalName, RiskState
Stage 8 · Tenant changesConditional Access, named locations, users, apps, credentials, federation, partners, transport rules and audit settings changed in the window
// Entra: directory and policy changes in the window, by anyone
AuditLogs
| where TimeGenerated between (datetime(<window start>) .. datetime(<window end>))
| where OperationName has_any ("Conditional Access policy", "named location", "security defaults",
"Add user", "Add application", "Add service principal", "Add service principal credentials",
"Add owner to application", "Add owner to service principal", "Set federation settings on domain",
"Set domain authentication", "Add unverified domain", "Verify domain", "Add partner to company",
"authorization policy", "Update StsRefreshTokenValidFrom Timestamp")
or OperationName has "Certificates and secrets management"
| project TimeGenerated, Category, OperationName, Result, Actor=tostring(InitiatedBy.user.userPrincipalName),
App=tostring(InitiatedBy.app.displayName), ActorIP=tostring(InitiatedBy.user.ipAddress),
Target=tostring(TargetResources[0].displayName), TargetResources
| order by TimeGenerated asc
// Exchange: transport rules, connectors, audit and auth settings
OfficeActivity
| where TimeGenerated between (datetime(<window start>) .. datetime(<window end>))
| where Operation in ("New-TransportRule", "Set-TransportRule", "Remove-TransportRule", "New-InboundConnector", "Set-InboundConnector",
"Set-AdminAuditLogConfig", "Set-OrganizationConfig", "Set-AuthenticationPolicy", "Set-MailboxAuditBypassAssociation",
"Add-RoleGroupMember", "New-ManagementRoleAssignment", "New-ApplicationAccessPolicy")
| project TimeGenerated, Operation, UserId, ClientIP, Parameters
// Set-AdminAuditLogConfig with UnifiedAuditLogIngestionEnabled false: the UAL was switched off; your evidence ends there.
// Set-MailboxAuditBypassAssociation: a mailbox silently excluded from auditing.
Stage 9 · Still activeToken use and Graph calls after the revocation time; which family clients the refresh token was minted for
revokeSignInSessions invalidates refresh tokens and session cookies; access tokens already issued live until they expire, 60–90 minutes by default and up to 28 hours on CAE-capable clients unless CAE pushed the revocation (it does for account disable and password change). A success after that window means containment missed a device, an app grant or a refresh token.let revoked = datetime(<revocation time, UTC>);
// or: toscalar(AuditLogs | where OperationName == "Update StsRefreshTokenValidFrom Timestamp" and tostring(TargetResources) has "<upn>" | summarize max(TimeGenerated))
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > revoked and UserPrincipalName =~ "<upn>" and ResultType == "0"
| summarize SignIns=count(), First=min(TimeGenerated), Last=max(TimeGenerated) by Type, IPAddress, AppDisplayName, UserAgent, IncomingTokenType
| order by First asc
// Graph calls after the revocation (needs the "MicrosoftGraphActivityLogs" diagnostic setting; nothing is kept without it)
MicrosoftGraphActivityLogs
| where TimeGenerated > revoked and UserId == "<object id>"
| summarize Calls=count(), Last=max(TimeGenerated) by IPAddress, AppId, RequestMethod, RequestUri
| order by Calls desc
// Which FOCI family clients the attacker minted tokens for before revocation: one family refresh token serves all of them
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>" and IPAddress in ("<ip1>","<ip2>")
| where AppId in ("d3590ed6-52b3-4102-aeff-aad2292ab01c", "04b07795-8ddb-461a-bbee-02f9e1bf7b46", "1950a258-227b-4e31-a9cf-717495945fc2",
"1fec8e78-bce4-4aaf-ab1b-5451cc387264", "4813382a-8fa7-425e-ab75-3b753aab3abb", "27922004-5251-4030-b22d-91ecd9a37ea4",
"ab9b8c07-8f02-4f72-87fa-80105867a763")
| summarize Apps=make_set(AppDisplayName), First=min(TimeGenerated) by IPAddress, UserAgent
Act
Disable first, revoke second, for every account in scope in one pass; verify it held before re-enabling anything. A victim's device that ran an infostealer is rebuilt, not cleaned.
T+0–15 · ContainDisable, then revoke, every account in scope at once; confirm compromised
Portal, per user: identity page › Disable / Revoke session / Mark as compromised. From an advanced hunting result: Take actions › Disable user (the query must return AccountObjectId). Defender for Cloud Apps: Suspend user, and Revoke app for a consented OAuth app. Synced users: Entra refuses AccountEnabled on them; disable in AD and run Start-ADSyncSyncCycle -PolicyType Delta, then revoke in Entra. With password hash sync the attacker holds the on-premises password too: reset it in AD. For the whole list, script it:
$scope = Get-Content .\accounts-in-scope.txt # one UPN per line
foreach ($u in $scope) {
Update-MgUser -UserId $u -AccountEnabled:$false # first: no new tokens; CAE clients drop within minutes
Revoke-MgUserSignInSession -UserId $u # then: refresh tokens and session cookies gone
Confirm-MgRiskyUserCompromised -UserIds (Get-MgUser -UserId $u).Id # Identity Protection: risk high, risk policies fire
}
T+0–15 · ContainBlock the attacker's source tenant-wide, with the break-glass accounts excluded
Entra: Conditional Access › Named locations › add the attacker IPs, then a block policy for that location, all users, with the break-glass accounts excluded: an IP block that catches your own emergency access is the outage you cannot fix. On devices: Settings › Endpoints › Indicators › IP addresses (enforcement needs network protection in block mode), or via the API:
POST https://api.security.microsoft.com/api/indicators
{"indicatorValue": "<attacker IP>", "indicatorType": "IpAddress", "action": "Block",
"title": "IR case #<n>", "description": "Attacker source, identity breach", "expirationTime": "<ISO date>"}
T+0–15 · ContainIsolate every victim device (infostealer or cookie theft)
Device page › Isolate device (Full); Live Response keeps working. The API isolates one device per call — run it for each device in scope:
POST https://api.security.microsoft.com/api/machines/{id}/isolate
{"Comment": "IR case #<n>", "IsolationType": "Full"}
Day 1–7 · ForensicsCollect volatile data and a persistence snapshot, then rebuild the device
Device page › Collect investigation package. For RAM, pagefile and the browser's live state, push Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d with Live Response: putfile it from the library, run a one-line wrapper script that starts it elevated, getfile the output. Then Get-PersistenceSnapshot.ps1 a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df with -Zip (read-only; -CompareTo diffs against a baseline). Rebuild from a known-good image; release isolation only after the rebuild and after the Hour 1–4 check shows nothing since the revocation time.
Hunt
Stage 1 · First sign-inSuccessful sign-ins for the account, earliest first per IP and client
#repo="3pi_microsoft_entra_id"
| Vendor.properties.userPrincipalName="<upn>"
| groupBy([Vendor.properties.ipAddress, Vendor.properties.userAgent, Vendor.properties.appDisplayName, Vendor.properties.authenticationProtocol],
function=[min(@timestamp, as=FirstSeen), count(as=SignIns)])
| sort(FirstSeen, order=asc)
// CrowdStrike documents Vendor.properties.authenticationProtocol and .appId for this connector.
// userPrincipalName / ipAddress / userAgent / appDisplayName are the matching keys of the raw
// Entra sign-in record: check them on one parsed event before trusting an empty result.
Stage 2 · MFAHow MFA was satisfied; PRT from an unmanaged device; one session on two IPs; cookie theft on the victim's device
#repo="3pi_microsoft_entra_id"
| Vendor.properties.userPrincipalName="<upn>"
| Vendor.properties.authenticationProtocol=deviceCode
// a hit is device-code phishing (fields as in CrowdStrike's Entra ID / PRT abuse blog)
// PRT from an unmanaged device and one session id on two IPs: the raw Entra keys are
// incomingTokenType (= primaryRefreshToken), deviceDetail.isManaged, deviceDetail.deviceId and sessionId;
// confirm the parsed names on one event, then:
#repo="3pi_microsoft_entra_id"
| Vendor.properties.userPrincipalName="<upn>"
| groupBy([Vendor.properties.sessionId], function=[collect([Vendor.properties.ipAddress]), count(Vendor.properties.ipAddress, distinct=true, as=Sources)])
| Sources > 1
// Endpoint: Chromium started for remote debugging (cookie theft), on the victim's device
#event_simpleName=ProcessRollup2 ComputerName="<victim host>"
| ImageFileName=/\\(chrome|msedge)\.exe$/i CommandLine=/--remote-debugging-port/i
| table([@timestamp, ComputerName, UserName, CommandLine])
Stage 3 · PersistenceMFA methods, TAPs, device registrations, app consents, inbox rules and forwarding
CrowdStrike does not publish the parsed field names for Entra audit or Microsoft 365
events. If your Entra ID / Microsoft 365 connectors ingest them: open one parsed audit
event for <upn>, find the operation-name field, then filter it for
User registered security info, Admin registered security info, Register device,
Add registered owner to device, Consent to application, Add delegated permission grant,
Add app role assignment grant to user, Add service principal credentials
and, on the Microsoft 365 events,
New-InboxRule, Set-InboxRule, UpdateInboxRules (Outlook and OWA log rules as this),
Set-Mailbox with a Forwarding* parameter, Add-MailboxPermission, New-TransportRule
and group by time and source IP.
Stage 4 · Data accessMail read and files downloaded, by operation and client IP
No published field names for Microsoft 365 audit events in Falcon Next-Gen SIEM.
If the Microsoft 365 connector is ingesting: open one parsed event for <upn>, find the
operation, client-IP, ClientInfoString and OperationProperties fields, filter the operation
for MailItemsAccessed, FileDownloaded, FileSyncDownloadedFull, FileAccessed, SoftDelete,
HardDelete and group by operation and IP. In OperationProperties, MailAccessType=Sync means
whole folders were pulled and IsThrottled=True means the log under-counts.
Otherwise use the page's Search-UnifiedAuditLog command.
Stage 6 · PrivilegeRole assignments to or by the account, including PIM
No published field names for Entra audit events in Falcon Next-Gen SIEM. If the
Entra ID connector ingests audit logs: find the operation-name field on one parsed event
and filter it for "Add member to role", "Add eligible member to role" (PIM variants
start with the same words) and "Add member to group" (then check whether the group is
role-assignable), and see whether <upn> is the actor or the target.
Also: Falcon Identity Protection, if licensed, shows the account's privileged status.
Stage 7 · Other accountsOther users signing in from the attacker's IPs or user agent
#repo="3pi_microsoft_entra_id"
| Vendor.properties.ipAddress="<ip1>" or Vendor.properties.ipAddress="<ip2>"
| groupBy([Vendor.properties.userPrincipalName], function=[min(@timestamp, as=First), count(as=SignIns)])
| sort(First, order=asc)
// field names as in Stage 1: confirm on one parsed event
Stage 8 · Tenant changesConditional Access, users, apps, credentials, federation and partner changes in the window
If the Entra ID connector ingests audit logs: on the operation-name field found in Stage 6,
restrict the time range to the exposure window and filter for operations containing
Conditional Access policy, named location, security defaults, Add user, Add application,
Add service principal, Add service principal credentials, Certificates and secrets management,
Add owner to application, Set federation settings on domain, Set domain authentication,
Add partner to company, authorization policy
by any actor, then group by actor and source IP. Otherwise use the page's Hour 1-4 AuditLogs query
in Sentinel or Get-MgAuditLogDirectoryAudit -All.
Stage 9 · Still activeToken use after the revocation time
revokeSignInSessions invalidates refresh tokens and session cookies; access tokens already issued live until they expire, 60–90 minutes by default and up to 28 hours on CAE-capable clients unless CAE pushed the revocation. A success after that window means containment missed a device, an app grant or a refresh token.Set the search window to start at the revocation time, then:
#repo="3pi_microsoft_entra_id"
| Vendor.properties.userPrincipalName="<upn>"
| groupBy([Vendor.properties.ipAddress, Vendor.properties.appDisplayName, Vendor.properties.isInteractive],
function=[max(@timestamp, as=Last), count(as=SignIns)])
// whether the connector includes non-interactive sign-ins depends on its configuration: check before relying on an empty result
Get-MessageTraceV2 -SenderAddress <upn> (10 days per query, 90 days back) and the EmailEvents / UrlClickEvents join on the Defender tab.Act
Disable first, revoke second, for every account in scope in one pass; verify it held before re-enabling anything. A victim's device that ran an infostealer is rebuilt, not cleaned.
T+0–15 · ContainDisable and revoke every account in scope — in Entra
Run the T+0–15 commands for every account in scope, and block the attacker's source in Conditional Access with the break-glass accounts excluded. Falcon Fusion SOAR can call Entra actions through its Microsoft Entra ID integration; check the action names and Graph permissions in your console before relying on it.
T+0–15 · ContainContain every victim device in one call (infostealer or cookie theft)
Host management › select the hosts › Network contain; RTR keeps working on a contained host. The API takes up to 100 host IDs per request:
POST /devices/entities/devices-actions/v2?action_name=contain
{"ids": ["<AID-1>", "<AID-2>"]}
Day 1–7 · ForensicsCollect before you stop anything (RTR)
put from the RTR file library and fetch the output with get.Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d for RAM, pagefile, processes and triage files; Get-PersistenceSnapshot.ps1 a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df with -Zip for a read-only persistence snapshot with chain of custody.
ps
netstat
put MagnetRESPONSEv172_Self_Extracting_Archive.exe
put Get-PersistenceSnapshot.ps1
runscript -Raw=```Start-Process .\MagnetRESPONSEv172_Self_Extracting_Archive.exe -Wait; .\Get-PersistenceSnapshot.ps1 -Zip```
get C:\path\to\infostealer.exe
get <collector output zip>
Day 1–7 · ForensicsStop it running, then rebuild the device
kill <pid>
Hunt
Stage 1 · First sign-inSuccessful sign-ins for the account, earliest first per IP and client
// 1. Which data sources carry this user?
* contains '<upn>'
| group Events = count() by dataSource.name
// 2. Dump one event from the Entra source to learn its field names
dataSource.name = '<entra source>' | limit 1 | columns *
// 3. On that source, group by the IP and user-agent fields step 2 showed, earliest first
dataSource.name = '<entra source>' <user field> contains:anycase '<upn>'
| group First = oldest(timestamp), Events = count() by <ip field>, <user agent field>, <app field>
Stage 2 · MFAHow MFA was satisfied; PRT from an unmanaged device; one session on two IPs; infostealer and cookie theft on the victim's device
// Entra authentication details: on the Entra source and fields found in Stage 1, filter the raw keys
// authenticationProtocol = deviceCode, incomingTokenType = primaryRefreshToken with deviceDetail.isManaged false,
// and group by sessionId with count_distinct(<ip field>) > 1 for a cookie seen from two sources.
// Endpoint, on the victim's device: SentinelOne infostealer indicators
indicator.category = 'InfoStealer'
| group Hits = count() by endpoint.name, src.process.name
// Chromium started for remote debugging (cookie theft)
event.type = 'Process Creation' AND tgt.process.name in ('chrome.exe','msedge.exe')
AND tgt.process.cmdline contains '--remote-debugging-port'
| columns event.time, endpoint.name, src.process.name, tgt.process.cmdline
Stage 3 · PersistenceMFA methods, TAPs, device registrations, app consents, inbox rules and forwarding
// After discovery (Stage 1), on the Entra audit source:
dataSource.name = '<entra audit source>' <operation field> in ('User registered security info',
'Admin registered security info', 'Register device', 'Add registered owner to device',
'Consent to application', 'Add delegated permission grant', 'Add app role assignment grant to user',
'Add service principal credentials')
| columns timestamp, <operation field>, <actor field>, <actor ip field>, <target field>
// Inbox rules and forwarding: the same on the Microsoft 365 source,
// <operation field> in ('New-InboxRule','Set-InboxRule','UpdateInboxRules','Set-Mailbox','Add-MailboxPermission','New-TransportRule')
// Outlook and OWA log rules as UpdateInboxRules; keep Set-Mailbox rows whose parameters mention Forwarding.
Stage 4 · Data accessMail read and files downloaded, by operation and client IP
// After discovery (Stage 1), on the Microsoft 365 source:
dataSource.name = '<m365 source>' <operation field> in ('MailItemsAccessed','FileDownloaded',
'FileSyncDownloadedFull','FileAccessed','SoftDelete','HardDelete') <user field> contains:anycase '<upn>'
| group Events = count(), First = oldest(timestamp) by <operation field>, <client ip field>, <ClientInfoString field>
// In the MailItemsAccessed OperationProperties: MailAccessType=Sync means whole folders were pulled;
// IsThrottled=True means the log stopped counting at 1,000 in 24 h and under-states the read.
// SentinelOne's M365 note: exclude Microsoft service-tier IPs before ranking client IPs
Stage 5 · OutboundMail sent from the account and files shared out of OneDrive and SharePoint
// After discovery (Stage 1), on the Microsoft 365 source:
dataSource.name = '<m365 source>' <user field> contains:anycase '<upn>'
<operation field> in ('Send','SendAs','SendOnBehalf','SharingSet','SharingInvitationCreated',
'AnonymousLinkCreated','SecureLinkCreated','AddedToSecureLink')
| columns timestamp, <operation field>, <client ip field>, <target user field>, <object field>
// Recipients and subjects of every message sent: Get-MessageTraceV2 -SenderAddress <upn> (10 days per query, 90 days back).
Stage 6 · PrivilegeRole assignments to or by the account, including PIM
// After discovery (Stage 1), on the Entra audit source:
dataSource.name = '<entra audit source>' <operation field> contains ('Add member to role','Add eligible member to role','Add member to group')
| columns timestamp, <operation field>, <actor field>, <target field>
// 'Add member to group': check the group with Get-MgGroup -GroupId <id> -Property IsAssignableToRole
Stage 7 · Other accountsOther users signing in from the attacker's IPs or user agent
// Value-anywhere search finds the IP in any parsed field, on every source
* contains '<ip>'
| group Events = count() by dataSource.name, <user field>
// <user field>: the Entra user field found in Stage 1
Stage 8 · Tenant changesConditional Access, users, apps, credentials, federation and partner changes in the window
// After discovery (Stage 1), on the Entra audit source, time range = the exposure window, any actor:
dataSource.name = '<entra audit source>' <operation field> contains ('Conditional Access policy','named location',
'security defaults','Add user','Add application','Add service principal','Add service principal credentials',
'Certificates and secrets management','Add owner to application','Set federation settings on domain',
'Set domain authentication','Add partner to company','authorization policy')
| columns timestamp, <operation field>, <actor field>, <actor ip field>, <target field>
// Exchange tenant settings (transport rules, connectors, Set-AdminAuditLogConfig): the same on the Microsoft 365 source.
Stage 9 · Still activeToken use after the revocation time
revokeSignInSessions invalidates refresh tokens and session cookies; access tokens already issued live until they expire, 60–90 minutes by default and up to 28 hours on CAE-capable clients unless CAE pushed the revocation. A success after that window means containment missed a device, an app grant or a refresh token.// Set the time range to start at the revocation time. On the Entra sign-in source from Stage 1:
dataSource.name = '<entra source>' <user field> contains:anycase '<upn>'
| group SignIns = count(), Last = newest(timestamp) by <ip field>, <app field>
Act
Disable first, revoke second, for every account in scope in one pass; verify it held before re-enabling anything. A victim's device that ran an infostealer is rebuilt, not cleaned.
T+0–15 · ContainDisable and revoke every account in scope — in Entra
Run the T+0–15 commands for every account in scope, and block the attacker's source in Conditional Access with the break-glass accounts excluded.
T+0–15 · ContainDisconnect every victim device in one call (infostealer or cookie theft)
Sentinels › select the endpoints › Actions › Disconnect from network; the management connection stays up. The API takes a filter, so one call covers the whole list:
POST /web/api/v2.1/agents/actions/disconnect
{"filter": {"ids": ["<agent-id-1>", "<agent-id-2>"]}}
Day 1–7 · ForensicsCollect before you stop anything
Remote Shell (PowerShell): Get-Process, Get-NetTCPConnection. RemoteOps: push Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d as a script-library binary and run it elevated for RAM, pagefile and triage files; run Get-PersistenceSnapshot.ps1 a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df with -Zip as a RemoteOps PowerShell script. Fetch Files: the collector output, the infostealer binary and the browser profile.
Day 1–7 · ForensicsStop it running, then rebuild the device
Threat › Mitigation › Kill, Quarantine.
Hunt
Stage 1 · First sign-inThe lure the victim followed, on the victim's PC
Hunt: Windows.Applications.Chrome.History (default globs cover Chrome, Edge, Brave, Vivaldi, Opera)
URLRegex = (?i)<lure domain>|microsoft\.com/devicelogin
userRegex = <victim user>
Stage 2 · MFASession-cookie theft and infostealer traces on the victim's device
Hunt: Windows.EventLogs.EvtxHunter
IocRegex = (?i)remote-debugging-port (needs command-line logging: Sysmon or 4688)
Hunt: Windows.Applications.Chrome.Extensions (Chrome by default; set extensionGlobs for Edge)
Hunt: Windows.Forensics.Prefetch
binaryRegex = (?i)<infostealer name from the EDR alert>
Stage 7 · Other accountsOther PCs that opened the same lure
Hunt: Windows.Applications.Chrome.History (all clients)
URLRegex = (?i)<lure domain>
Stage 9 · Still activeA stealer still running after containment
Hunt: Windows.System.Pslist
ProcessRegex = (?i)<infostealer process name>
Keep the device quarantined until it is re-imaged.
Get-MgUserAuthenticationMethod, Get-MgUserRegisteredDevice, Get-MgUserOauth2PermissionGrant and Get-InboxRule commands, or the Defender or Splunk tab.Search-UnifiedAuditLog -SessionCommand ReturnLargeSet export (MailItemsAccessed, FileDownloaded), or the Defender, Splunk or Elastic tab.Get-MessageTraceV2 -SenderAddress <upn> (10 days per query, 90 days back) and the SharingSet family of UAL operations; see the Defender tab for the EmailEvents / UrlClickEvents join.Get-MgAuditLogDirectoryAudit -All -Filter "category eq 'RoleManagement'", or the Defender tab for the AuditLogs and AzureActivity queries.Get-MgAuditLogDirectoryAudit -All over the exposure window, or the Defender tab's Stage 8 queries.Act
Disable first, revoke second, for every account in scope in one pass; verify it held before re-enabling anything. A victim's device that ran an infostealer is rebuilt, not cleaned.
T+0–15 · ContainDisable and revoke every account in scope — in Entra
Run the T+0–15 commands for every account in scope, and block the attacker's source in Conditional Access with the break-glass accounts excluded.
T+0–15 · ContainQuarantine every victim device with one hunt (infostealer or cookie theft)
Label the victim devices, then Hunt Manager › New Hunt, include that label, artifact Windows.Remediation.Quarantine. It applies a firewall policy that only allows the Velociraptor server; the same artifact with RemovePolicy set lifts it.
Day 1–7 · ForensicsCollect before you stop anything
Windows.Memory.Acquisition for RAM, then the artifacts below. For a persistence snapshot with chain of custody, add Get-PersistenceSnapshot.ps1 a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df as a server tool and run it with Windows.System.PowerShell (-Zip; -CompareTo against a baseline). Where the case needs the Magnet output format, run Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d elevated from removable media on the console.
Windows.Memory.Acquisition
Windows.System.Pslist
Windows.Network.NetstatEnriched
Windows.Applications.Chrome.Extensions
Windows.Applications.Chrome.History
Windows.KapeFiles.Targets (Target = KapeTriage)
Day 1–7 · RebuildRebuild the device, then lift the quarantine
Hunt
Stage 1 · First sign-inSuccessful sign-ins for the account, interactive and non-interactive, earliest first per IP and client
index=<index> sourcetype="azure:aad:signin" userPrincipalName="<upn>" status.errorCode=0
| stats min(_time) as first_seen max(_time) as last_seen count by ipAddress appDisplayName clientAppUsed authenticationProtocol
| sort first_seen
| convert ctime(first_seen) ctime(last_seen)
// Non-interactive (Event Hub input)
index=<index> sourcetype="azure:monitor:aad" category="NonInteractiveUserSignInLogs"
properties.userPrincipalName="<upn>" properties.status.errorCode=0
| stats min(_time) as first_seen count by properties.ipAddress properties.appDisplayName properties.incomingTokenType
| sort first_seen
| convert ctime(first_seen)
Stage 2 · MFAHow MFA was satisfied; PRT from an unmanaged device; one session on two IPs; cookie theft on the victim's device
index=<index> sourcetype="azure:aad:signin" userPrincipalName="<upn>" ipAddress="<ip>"
| table _time ipAddress appDisplayName authenticationProtocol authenticationRequirement incomingTokenType
authenticationDetails{}.authenticationMethod authenticationDetails{}.authenticationStepResultDetail
// these fields exist only when the add-on input reads the Graph beta endpoint
// PRT from a device Entra does not manage
index=<index> sourcetype="azure:aad:signin" userPrincipalName="<upn>" incomingTokenType="primaryRefreshToken"
NOT deviceDetail.isManaged="true"
| table _time ipAddress appDisplayName deviceDetail.deviceId deviceDetail.trustType sessionId
// One session id from more than one IP
index=<index> sourcetype="azure:aad:signin" userPrincipalName="<upn>" sessionId=*
| stats dc(ipAddress) as sources values(ipAddress) as ips min(_time) as first by sessionId
| where sources > 1
// Endpoint (Sysmon): Chromium started for remote debugging (cookie theft)
index=windows source="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventCode=1
(Image="*\\chrome.exe" OR Image="*\\msedge.exe") CommandLine="*--remote-debugging-port*"
| table _time host User ParentImage CommandLine
Stage 3 · PersistenceMFA methods, TAPs, device registrations, app consents and credentials, inbox rules, forwarding, delegation, transport rules
UpdateInboxRules; only PowerShell logs New-InboxRule.index=<index> sourcetype="azure:aad:audit"
(activityDisplayName IN ("User registered security info", "Admin registered security info",
"User deleted security info", "Register device", "Add registered owner to device",
"Add registered users to device", "Consent to application", "Add delegated permission grant",
"Add app role assignment grant to user", "Add app role assignment to service principal",
"Add service principal credentials") OR activityDisplayName="*Certificates and secrets management*")
("initiatedBy.user.userPrincipalName"="<upn>" OR "targetResources{}.userPrincipalName"="<upn>")
| table _time activityDisplayName result resultReason initiatedBy.user.userPrincipalName initiatedBy.user.ipAddress targetResources{}.displayName
// resultReason containing "temporary access pass": a TAP was issued; who, from where, and was it redeemed from the attacker IP?
// Exchange: rules, forwarding, delegation, transport rules
index=<index> sourcetype="o365:management:activity" (UserId="<upn>" OR "Parameters{}.Value"="<upn>")
Operation IN ("New-InboxRule","Set-InboxRule","UpdateInboxRules","Set-Mailbox",
"Add-MailboxPermission","Add-MailboxFolderPermission","Add-RecipientPermission",
"New-TransportRule","Set-TransportRule","New-InboundConnector","Set-InboundConnector")
| where Operation!="Set-Mailbox" OR like(mvjoin('Parameters{}.Name', ","), "%Forwarding%") OR like(mvjoin('Parameters{}.Name', ","), "%DeliverToMailboxAndForward%")
| table _time Operation UserId ClientIP Parameters{}.Name Parameters{}.Value
Stage 4 · Data accessMail read and files downloaded, by client IP, with sync-versus-bind, throttling, client string and deletions
index=<index> sourcetype="o365:management:activity" UserId="<upn>"
Operation IN ("MailItemsAccessed","FileDownloaded","FileSyncDownloadedFull","FileAccessed",
"SearchQueryInitiatedExchange","MoveToDeletedItems","SoftDelete","HardDelete")
| eval MailAccessType=mvindex('OperationProperties{}.Value', mvfind('OperationProperties{}.Name', "MailAccessType")),
IsThrottled=mvindex('OperationProperties{}.Value', mvfind('OperationProperties{}.Name', "IsThrottled"))
| stats count min(_time) as first max(_time) as last
count(eval(MailAccessType="Sync")) as sync count(eval(IsThrottled="True")) as throttled
values(ClientInfoString) as clients values(Folders{}.Path) as folders
by Operation Workload ClientIP
| sort first
| convert ctime(first) ctime(last)
// SearchQueryInitiatedExchange needs Audit (Premium) and the SearchQueryInitiated owner action.
Stage 5 · OutboundMail sent from the account (message trace) and files shared out of OneDrive and SharePoint
// Message trace input (last 10 days per fetch; Get-MessageTraceV2 covers 90 days outside Splunk)
index=<index> sourcetype="o365:reporting:messagetrace" SenderAddress="<upn>"
| stats count dc(RecipientAddress) as recipients values(Subject) as subjects min(_time) as first by RecipientAddress
| sort first
| convert ctime(first)
// Sharing out of OneDrive and SharePoint
index=<index> sourcetype="o365:management:activity" UserId="<upn>"
Operation IN ("SharingSet","SharingInvitationCreated","AnonymousLinkCreated","SecureLinkCreated","AddedToSecureLink","CompanyLinkCreated","SharingLinkCreated")
| table _time Operation Workload ClientIP SiteUrl SourceFileName TargetUserOrGroupName TargetUserOrGroupType
Stage 6 · PrivilegeRole assignments to or by the account, including PIM and role-assignable groups
index=<index> sourcetype="azure:aad:audit"
((category="RoleManagement" (activityDisplayName="Add member to role*" OR activityDisplayName="Add eligible member to role*"))
OR (category="GroupManagement" activityDisplayName="Add member to group"))
("initiatedBy.user.userPrincipalName"="<upn>" OR "targetResources{}.userPrincipalName"="<upn>")
| table _time category activityDisplayName result initiatedBy.user.userPrincipalName targetResources{}.userPrincipalName targetResources{}.displayName
| sort _time
// "Add member to group": check the group with Get-MgGroup -GroupId <id> -Property IsAssignableToRole.
// Azure RBAC writes: the Azure Activity log input of the Microsoft Cloud Services add-on; filter operationName
// on Microsoft.Authorization/roleAssignments/write (field names depend on the input type).
Stage 7 · Other accountsOther users signing in from the attacker's IPs or user agent
index=<index> sourcetype="azure:aad:signin" ipAddress IN ("<ip1>","<ip2>")
| stats count as attempts count(eval('status.errorCode'==0)) as successes min(_time) as first by userPrincipalName
| sort first
| convert ctime(first)
Stage 8 · Tenant changesConditional Access, users, apps, credentials, federation, partners, transport rules and audit settings changed in the window
index=<index> sourcetype="azure:aad:audit" earliest="<window start>" latest="<window end>"
(activityDisplayName="*Conditional Access policy*" OR activityDisplayName="*named location*" OR activityDisplayName="*security defaults*"
OR activityDisplayName IN ("Add user","Add application","Add service principal","Add service principal credentials",
"Add owner to application","Add owner to service principal","Set federation settings on domain","Set domain authentication",
"Add unverified domain","Verify domain","Add partner to company")
OR activityDisplayName="*Certificates and secrets management*" OR activityDisplayName="*authorization policy*")
| table _time category activityDisplayName result initiatedBy.user.userPrincipalName initiatedBy.app.displayName initiatedBy.user.ipAddress targetResources{}.displayName
| sort _time
// Exchange tenant settings
index=<index> sourcetype="o365:management:activity" earliest="<window start>" latest="<window end>"
Operation IN ("New-TransportRule","Set-TransportRule","Remove-TransportRule","New-InboundConnector","Set-InboundConnector",
"Set-AdminAuditLogConfig","Set-OrganizationConfig","Set-AuthenticationPolicy","Set-MailboxAuditBypassAssociation",
"Add-RoleGroupMember","New-ManagementRoleAssignment","New-ApplicationAccessPolicy")
| table _time Operation UserId ClientIP Parameters{}.Name Parameters{}.Value
// Set-AdminAuditLogConfig with UnifiedAuditLogIngestionEnabled false: the UAL was switched off; your evidence ends there.
Stage 9 · Still activeToken use after the revocation time
revokeSignInSessions invalidates refresh tokens and session cookies; access tokens already issued live until they expire, 60–90 minutes by default and up to 28 hours on CAE-capable clients unless CAE pushed the revocation. A success after that window means containment missed a device, an app grant or a refresh token.index=<index> sourcetype="azure:monitor:aad" category IN ("SignInLogs","NonInteractiveUserSignInLogs")
properties.userPrincipalName="<upn>" properties.status.errorCode=0
earliest="<revocation time as %m/%d/%Y:%H:%M:%S>"
| stats count min(_time) as first max(_time) as last by category properties.ipAddress properties.appDisplayName properties.incomingTokenType
| convert ctime(first) ctime(last)
Act
Splunk is where you hunt, not where you act. Disable first, revoke second, for every account in scope in one pass, from the tools that can.
T+0–15 · ContainHand the scope to the tools that act
Accounts and attacker sources: the T+0–15 Entra commands and a Conditional Access block with the break-glass accounts excluded. Victim devices: isolate them with your EDR (see its tab). With Splunk SOAR, a playbook can run the Entra and EDR actions for the whole list.
Hunt
Stage 1 · First sign-inSuccessful sign-ins for the account, interactive and non-interactive, earliest first per IP and client
FROM logs-azure.signinlogs-*
| WHERE azure.signinlogs.properties.user_principal_name == "<upn>" AND event.outcome == "success"
| STATS first_seen = MIN(@timestamp), last_seen = MAX(@timestamp), signins = COUNT(*), categories = VALUES(azure.signinlogs.category)
BY source.ip, user_agent.original, azure.signinlogs.properties.app_display_name, azure.signinlogs.properties.authentication_protocol
| SORT first_seen ASC
Stage 2 · MFAHow MFA was satisfied; PRT from an unmanaged device; cookie theft on the victim's device
FROM logs-azure.signinlogs-*
| WHERE azure.signinlogs.properties.user_principal_name == "<upn>" AND source.ip == TO_IP("<ip>")
| KEEP @timestamp, source.ip, azure.signinlogs.properties.app_display_name, azure.signinlogs.properties.authentication_protocol,
azure.signinlogs.properties.authentication_requirement, azure.signinlogs.properties.incoming_token_type
// PRT presented from a device the integration shows as unmanaged
FROM logs-azure.signinlogs-*
| WHERE azure.signinlogs.properties.user_principal_name == "<upn>"
AND azure.signinlogs.properties.incoming_token_type == "primaryRefreshToken"
AND azure.signinlogs.properties.device_detail.is_managed != true
| KEEP @timestamp, source.ip, azure.signinlogs.properties.app_display_name, azure.signinlogs.properties.device_detail.device_id, azure.signinlogs.properties.device_detail.trust_type
// Endpoint (EQL, same logic as Elastic's prebuilt rule "Potential Cookies Theft via Browser Debugging")
process where event.type == "start" and process.name : ("chrome.exe", "msedge.exe") and
process.args : "--remote-debugging-port=*" and process.args : "--user-data-dir=*"
// a hit on the victim's device: treat every session on it as stolen
Stage 3 · PersistenceMFA methods, TAPs, device registrations, app consents and credentials, inbox rules, forwarding, delegation, transport rules
UpdateInboxRules; only PowerShell logs New-InboxRule.FROM logs-azure.auditlogs-*
| WHERE azure.auditlogs.operation_name IN ("User registered security info", "Admin registered security info",
"User deleted security info", "Register device", "Add registered owner to device",
"Add registered users to device", "Consent to application", "Add delegated permission grant",
"Add app role assignment grant to user", "Add app role assignment to service principal", "Add service principal credentials")
OR azure.auditlogs.operation_name LIKE "*Certificates and secrets management*"
| KEEP @timestamp, azure.auditlogs.operation_name, azure.auditlogs.result_reason, azure.auditlogs.properties.initiated_by.user.userPrincipalName,
azure.auditlogs.properties.initiated_by.user.ipAddress, azure.auditlogs.properties.target_resources.0.display_name
| SORT @timestamp ASC
// result_reason containing "temporary access pass": a TAP was issued; who, from where, and was it redeemed from the attacker IP?
// Exchange: rules, forwarding, delegation, transport rules
FROM logs-o365.audit-*
| WHERE o365.audit.UserId == "<upn>"
AND o365.audit.Operation IN ("New-InboxRule", "Set-InboxRule", "UpdateInboxRules", "Set-Mailbox",
"Add-MailboxPermission", "Add-MailboxFolderPermission", "Add-RecipientPermission",
"New-TransportRule", "Set-TransportRule", "New-InboundConnector", "Set-InboundConnector")
| KEEP @timestamp, o365.audit.Operation, o365.audit.ClientIP, o365.audit.Parameters
// keep Set-Mailbox rows whose Parameters mention ForwardingSmtpAddress, ForwardingAddress or DeliverToMailboxAndForward
Stage 4 · Data accessMail read and files downloaded, by client IP, with client string, logon type and deletions
ClientInfoString names the tool; MailAccessType (Sync means whole folders) and IsThrottled (the log under-counts past 1,000 in 24 h) sit inside OperationProperties and are read per document.FROM logs-o365.audit-*
| WHERE o365.audit.UserId == "<upn>"
AND o365.audit.Operation IN ("MailItemsAccessed", "FileDownloaded", "FileSyncDownloadedFull", "FileAccessed",
"SearchQueryInitiatedExchange", "MoveToDeletedItems", "SoftDelete", "HardDelete")
| STATS events = COUNT(*), first = MIN(@timestamp), last = MAX(@timestamp), clients = VALUES(o365.audit.ClientInfoString), logon_types = VALUES(o365.audit.LogonType)
BY o365.audit.Operation, o365.audit.Workload, o365.audit.ClientIP
| SORT first ASC
// Open the MailItemsAccessed documents from the attacker IP and read OperationProperties for MailAccessType and IsThrottled.
Stage 5 · OutboundMail sent from the account (Audit Premium) and files shared out of OneDrive and SharePoint
Get-MessageTraceV2 is the complete source for recipients.FROM logs-o365.audit-*
| WHERE o365.audit.UserId == "<upn>"
AND o365.audit.Operation IN ("Send", "SendAs", "SendOnBehalf", "SharingSet", "SharingInvitationCreated",
"AnonymousLinkCreated", "SecureLinkCreated", "AddedToSecureLink", "CompanyLinkCreated", "SharingLinkCreated")
| KEEP @timestamp, o365.audit.Operation, o365.audit.Workload, o365.audit.ClientIP, o365.audit.ObjectId, o365.audit.TargetUserOrGroupName, o365.audit.TargetUserOrGroupType
| SORT @timestamp ASC
// Recipients and subjects of every message sent: Get-MessageTraceV2 -SenderAddress <upn> (10 days per query, 90 days back).
Stage 6 · PrivilegeRole assignments to or by the account, including PIM and role-assignable groups
FROM logs-azure.auditlogs-*
| WHERE azure.auditlogs.operation_name LIKE "Add member to role*"
OR azure.auditlogs.operation_name LIKE "Add eligible member to role*"
OR azure.auditlogs.operation_name == "Add member to group"
| KEEP @timestamp, azure.auditlogs.category, azure.auditlogs.operation_name, azure.auditlogs.properties.initiated_by.user.userPrincipalName,
azure.auditlogs.properties.target_resources.0.display_name
| SORT @timestamp ASC
// open each hit to read the target user and role under azure.auditlogs.properties.target_resources
// "Add member to group": check the group with Get-MgGroup -GroupId <id> -Property IsAssignableToRole
Stage 7 · Other accountsOther users signing in from the attacker's IPs or user agent
FROM logs-azure.signinlogs-*
| WHERE source.ip IN (TO_IP("<ip1>"), TO_IP("<ip2>")) // or: user_agent.original == "<attacker user agent>"
| STATS attempts = COUNT(*), first = MIN(@timestamp) BY azure.signinlogs.properties.user_principal_name, event.outcome
| SORT first ASC
Stage 8 · Tenant changesConditional Access, users, apps, credentials, federation, partners, transport rules and audit settings changed in the window
FROM logs-azure.auditlogs-*
| WHERE @timestamp >= TO_DATETIME("<window start>") AND @timestamp <= TO_DATETIME("<window end>")
AND (azure.auditlogs.operation_name LIKE "*Conditional Access policy*" OR azure.auditlogs.operation_name LIKE "*named location*"
OR azure.auditlogs.operation_name LIKE "*security defaults*" OR azure.auditlogs.operation_name LIKE "*Certificates and secrets management*"
OR azure.auditlogs.operation_name LIKE "*authorization policy*"
OR azure.auditlogs.operation_name IN ("Add user", "Add application", "Add service principal", "Add service principal credentials",
"Add owner to application", "Add owner to service principal", "Set federation settings on domain", "Set domain authentication",
"Add unverified domain", "Verify domain", "Add partner to company"))
| KEEP @timestamp, azure.auditlogs.category, azure.auditlogs.operation_name, azure.auditlogs.properties.initiated_by.user.userPrincipalName,
azure.auditlogs.properties.initiated_by.app.displayName, azure.auditlogs.properties.initiated_by.user.ipAddress,
azure.auditlogs.properties.target_resources.0.display_name
| SORT @timestamp ASC
// Exchange tenant settings
FROM logs-o365.audit-*
| WHERE @timestamp >= TO_DATETIME("<window start>") AND @timestamp <= TO_DATETIME("<window end>")
AND o365.audit.Operation IN ("New-TransportRule", "Set-TransportRule", "Remove-TransportRule", "New-InboundConnector", "Set-InboundConnector",
"Set-AdminAuditLogConfig", "Set-OrganizationConfig", "Set-AuthenticationPolicy", "Set-MailboxAuditBypassAssociation",
"Add-RoleGroupMember", "New-ManagementRoleAssignment", "New-ApplicationAccessPolicy")
| KEEP @timestamp, o365.audit.Operation, o365.audit.UserId, o365.audit.ClientIP, o365.audit.Parameters
// Set-AdminAuditLogConfig with UnifiedAuditLogIngestionEnabled false: the UAL was switched off; your evidence ends there.
Stage 9 · Still activeToken use after the revocation time
revokeSignInSessions invalidates refresh tokens and session cookies; access tokens already issued live until they expire, 60–90 minutes by default and up to 28 hours on CAE-capable clients unless CAE pushed the revocation. A success after that window means containment missed a device, an app grant or a refresh token.FROM logs-azure.signinlogs-*
| WHERE @timestamp > TO_DATETIME("<revocation time, ISO 8601>")
AND azure.signinlogs.properties.user_principal_name == "<upn>" AND event.outcome == "success"
| STATS signins = COUNT(*), first = MIN(@timestamp), last = MAX(@timestamp)
BY azure.signinlogs.category, source.ip, azure.signinlogs.properties.app_display_name, azure.signinlogs.properties.incoming_token_type
| SORT first ASC
Act
Disable first, revoke second, for every account in scope in one pass; verify it held before re-enabling anything. A victim's device that ran an infostealer is rebuilt, not cleaned.
T+0–15 · ContainDisable and revoke every account in scope — in Entra
Run the T+0–15 commands for every account in scope, and block the attacker's source in Conditional Access with the break-glass accounts excluded.
T+0–15 · ContainIsolate every victim device in one call (infostealer or cookie theft)
Elastic Defend: host › Take action › Isolate host, or isolate in the response console. The API takes up to 250 endpoint IDs per call. Wazuh: an isolation active-response script run through PUT /active-response — Wazuh ships no isolation script, so write and test one before you need it.
POST /api/endpoint/action/isolate
{"endpoint_ids": ["<endpoint-id-1>", "<endpoint-id-2>"], "comment": "IR case #<n>"}
Day 1–7 · ForensicsCollect, stop it running, then rebuild the device
Response console: upload then execute for Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d (RAM, pagefile, processes, triage files) and Get-PersistenceSnapshot.ps1 a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df (-Zip for chain of custody); get-file the output.
processes
upload --file MagnetRESPONSEv172_Self_Extracting_Archive.exe
upload --file Get-PersistenceSnapshot.ps1
execute --command "MagnetRESPONSEv172_Self_Extracting_Archive.exe" --timeout 30m
execute --command "powershell -ExecutionPolicy Bypass -File Get-PersistenceSnapshot.ps1 -Zip"
get-file --path <collector output zip>
kill-process --pid <pid>
By Incident Type: Specific Variations
1. Standard Account Takeover (credential reuse, infostealer log, password spray)
AuthenticationRequirement singleFactorAuthentication and no AiTM artefacts. No MFA prompt means MFA was not required for that app, location or user (a Conditional Access gap or exclusion), or the user approved a push; a TOTP code cannot be replayed without a prompt. Basic authentication for EWS, POP, IMAP, ActiveSync and remote PowerShell has been off in Exchange Online since 2022–23; the one legacy path still subject to a retirement timeline is SMTP AUTH client submission, so check Get-CASMailbox <upn> | fl SmtpClientAuthenticationDisabled and Get-TransportConfig | fl SmtpClientAuthenticationDisabled before blaming "legacy protocols".Variation: Follow the universal timeline. Add: block the source IP in a Conditional Access named location (break-glass excluded) and close the MFA gap that let a password alone through. Hunt for the same IP across all users in the tenant — password spray is rarely a single-user event — and check the user's credentials against infostealer-log feeds; a stealer log means the device is in scope too.
2. AiTM Proxy Phishing (Evilginx, Tycoon 2FA, Cloudflare Worker)
Variation: Token revocation is the only meaningful containment — the credential itself wasn't useful to the attacker, the cookie was. After eradication, accelerate FIDO2 rollout for the affected role — origin binding makes the proxy class structurally impossible.
3. Device-Code Phishing (Storm-2372, EvilTokens, FlowerStorm)
authenticationProtocol = "deviceCode" in Entra sign-in logs, often successful with no MFA challenge (victim's session satisfied IdP), attacker's device immediately requests tokens.Variation: Containment is identical — revoke sessions, kill device registrations, audit OAuth grants. Critical posture fix during T+4–24h: add Conditional Access policy restricting Device Authorization Grant flow to apps/users with legitimate need. Block entirely for privileged roles. This is config-only, no licensing. Response checklist: Device Code Abuse Response (12 steps). Detection and prevention: Device Code Abuse — 8 Sentinel hunts (including IP-divergence detection that catches the attacker's token use after the victim's auth) and the step-by-step CA policy build with Report-only rollout.
4. OAuth Consent Abuse (rogue app granted permissions)
Mail.Read, Files.ReadWrite.All, offline_access) consented by a regular user; access continues despite password reset.Variation: Revoking the user's sessions and resetting the password is insufficient — the app holds its own refresh token. The app registration lives in the attacker's tenant and you cannot delete it; what is in your tenant is the service principal (the Enterprise App). Disable it first (Update-MgServicePrincipal -AccountEnabled:$false), which stops token issuance for every user who consented, export its grants and app-role assignments, remove them (Remove-MgOauth2PermissionGrant, Remove-MgServicePrincipalAppRoleAssignedTo), then delete the SP (Remove-MgServicePrincipal). Deleting first destroys the grant records. Check whether the same app id was consented by anyone else (Get-MgServicePrincipalOauth2PermissionGrant). After eradication: restrict user consent to verified publishers and low-impact scopes, require admin consent for the rest, and alert on "Consent to application".
5. Refresh-Token / PRT Persistence (post-password-reset re-entry)
Variation: The previous IR was incomplete — the account was revoked before it was disabled, the registered device wasn't removed, or a FOCI refresh token kept minting tokens for Office, Teams and Outlook from the attacker's machine. Re-run the full T+0–15 playbook in order (disable, revoke, devices, grants, export), and run the Stage 2 and Stage 9 hunts for IncomingTokenType == "primaryRefreshToken" from an unmanaged device and for the FOCI app ids from the attacker IP. Refresh-token lifetime cannot be reduced: those policies were retired in 2021 and the 90-day inactivity window is fixed. The levers are Conditional Access sign-in frequency for the user's apps, persistent browser session off, and Token Protection so the next stolen token is bound to the device it was issued to.
6. Non-Human Identity Compromise (API key, service principal, NHI)
Variation: No user to "reset" and no sessions to revoke: app-only tokens are minted from the credential, not from a refresh token, so Revoke-MgUserSignInSession has nothing to act on. Identify the named owner from your NHI inventory. Disable the service principal (Update-MgServicePrincipal -AccountEnabled:$false) so no new tokens are issued, remove every secret and certificate (Remove-MgServicePrincipalPassword, Remove-MgServicePrincipalKey; on the app object Remove-MgApplicationPassword, Remove-MgApplicationKey), then issue new ones and re-enable; an access token already issued lives out its 60–90 minutes. Audit AADServicePrincipalSignInLogs and MicrosoftGraphActivityLogs for the SP's calls by IP. If no inventory exists, the key may be orphaned — coordinate with engineering to identify systems calling it before the disable breaks production. After eradication: enforce a rotation policy (≤ 12 months for SP secrets, ≤ 6 months for API keys), prefer workload identity federation or managed identities over secrets, and assign a named owner per credential.
7. Privileged Account Compromise (Global Admin, Subscription Owner)
Variation: Tenant-wide incident, not a user incident. Assume the attacker has tenant takeover capability and has already placed a back door that survives every per-user action. Containment escalates: disable then revoke every privileged identity (break-glass accounts excluded and their passwords rotated by hand), then audit the window for the doors that outlive a user: federation (Get-MgDomain | fl Id, AuthenticationType and Get-MgDomainFederationConfiguration: a new federated domain or a changed signing certificate is a golden-SAML persistence), pass-through authentication agents (a rogue PTA agent sees every password; list them in Entra Connect health and Get-MgBetaOnPremisesPublishingProfileAgent), the Entra Connect server itself (its sync account holds directory write rights; treat the server as tier 0 and in scope), GDAP and DAP partner relationships (Entra › Delegated admin partners; "Add partner to company" in the audit log), Conditional Access as a diff, not a glance (Get-MgIdentityConditionalAccessPolicy -All | ConvertTo-Json -Depth 10 against last week's export; look for new exclusions, named locations and report-only flips), the UAL ingestion switch (Get-AdminAuditLogConfig | fl UnifiedAuditLogIngestionEnabled and "Set-AdminAuditLogConfig" in the admin audit), transport rules and inbound connectors (Get-TransportRule, Get-InboundConnector, changed in the window), newly created accounts with roles, and credentials added to existing privileged apps (Hour 1–4). Engage external IR if available. Notify the regulator: this is often a reportable major incident under NIS2.
8. Vendor / Third-Party Identity Exposure (their breach, your problem)
Variation: You don't control their identity store, so you can't revoke at the source: refresh tokens for a B2B user are not revoked in your tenant, only in their home tenant (Microsoft identity platform, refresh tokens). What you control is your side of the trust. Block the guest objects (Update-MgUser -AccountEnabled:$false works on them), then use cross-tenant access settings (Entra › External Identities › Cross-tenant access settings): add the vendor's tenant as an organisation and set inbound B2B collaboration to blocked, or stop trusting their MFA and device claims so your Conditional Access re-challenges them. Scope the exposure by their tenant, not by user: in sign-in logs HomeTenantId == "<vendor tenant id>" and CrossTenantAccessType != "none" lists every vendor identity that touched you; in Get-MgServicePrincipal, AppOwnerOrganizationId equal to their tenant id lists every app of theirs that holds grants here. Restrict their app access to read-only during validation and contact their security team with specific exposure data (not generic "improve your security"). See Vendor Breach Response for the full vendor-IR workflow.
Microsoft Graph PowerShell — Containment
# 1. Disable (no new tokens; CAE critical event). Synced user: Disable-ADAccount + delta sync instead
Update-MgUser -UserId <upn> -AccountEnabled:$false
# 2. Revoke refresh tokens and session cookies; issued access tokens live 60-90 min (CAE: up to 28 h)
Revoke-MgUserSignInSession -UserId <upn>
Confirm-MgRiskyUserCompromised -UserIds (Get-MgUser -UserId <upn>).Id
# 3. MFA methods: list with ids, remove per type (no generic remove)
Get-MgUserAuthenticationMethod -UserId <upn> | Select-Object Id, AdditionalProperties
Remove-MgUserAuthenticationPhoneMethod -UserId <upn> -PhoneAuthenticationMethodId <id>
Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod -UserId <upn> -MicrosoftAuthenticatorAuthenticationMethodId <id>
Remove-MgUserAuthenticationTemporaryAccessPassMethod -UserId <upn> -TemporaryAccessPassAuthenticationMethodId <id>
# 4. Registered devices added in the window: disable now, delete after the case
Get-MgUserRegisteredDevice -UserId <upn> -All |
ForEach-Object { Get-MgDevice -DeviceId $_.Id } |
Where-Object { $_.RegistrationDateTime -gt $compromiseStart } |
ForEach-Object { Update-MgDevice -DeviceId $_.Id -AccountEnabled:$false }
# 5. OAuth: date consents from the audit log (startTime on the grant is ignored by Graph),
# disable the SP, then remove grants and app-role assignments
$sp = Get-MgServicePrincipal -Filter "appId eq '<app id>'"
Update-MgServicePrincipal -ServicePrincipalId $sp.Id -AccountEnabled:$false
Get-MgUserOauth2PermissionGrant -UserId <upn> -All | Where-Object ClientId -eq $sp.Id |
ForEach-Object { Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $_.Id }
Get-MgUserAppRoleAssignment -UserId <upn> -All | Where-Object ResourceId -eq $sp.Id |
ForEach-Object { Remove-MgUserAppRoleAssignment -UserId <upn> -AppRoleAssignmentId $_.Id }
# 6. Reset (Hour 4-24), then re-enrol via a one-time TAP, re-enable, revoke again
Update-MgUser -UserId <upn> -PasswordProfile @{ ForceChangePasswordNextSignIn = $true; Password = "<new-temp>" }
New-MgUserAuthenticationTemporaryAccessPassMethod -UserId <upn> -BodyParameter @{ isUsableOnce = $true; lifetimeInMinutes = 60 }
Update-MgUser -UserId <upn> -AccountEnabled:$true
Revoke-MgUserSignInSession -UserId <upn>
Exchange / Sign-in Hunting
# Inbox rules across all mailboxes, hidden ones included (BEC persistence)
Get-Mailbox -ResultSize Unlimited |
Get-InboxRule -IncludeHidden | Where-Object {
$_.ForwardTo -or $_.RedirectTo -or $_.ForwardAsAttachmentTo -or $_.DeleteMessage -or $_.MoveToFolder
}
# Forwarding (separate persistence channel)
Get-Mailbox -ResultSize Unlimited |
Where-Object { $_.ForwardingSmtpAddress -or $_.ForwardingAddress } |
Format-List PrimarySmtpAddress, ForwardingSmtpAddress, ForwardingAddress, DeliverToMailboxAndForward
# Unified audit log export that does not truncate (ReturnLargeSet pages to 50,000)
$sid = "IR-<n>"; do {
$page = Search-UnifiedAuditLog -StartDate $compromiseStart -EndDate (Get-Date) -UserIds <upn> `
-SessionId $sid -SessionCommand ReturnLargeSet -ResultSize 5000
$page | Export-Csv .\ual-<upn>.csv -NoTypeInformation -Append
} while ($page)
# Message trace: 10 days per query, 90 days back
Get-MessageTraceV2 -SenderAddress <upn> -StartDate <start> -EndDate <end> -ResultSize 5000
# Device-code phishing hunt (KQL, Sentinel); redemptions are non-interactive
union SigninLogs, AADNonInteractiveUserSignInLogs
| where AuthenticationProtocol == "deviceCode"
| project TimeGenerated, UserPrincipalName, IPAddress, AppDisplayName, ResultType
# Tenant-wide admin consents (AllPrincipals)
Get-MgOauth2PermissionGrant -All -Filter "consentType eq 'AllPrincipals'" |
Select-Object ClientId, ResourceId, Scope
# Role, eligible-role and group-membership changes in the window, every page
Get-MgAuditLogDirectoryAudit -All `
-Filter "activityDateTime ge $start and (category eq 'RoleManagement' or category eq 'GroupManagement')"
Free / Open-Source Tools for Identity IR
- CISA ScubaGear — M365 / Entra compliance baseline (run during T+24–48h hardening)
- Cazadora — OAuth application hunting in M365
- O365 Investigation Tooling — archived;
DumpDelegatesandForwardingRules.ps1andRemediateBreachedAccount.ps1depend on the retired MSOnline and AzureAD modules and no longer run. Use the native Exchange Online and Graph cmdlets in the containment reference above, and keep that script tested in your own tenant instead - Check by CyberDrain — AiTM proxy detection browser extension (post-IR rollout)
- Canarytokens — identity tripwires (post-IR detection layer)
- HIBP — domain monitoring + exposure check during T+4–24h
- zolder.io AITMWorker + honeytokens — AiTM detection PoC and defender guide
- Microsoft Graph PowerShell SDK — required for all containment commands above
Engagement Options
| When | Engagement | Duration | Investment |
|---|---|---|---|
| In an active incident Active | IR assist — remote hands on the T+0–72h timeline. Direct work on containment, scope, eradication. Hand-off to internal team for posture work. | 2–5 days | €5,000 – 12,500 |
| Post-incident | Post-mortem + runbook codification + detection-rule updates + tabletop. Make sure the same incident shape cannot recur. | 3–5 days | €3,750 – 6,250 |
| Pre-incident | Identity-IR readiness: scripts pre-staged, named owners, tabletop, runbook delivered. Test Revoke-MgUserSignInSession against a real tenant before you need it at 3 AM. |
2–3 days | €2,500 – 3,750 |
Revoke-MgUserSignInSession should not be at 3 AM during a real incident. Run it on a test account during business hours first. Pre-stage the scripts. Document the named owners. Then when the alert fires, the response is muscle memory, not improvisation.
After the Incident: Where to Go Next
This playbook gets you through the incident. The next step is making sure it doesn't recur in the same shape. Hand off to the relevant prevention playbook based on root cause:
Identity Hardening
The prevention companion to this IR playbook. FIDO2 universal, Conditional Access architecture, OAuth consent posture, NHI program — closes the six branches of the identity attack tree.
BEC Defense
If the initial access was email-borne (phishing, impersonation, malicious attachment).
Supply Chain Security
If the initial access came through a vendor, MSP, B2B guest, or third-party SaaS.
Configuration Drift
If a Conditional Access policy gap, OAuth consent policy, or MFA exception enabled the breach.
Cloud Security
If a cloud key, service principal, or admin role compromise was the root cause.