How Far Has It Got?
| # | Stage | ATT&CK | Question | Where to look |
|---|---|---|---|---|
| 1 | Mailbox access | T1078.004 T1550.004 | When did the attacker first access the mailbox, and how: password, infostealer, or a replayed session cookie from a proxy phishing page? | Entra sign-in logs (30 d on P1/P2, 7 d free: export now); Defender alert “Stolen session cookie was used” |
| 2 | Rules | T1114.003 T1098.002 | Did they create inbox rules, forwarding, delegates, transport rules or an OAuth grant? | Get-InboxRule; Unified Audit Log (180 d Standard, 1 y Premium); Entra audit log |
| 3 | Reading | T1114.002 | Which threads did they read: invoices, payments, contracts? | Unified Audit Log (MailItemsAccessed, by session) |
| 4 | Impersonation | T1534 T1656 | Did they send mail as the user, or from a lookalike domain? | Send audit records; Sent Items and Drafts; message trace (10 d live, 90 d historical); dnstwist list |
| 5 | Payment request | T1656 | Did anyone receive a request to pay or change bank details? | Payment-thread hunt (stage 3 message ids against mail flow); finance team |
| 6 | Money moved | T1657 | Has a payment been made: amount, when, to which bank? Were any supplier bank details changed in the last 90 days? | Finance; payment system; vendor master change history |
| 7 | Recall window | T1657 | Can the payment still be recalled? | Our bank's fraud department, on the number on file (SWIFT gpi stop-and-recall; SEPA SCT Recall) |
| 8 | Wider targets | T1534 | Were other mailboxes, clients or partners targeted from here? | Sign-ins from the attacker's ASN; message-trace recipients; partner reports |
| 9 | Still active | T1078.004 | Is any session still in use after containment? | Sign-ins and mailbox audit after the revocation time, re-run at T+2h and T+24h |
Respond: The First 60 Minutes
.eml with full headers from both ends (victim's Sent Items and a recipient's inbox, via Content Search export) so Authentication-Results, Message-ID and Reply-To survive. Raise the mailbox audit age limit from its 90-day default; it only applies forward, so do it now. Native cmdlets only: the archived O365 investigation scripts (DumpDelegatesandForwardingRules.ps1, RemediateBreachedAccount.ps1) depend on MSOnline and AzureAD, retired in 2025. Why: the creation times of the attacker's rules bound the access window, and once purged or reset they are gone as evidence.Set-Mailbox -Identity <upn> -LitigationHoldEnabled $true
Set-Mailbox -Identity <upn> -AuditLogAgeLimit 365
Search-UnifiedAuditLog -StartDate (Get-Date).AddDays(-180) -EndDate (Get-Date) -UserIds <upn> `
-SessionId "case<n>-ual" -SessionCommand ReturnLargeSet -ResultSize 5000 |
Select-Object -ExpandProperty AuditData | Add-Content .\case<n>-ual.jsonl # repeat until it returns nothing
Start-HistoricalSearch -ReportTitle "case<n>-trace" -ReportType MessageTrace -StartDate (Get-Date).AddDays(-90) `
-EndDate (Get-Date) -SenderAddress <upn> -NotifyAddress <your upn>
Get-MgAuditLogSignIn -Filter "userPrincipalName eq '<upn>'" -All | Export-Csv .\case<n>-signins.csv
# Capture, as they are now
Get-InboxRule -Mailbox <upn> | Export-Clixml .\case<n>-rules.xml
Get-Mailbox -Identity <upn> | fl ForwardingSmtpAddress, ForwardingAddress, DeliverToMailboxAndForward, GrantSendOnBehalfTo, DefaultAuditSet, AuditOwner
Get-MailboxPermission -Identity <upn> | Where-Object { -not $_.IsInherited }
Get-RecipientPermission -Identity <upn>
Get-MailboxFolderPermission -Identity "<upn>:\Inbox"
Get-MgUserOauth2PermissionGrant -UserId <upn>
Get-MgUserAuthenticationMethod -UserId <upn>
Get-MgUserRegisteredDevice -UserId <upn>
Get-Mailbox -ResultSize Unlimited | Get-InboxRule | Where-Object {
$_.ForwardTo -or $_.ForwardAsAttachmentTo -or $_.RedirectTo
} | Select-Object MailboxOwnerId, Name, ForwardTo, RedirectTo
Get-Mailbox -ResultSize Unlimited -Filter 'ForwardingSmtpAddress -ne $null -or ForwardingAddress -ne $null' |
Select-Object UserPrincipalName, ForwardingSmtpAddress, ForwardingAddress, DeliverToMailboxAndForward
Get-TransportRule | Where-Object { $_.WhenChanged -gt (Get-Date).AddDays(-30) } | Select-Object Name, WhenChanged, BlindCopyTo, RedirectMessageTo
# Other accounts seen from the attacker's source (Sentinel / Log Analytics; ResultType is a string)
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > datetime(<compromise_start>)
| where IPAddress in (<attacker_ips>) or AutonomousSystemNumber in (<attacker_asns>)
| where ResultType == "0"
| summarize FirstSeen=min(TimeGenerated), Sessions=make_set(SessionId, 5) by UserPrincipalName, IPAddress
Update-MgUser -UserId <upn> -AccountEnabled:$false
Update-MgUser -UserId <upn> -PasswordProfile @{ Password = "<random>"; ForceChangePasswordNextSignIn = $true }
Revoke-MgUserSignInSession -UserId <upn>
Get-InboxRule -Mailbox <upn> | Remove-InboxRule -Confirm:$false
Set-Mailbox -Identity <upn> -ForwardingSmtpAddress $null -ForwardingAddress $null -DeliverToMailboxAndForward $false
Remove-MailboxPermission / Remove-RecipientPermission / Remove-MailboxFolderPermission # for every grant not in the baseline
Get-MgUserOauth2PermissionGrant -UserId <upn> | ForEach-Object { Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $_.Id }
Remove-MgUserAuthentication<Phone|MicrosoftAuthenticator|SoftwareOath|Fido2>Method # the ones added in the window
Get-MgUserRegisteredDevice -UserId <upn> | ForEach-Object { Update-MgDevice -DeviceId $_.Id -AccountEnabled:$false }
Set-CASMailbox -Identity <upn> -ActiveSyncEnabled $false -ImapEnabled $false -PopEnabled $false -SmtpClientAuthenticationDisabled $true
let revoked = datetime(<revocation_time>);
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > revoked
| where UserPrincipalName in~ (<evicted_upns>) and ResultType == "0"
| project TimeGenerated, Type, UserPrincipalName, IPAddress, AutonomousSystemNumber, AppDisplayName, SessionId
// Mailbox activity after revocation (arrives ~1 h late):
OfficeActivity
| where TimeGenerated > revoked and (UserId in~ (<evicted_upns>) or MailboxOwnerUPN in~ (<evicted_upns>))
| extend SourceIP = coalesce(Client_IPAddress, ClientIP)
| summarize Operations=make_set(Operation, 10), Last=max(TimeGenerated) by UserId, SourceIP, SessionId
Authentication-Results header on the copy the recipient received (the .eml from T+5–15): it records the SPF, DKIM and DMARC verdicts the receiving server actually computed for that message, which is the evidence; a DNS lookup of the sending domain today is not. Check Reply-To and the envelope sender against the header From on the same copy. Why: what they sent decides who else must be warned; what they read decides whether this is a personal-data breach and which supplier gets paid next.Hour 1–24: Verify, Re-enable, Notify
a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df with -Zip for the persistence the stealer set, and Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d run elevated for RAM, pagefile and triage files; push either with your EDR's file-transfer (Live Response putfile, RTR put, a RemoteOps script) or from removable media. The kill and remove actions under Hunt & Act stop the malware running and preserve evidence; they are not the fix. If the cookie was replayed from a proxy phishing page instead, the device is clean and the eviction has to kill the session, not the laptop. Why: a cleaned device you can't prove is clean keeps feeding the attacker new credentials..eml evidence) and a report to the Fraudehelpdesk; in the US, the IC3 complaint from T+0–5. Tell the insurer the same day: cyber and crime policies commonly pay social-engineering and funds-transfer fraud under a separate sub-limit, lower than the policy limit and often conditional on an out-of-band verification having been attempted, so read the clause before promising finance a recovery figure. Why: the clocks run from awareness, not from close.After the Incident: Feed the Case Back
Hunt & Act by Platform
Hunt
Stage 1 · Mailbox accessSuccessful sign-ins for the user, first seen per IP
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| where UserPrincipalName =~ "<upn>" and ResultType == "0"
| summarize FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated), SignIns=count(),
Apps=make_set(AppDisplayName, 10), Sessions=make_set(SessionId, 10)
by IPAddress, AutonomousSystemNumber, Location, UserAgent
| order by FirstSeen asc
Stage 1 · Mailbox accessAiTM: one session replayed from two networks, MFA carried by the token
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| where UserPrincipalName =~ "<upn>" and ResultType == "0" and isnotempty(SessionId)
| summarize ASNs=dcount(AutonomousSystemNumber), IPs=make_set(IPAddress, 10),
First=min(TimeGenerated), Last=max(TimeGenerated) by SessionId
| where ASNs > 1
// MFA satisfied by a claim in the token rather than performed in this sign-in:
SigninLogs
| where TimeGenerated > ago(30d) and UserPrincipalName =~ "<upn>" and ResultType == "0"
| mv-expand step = AuthenticationDetails
| where tostring(step.authenticationStepResultDetail) == "MFA requirement satisfied by claim in the token"
| project TimeGenerated, IPAddress, AutonomousSystemNumber, AppDisplayName, SessionId, UserAgent
// The alerts, with the account and remote IP they carry:
AlertInfo
| where Timestamp > ago(30d)
| where Title in ("Stolen session cookie was used", "Possible use of a stolen session cookie",
"Authentication request from AiTM-related phishing page")
| join kind=inner (AlertEvidence | where AccountUpn =~ "<upn>") on AlertId
| project Timestamp, Title, Severity, EntityType, RemoteIP, AccountUpn
Stage 1 · Mailbox accessVictim device: infostealer or fake installer before first access
DeviceProcessEvents
| where Timestamp between (datetime(<first access UTC>) - 14d .. datetime(<first access UTC>))
| where DeviceName =~ "<victim host>"
| where FolderPath has_any (@"\AppData\Local\Temp\", @"\Downloads\")
| where not(FileName in~ ("msedge.exe", "chrome.exe", "Teams.exe", "OneDrive.exe"))
| project Timestamp, FileName, FolderPath, ProcessCommandLine, SHA256, InitiatingProcessFileName
// Something other than the browser touching the browser's credential and cookie stores:
DeviceFileEvents
| where Timestamp between (datetime(<first access UTC>) - 14d .. datetime(<first access UTC>))
| where DeviceName =~ "<victim host>"
| where FolderPath has_any (@"\User Data\Default\Network\", @"\User Data\Default\") and FileName in~ ("Cookies", "Login Data", "Web Data")
| where not(InitiatingProcessFileName in~ ("msedge.exe", "chrome.exe", "brave.exe"))
| project Timestamp, FileName, FolderPath, InitiatingProcessFileName, InitiatingProcessCommandLine, InitiatingProcessSHA256
Stage 2 · RulesInbox rules and mailbox forwarding created or changed
OfficeActivity
| where TimeGenerated > ago(30d) and OfficeWorkload == "Exchange"
| where Operation in ("New-InboxRule", "Set-InboxRule", "Enable-InboxRule", "UpdateInboxRules", "Set-Mailbox")
| where Operation != "Set-Mailbox"
or Parameters has_any ("ForwardingSmtpAddress", "ForwardingAddress", "DeliverToMailboxAndForward")
| where UserId =~ "<upn>" or MailboxOwnerUPN =~ "<upn>" or OfficeObjectId has "<mailbox alias>"
| extend SourceIP = coalesce(Client_IPAddress, ClientIP)
| project TimeGenerated, Operation, UserId, OfficeObjectId, SourceIP, SessionId, Parameters, OperationProperties
| order by TimeGenerated asc
Stage 2 · RulesPermissions, transport rules, auto-reply, junk settings and deletions from the attacker's IP
OfficeActivity
| where TimeGenerated > ago(30d) and OfficeWorkload == "Exchange"
| where Operation in ("Add-MailboxPermission", "Add-RecipientPermission", "Add-MailboxFolderPermission",
"Set-MailboxFolderPermission", "UpdateFolderPermissions", "New-TransportRule", "Set-TransportRule",
"Set-MailboxAutoReplyConfiguration", "Set-MailboxJunkEmailConfiguration",
"HardDelete", "SoftDelete", "MoveToDeletedItems")
or (Operation == "Set-Mailbox" and Parameters has "GrantSendOnBehalfTo")
| extend SourceIP = coalesce(Client_IPAddress, ClientIP)
| where UserId =~ "<upn>" or MailboxOwnerUPN =~ "<upn>" or OfficeObjectId has "<mailbox alias>"
or SourceIP in ("<ip1>", "<ip2>")
| project TimeGenerated, Operation, UserId, MailboxOwnerUPN, OfficeObjectId, SourceIP, SessionId,
Parameters, AffectedItems
| order by TimeGenerated asc
Stage 2 · RulesOAuth consent granted in the window, and what the app did with it
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ("Consent to application", "Add OAuth2PermissionGrant", "Add delegated permission grant",
"Add app role assignment grant to user", "Add service principal")
| where tostring(InitiatedBy.user.userPrincipalName) =~ "<upn>" or tostring(TargetResources) has "<upn>"
| mv-expand TargetResources
| project TimeGenerated, OperationName, Result, App=tostring(TargetResources.displayName),
ServicePrincipalId=tostring(TargetResources.id), InitiatedIP=tostring(InitiatedBy.user.ipAddress),
Changes=TargetResources.modifiedProperties
// The app id for the next query: Get-MgServicePrincipal -ServicePrincipalId <ServicePrincipalId> | select AppId
CloudAppEvents
| where Timestamp > ago(30d) and OAuthAppId == "<app id>"
| summarize Actions=count(), First=min(Timestamp), Last=max(Timestamp),
Users=make_set(AccountDisplayName, 20) by ActionType, IPAddress
Stage 3 · ReadingMailItemsAccessed in the attacker's sessions, down to the message ids
let attackerSessions = OfficeActivity
| where TimeGenerated > ago(30d) and Operation == "MailItemsAccessed" and MailboxOwnerUPN =~ "<upn>"
| where Client_IPAddress in ("<ip1>", "<ip2>") or SessionId in ("<session id from stage 1>")
| distinct SessionId;
OfficeActivity
| where TimeGenerated > ago(30d) and Operation == "MailItemsAccessed" and MailboxOwnerUPN =~ "<upn>"
| where SessionId in (attackerSessions) or Client_IPAddress in ("<ip1>", "<ip2>")
| mv-apply p = OperationProperties on (
summarize AccessType = take_anyif(tostring(p.Value), tostring(p.Name) == "MailAccessType"),
Throttled = take_anyif(tostring(p.Value), tostring(p.Name) == "IsThrottled"))
| mv-expand Folder = Folders
| mv-expand Item = Folder.FolderItems
| project TimeGenerated, SessionId, Client_IPAddress, ClientInfoString, AccessType, Throttled,
FolderPath = tostring(Folder.Path), InternetMessageId = tostring(Item.InternetMessageId)
| order by TimeGenerated asc
// Defender XDR only (30-day retention), the raw record uses ClientIPAddress, not ClientIP:
CloudAppEvents
| where Timestamp > ago(30d) and ActionType == "MailItemsAccessed" and AccountObjectId == "<user object id>"
| where tostring(RawEventData.ClientIPAddress) in ("<ip1>", "<ip2>") or tostring(RawEventData.SessionId) in ("<session id>")
| project Timestamp, RawEventData.SessionId, RawEventData.ClientIPAddress, RawEventData.OperationProperties, RawEventData.Folders
Stage 4 · ImpersonationMail the attacker sent from the real mailbox, with recipients
OfficeActivity
| where TimeGenerated > ago(30d) and Operation in ("Send", "SendAs", "SendOnBehalf")
| where MailboxOwnerUPN =~ "<upn>" or UserId =~ "<upn>"
| project TimeGenerated, Operation, UserId, Client_IPAddress, SessionId, ClientInfoString,
Subject = tostring(Item.Subject), InternetMessageId = tostring(Item.InternetMessageId)
| order by TimeGenerated asc
// Recipients and delivery, from mail flow:
EmailEvents
| where Timestamp > ago(30d) and SenderFromAddress =~ "<upn>" and EmailDirection in ("Outbound", "Intra-org")
| project Timestamp, RecipientEmailAddress, Subject, SenderIPv4, InternetMessageId, NetworkMessageId, DeliveryAction
| order by Timestamp asc
Stage 4 · ImpersonationLookalike domains writing to your people, and envelope-versus-header mismatches
let lookalikes = dynamic(["<dnstwist domain 1>", "<dnstwist domain 2>"]);
EmailEvents
| where Timestamp > ago(30d)
| where SenderFromDomain in~ (lookalikes) or SenderMailFromDomain in~ (lookalikes)
| project Timestamp, SenderFromAddress, SenderMailFromAddress, RecipientEmailAddress, Subject,
DeliveryAction, AuthenticationDetails
// Header From claims a trusted domain, the envelope does not:
EmailEvents
| where Timestamp > ago(30d) and EmailDirection == "Inbound"
| where SenderFromDomain in~ ("<your domain>", "<supplier domain>") and SenderMailFromDomain !~ SenderFromDomain
| summarize Messages=count(), Recipients=dcount(RecipientEmailAddress), Subjects=make_set(Subject, 5)
by SenderFromDomain, SenderMailFromDomain, SenderIPv4
Stage 5 · Payment requestPayment and bank-detail threads in the window, ranked by what the attacker read
let keywords = dynamic(["invoice", "remittance", "payment", "wire", "IBAN", "bank details", "account change",
"overdue", "factuur", "betaling", "rekeningnummer"]);
let readIds = OfficeActivity
| where TimeGenerated > ago(30d) and Operation == "MailItemsAccessed" and MailboxOwnerUPN =~ "<upn>"
| where SessionId in ("<attacker session id>") or Client_IPAddress in ("<ip1>", "<ip2>")
| mv-expand Folder = Folders | mv-expand Item = Folder.FolderItems
| distinct InternetMessageId = tostring(Item.InternetMessageId);
EmailEvents
| where Timestamp between (datetime(<first access UTC>) .. datetime(<revocation time UTC>))
| where SenderFromAddress =~ "<upn>" or RecipientEmailAddress =~ "<upn>"
| join kind=leftouter (EmailAttachmentInfo | project NetworkMessageId, FileName, FileType) on NetworkMessageId
| where Subject has_any (keywords) or FileName has_any (keywords) or InternetMessageId in (readIds)
| extend AttackerRead = InternetMessageId in (readIds)
| project Timestamp, EmailDirection, SenderFromAddress, RecipientEmailAddress, Subject, FileName, AttackerRead
| order by AttackerRead desc, Timestamp asc
Stage 8 · Wider targetsOther accounts on the attacker's IPs or sessions; the same rule elsewhere
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d) and (IPAddress in ("<ip1>", "<ip2>") or AutonomousSystemNumber in (<asn>))
| where ResultType == "0"
| summarize FirstSeen=min(TimeGenerated), SignIns=count(), Apps=make_set(AppDisplayName, 5) by UserPrincipalName, IPAddress
// Same rule pattern or the same source in other mailboxes:
OfficeActivity
| where TimeGenerated > ago(30d) and Operation in ("New-InboxRule", "Set-InboxRule", "UpdateInboxRules", "New-TransportRule")
| extend SourceIP = coalesce(Client_IPAddress, ClientIP)
| where Parameters has "<rule name or keyword>" or SourceIP in ("<ip1>", "<ip2>")
| summarize FirstSeen=min(TimeGenerated) by UserId, OfficeObjectId, Operation, SourceIP
// Internal recipients of the attacker's sends are the next victims: the stage 4 recipient list, filtered to your domain.
Stage 9 · Still activeSign-ins and mailbox activity after the revocation time
let revoked = datetime(<revocation time UTC>);
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > revoked
| where UserPrincipalName in~ (<evicted upns>) and ResultType == "0"
| project TimeGenerated, Type, UserPrincipalName, IPAddress, AutonomousSystemNumber, AppDisplayName, UserAgent, SessionId
// Mailbox activity after revocation, from any IP (the attacker may have moved):
OfficeActivity
| where TimeGenerated > revoked and (UserId in~ (<evicted upns>) or MailboxOwnerUPN in~ (<evicted upns>))
| extend SourceIP = coalesce(Client_IPAddress, ClientIP)
| summarize Operations=make_set(Operation, 10), Count=count(), Last=max(TimeGenerated) by UserId, SourceIP, SessionId, ClientInfoString
Act
Export before you change anything, then go broad: block the attacker for the whole tenant and evict every mailbox on the sweep list together. On the victim's device, collect before you stop anything; the device is rebuilt, not cleaned.
T+5–15 · ExportPreserve the evidence before any purge or reset
Exchange Online PowerShell and Microsoft Graph PowerShell. -SessionCommand ReturnLargeSet pages the UAL export in 5,000-record calls; repeat the call with the same -SessionId until it returns nothing. Save the attacker's messages as .eml with full headers from both the victim's Sent Items and a recipient's inbox (Content Search › export), so the Authentication-Results, Message-ID and Reply-To are preserved on both ends.
Set-Mailbox -Identity <upn> -LitigationHoldEnabled $true
Set-Mailbox -Identity <upn> -AuditLogAgeLimit 365
Search-UnifiedAuditLog -StartDate (Get-Date).AddDays(-180) -EndDate (Get-Date) -UserIds <upn> `
-SessionId "case<n>-ual" -SessionCommand ReturnLargeSet -ResultSize 5000 |
Select-Object -ExpandProperty AuditData | Add-Content .\case<n>-ual.jsonl # repeat until empty
Start-HistoricalSearch -ReportTitle "case<n>-trace" -ReportType MessageTrace -StartDate (Get-Date).AddDays(-90) `
-EndDate (Get-Date) -SenderAddress <upn> -NotifyAddress <your upn>
Get-InboxRule -Mailbox <upn> | Export-Clixml .\case<n>-rules.xml
Get-MgAuditLogSignIn -Filter "userPrincipalName eq '<upn>'" -All | Export-Csv .\case<n>-signins.csv
T+10–20 · BlockBlock the attacker for the whole tenant
Defender portal › Email & collaboration › Policies & rules › Threat policies › Tenant Allow/Block Lists. Keep the default 30-day expiry rather than -NoExpiration, so the block doesn't outlive the incident. Attacker sign-in IPs: Entra admin center › Conditional Access › Named locations, then a block policy for that location.
New-TenantAllowBlockListItems -ListType Sender -Block -Entries "<lookalike-domain>","<attacker-sender>"
New-TenantAllowBlockListItems -ListType Url -Block -Entries "<phishing-host>"
T+20–35 · EvictDisable, reset, revoke, strip every channel, verify, then re-enable: each mailbox on the list
Incidents titled “BEC financial fraud attack launched from a compromised account (attack disruption)” may already have acted. Run the block below per account, for every account on the sweep list in the same pass. Re-enable only after the T+35–45 check and its re-runs, with a fresh MFA enrolment from a known-good device.
# 1 Disable
Update-MgUser -UserId <upn> -AccountEnabled:$false
# 2 Reset the password (the user enrols a new one later from a known-good device)
Update-MgUser -UserId <upn> -PasswordProfile @{ Password = "<random>"; ForceChangePasswordNextSignIn = $true }
# 3 Revoke refresh tokens and sessions (issued access tokens live on until they expire)
Revoke-MgUserSignInSession -UserId <upn>
# 4 Rules, forwarding, delegates, SendAs, SendOnBehalf, folder permissions
Get-InboxRule -Mailbox <upn> | Remove-InboxRule -Confirm:$false
Set-Mailbox -Identity <upn> -ForwardingSmtpAddress $null -ForwardingAddress $null -DeliverToMailboxAndForward $false
Get-MailboxPermission -Identity <upn> | Where-Object { -not $_.IsInherited -and $_.User -ne "NT AUTHORITY\SELF" } |
ForEach-Object { Remove-MailboxPermission -Identity <upn> -User $_.User -AccessRights $_.AccessRights -Confirm:$false }
Get-RecipientPermission -Identity <upn> | Where-Object { $_.Trustee -ne "NT AUTHORITY\SELF" } |
ForEach-Object { Remove-RecipientPermission -Identity <upn> -Trustee $_.Trustee -AccessRights SendAs -Confirm:$false }
Set-Mailbox -Identity <upn> -GrantSendOnBehalfTo $null
Remove-MailboxFolderPermission -Identity "<upn>:\Inbox" -User <user added in the window> -Confirm:$false
# 5 OAuth grants, MFA methods and devices added in the window (compare with the T+5-15 export)
Get-MgUserOauth2PermissionGrant -UserId <upn> | ForEach-Object { Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $_.Id }
Get-MgUserAuthenticationMethod -UserId <upn> # then the matching Remove-MgUserAuthentication*Method:
Remove-MgUserAuthenticationPhoneMethod -UserId <upn> -PhoneAuthenticationMethodId <id>
Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod -UserId <upn> -MicrosoftAuthenticatorAuthenticationMethodId <id>
Remove-MgUserAuthenticationSoftwareOathMethod -UserId <upn> -SoftwareOathAuthenticationMethodId <id>
Remove-MgUserAuthenticationFido2Method -UserId <upn> -Fido2AuthenticationMethodId <id>
Get-MgUserRegisteredDevice -UserId <upn> | ForEach-Object { Update-MgDevice -DeviceId $_.Id -AccountEnabled:$false }
# 6 Close the legacy and sync protocols the attacker used (re-open what the user needs after re-enable)
Set-CASMailbox -Identity <upn> -ActiveSyncEnabled $false -ImapEnabled $false -PopEnabled $false -SmtpClientAuthenticationDisabled $true
# 7 Verify at T+35-45, T+2h and T+24h (stage 9 hunt), then
Update-MgUser -UserId <upn> -AccountEnabled:$true
T+20–35 · EvictPull the attacker's mail from every mailbox
Threat Explorer › All email › select the messages › Take action › Move or delete › Soft deleted items; for mail sent from the compromised mailbox also tick Delete sender's copy. Threat Explorer holds 30 days of mail flow. Older messages need a Compliance Search purge (Purview, eDiscovery Manager plus the Search And Purge role; 10 items per mailbox per action). Search-Mailbox is retired; do not reach for it.
New-ComplianceSearch -Name "case<n>-purge" -ExchangeLocation All `
-ContentMatchQuery 'from:"<attacker-sender>" AND received>=<yyyy-mm-dd>'
Start-ComplianceSearch -Identity "case<n>-purge"
Get-ComplianceSearch -Identity "case<n>-purge" | fl Status, Items # wait for Completed, check the count
New-ComplianceSearchAction -SearchName "case<n>-purge" -Purge -PurgeType SoftDelete
T+20–35 · DeviceBlock the stealer on every device
POST https://api.security.microsoft.com/api/indicators
{"indicatorValue": "<sha256>", "indicatorType": "FileSha256", "action": "BlockAndRemediate",
"title": "IR case #<n>: infostealer", "description": "BEC case #<n>", "expirationTime": "<UTC date>"}
Hour 1–24 · RebuildIsolate the victim's device, collect, then rebuild it
Device page › Isolate device. Then Live Response: add Get-PersistenceSnapshot.ps1 a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df and Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d to the library, push them with putfile, run the snapshot with -Zip, start RESPONSE elevated for RAM, pagefile and triage files, and getfile the snapshot zip. A RAM image is too large for getfile; stage it to a share the isolated device may still reach, or collect it from the device by hand.
POST https://api.security.microsoft.com/api/machines/{id}/isolate
{"Comment": "IR case #<n>", "IsolationType": "Full"}
# Live Response console:
putfile Get-PersistenceSnapshot.ps1
run Get-PersistenceSnapshot.ps1 -parameters "-Zip"
putfile MagnetRESPONSEv172_Self_Extracting_Archive.exe
getfile C:\<snapshot folder>\PersistenceSnapshot-<host>-<timestamp>.zip
Ahead of timeSentinel playbook
Content hub › SOAR essentials solution › Defender XDR BEC Playbook for SecOps-Tasks.
Hunt
Stage 1 · Mailbox accessSuccessful sign-ins for the user, first seen per IP
// Entra sign-ins reach Next-Gen SIEM through the Microsoft connector; field names depend on its parser.
// Search the user over the last 30 days, open one sign-in event, and note the source-IP, ASN and result fields:
"<upn>"
// Then group on the fields you found, e.g.
// | groupBy([<source ip field>, <asn field>], function=[min(@timestamp, as=FirstSeen), max(@timestamp, as=LastSeen), count()])
Stage 1 · Mailbox accessAiTM: one session replayed from two networks, MFA carried by the token
// Find the session-id field in one parsed sign-in event, then:
"<upn>"
| groupBy([<session id field>], function=[count(<source ip field>, distinct=true, as=IPs), collect([<source ip field>, <asn field>])])
| IPs > 1
// MFA satisfied by the token rather than performed:
"<upn>" "MFA requirement satisfied by claim in the token"
// Identity Protection (with the Entra ID connector): Identity protection > Detections for the user.
Stage 1 · Mailbox accessVictim device: infostealer or fake installer before first access
#event_simpleName=ProcessRollup2 ComputerName="<victim host>"
| ImageFileName=/\\(AppData\\Local\\Temp|Downloads)\\/i
| table([@timestamp, UserName, ImageFileName, CommandLine, SHA256HashData])
// Also: Endpoint security > Detections, filter on the host, tactic Credential Access.
Stage 2 · RulesInbox rules, forwarding, permissions, transport rules and deletions
// Free text plus a regex over all fields; works whatever the parser named the fields.
"<upn>" /New-InboxRule|Set-InboxRule|UpdateInboxRules|Add-MailboxPermission|Add-RecipientPermission|Add-MailboxFolderPermission|New-TransportRule|Set-TransportRule|Set-MailboxAutoReplyConfiguration|Set-MailboxJunkEmailConfiguration|HardDelete|MoveToDeletedItems/
// Set-Mailbox only with a forwarding or send-on-behalf parameter:
"<upn>" "Set-Mailbox" /ForwardingSmtpAddress|ForwardingAddress|DeliverToMailboxAndForward|GrantSendOnBehalfTo/
// Read ClientIPAddress / ClientIP, ObjectId, SessionId and Parameters from the matching events.
Stage 2 · RulesOAuth consent granted in the window
"<upn>" /Consent to application|Add OAuth2PermissionGrant|Add delegated permission grant|Add app role assignment grant to user/
// Note the app display name and service-principal id in the target resource; remove the grant in Respond (T+20-35).
Stage 3 · ReadingMailItemsAccessed in the attacker's sessions, down to the message ids
"<upn>" "MailItemsAccessed" ("<attacker ip>" OR "<attacker session id>")
// In each event: OperationProperties (MailAccessType Bind or Sync, IsThrottled), SessionId, ClientInfoString,
// and Folders[].FolderItems[].InternetMessageId for the message list that feeds stage 5.
Stage 4 · ImpersonationMail sent as the user, and mail from a lookalike domain
"<upn>" /"Operation"\s*:\s*"Send(As|OnBehalf)?"/
// Subject and InternetMessageId are under Item in the raw JSON. Recipients and lookalikes: message trace in the
// Exchange admin center, SenderAddress domain matched against the dnstwist output.
Stage 8 · Wider targetsOther accounts on the attacker's IPs; the stealer on other hosts
"<ip1>"
// Group on the user field of the parsed Microsoft events, e.g. | groupBy([<user field>])
// Endpoint: the stealer binary found at stage 1 on any other host:
#event_simpleName=ProcessRollup2 | SHA256HashData="<sha256>" OR ImageFileName=/<stealer file name>/i
| groupBy([ComputerName, UserName])
Stage 9 · Still activeSign-ins and mailbox activity after the revocation time
// Set the time picker to start at the revocation time.
"<upn>" ("<attacker ip>" OR "<attacker asn>" OR "<attacker session id>")
// Any hit after revocation: the session is still in use. Check the victim host again with the stage 1 device hunt.
Act
Broad first: block the attacker for the whole tenant and evict every mailbox on the sweep list together. On the victim's device, collect before you stop anything; the device is rebuilt, not cleaned.
T+5–35 · IdentityExport, block and evict in Entra and Exchange
Falcon doesn't export the UAL, block senders or revoke Entra sessions. Run the export (T+5–15), the tenant-wide block (T+10–20) and the native eviction sequence (T+20–35) from Respond; Falcon covers the devices.
T+20–35 · DeviceBlock the stealer on every host
POST /iocs/entities/indicators/v1
{"indicators": [{"type": "sha256", "value": "<sha256>", "action": "prevent", "platforms": ["windows"],
"severity": "high", "applied_globally": true, "expiration": "<UTC date>", "description": "BEC case #<n>"}]}
T+20–35 · DeviceContain the victim's device
Host management › Network contain. The API takes several AIDs in one call if the sweep found the stealer elsewhere.
POST /devices/entities/devices-actions/v2?action_name=contain
{"ids": ["<AID>"]}
Hour 1–24 · RebuildCollect with RTR, stop it, then rebuild the device
Upload Get-PersistenceSnapshot.ps1 a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df and Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d under Host setup and management › Response scripts and files first. get is for the snapshot zip and the suspect binary; a RAM image goes to a share the contained host is allowed to reach.
ps
netstat
ls C:\Users\<user>\AppData\Local\Temp
ls C:\Users\<user>\Downloads
get C:\path\to\suspect.exe
put Get-PersistenceSnapshot.ps1
runscript -Raw=```& .\Get-PersistenceSnapshot.ps1 -Zip```
put MagnetRESPONSEv172_Self_Extracting_Archive.exe
get C:\<cwd>\PersistenceSnapshot-<host>-<timestamp>.zip
kill <pid>
Hunt
Stage 1 · Mailbox accessSuccessful sign-ins for the user, first seen per IP
// Which data sources carry the user at all:
* contains '<upn>'
| group ct = count() by dataSource.name
// Then dump one sign-in event to find the source-IP, ASN, session and result fields for this tenant:
dataSource.name = '<m365_source>' | limit 1 | columns *
// and group: dataSource.name = '<m365_source>' <user_field> contains:anycase '<upn>' | group first = min(timestamp), ct = count() by <source ip field>
Stage 1 · Mailbox accessAiTM: one session replayed from two networks, MFA carried by the token
dataSource.name = '<m365_source>' <user_field> contains:anycase '<upn>'
| group ips = estimate_distinct(<source ip field>) by <session id field>
| filter ips > 1
// MFA satisfied by the token rather than performed:
dataSource.name = '<m365_source>' <user_field> contains:anycase '<upn>' message contains 'MFA requirement satisfied by claim in the token'
| columns timestamp, <source ip field>, <session id field>
Stage 1 · Mailbox accessVictim device: infostealer or fake installer before first access
indicator.category contains 'InfoStealer' AND endpoint.name = '<victim host>'
| columns event.time, src.process.name, src.process.cmdline
// Binaries started from user-writable folders on the same host:
event.type = 'Process Creation' AND endpoint.name = '<victim host>' AND tgt.process.image.path contains ('AppData','Downloads')
| columns event.time, tgt.process.name, tgt.process.image.path, tgt.process.cmdline, tgt.process.image.sha256
Stage 2 · RulesInbox rules, forwarding, permissions, transport rules and deletions
dataSource.name = '<m365_source>'
<operation_field> in ('New-InboxRule','Set-InboxRule','UpdateInboxRules','Add-MailboxPermission','Add-RecipientPermission',
'Add-MailboxFolderPermission','New-TransportRule','Set-TransportRule','Set-MailboxAutoReplyConfiguration',
'Set-MailboxJunkEmailConfiguration','HardDelete','MoveToDeletedItems')
<user_field> contains:anycase '<upn>'
| columns timestamp, <operation_field>, message
// Set-Mailbox only with a forwarding parameter:
dataSource.name = '<m365_source>' <operation_field> = 'Set-Mailbox' message contains ('ForwardingSmtpAddress','ForwardingAddress','DeliverToMailboxAndForward','GrantSendOnBehalfTo')
// Rule parameters, ClientIPAddress / ClientIP, ObjectId and SessionId are inside the JSON message field.
Stage 2 · RulesOAuth consent granted in the window
dataSource.name = '<m365_source>' <user_field> contains:anycase '<upn>'
message contains ('Consent to application','Add OAuth2PermissionGrant','Add delegated permission grant','Add app role assignment grant to user')
| columns timestamp, message
Stage 3 · ReadingMailItemsAccessed in the attacker's sessions, down to the message ids
dataSource.name = '<m365_source>'
<operation_field> = 'MailItemsAccessed'
<user_field> contains:anycase '<upn>'
message contains ('<attacker ip>','<attacker session id>')
| columns timestamp, message
// MailAccessType, IsThrottled, SessionId and Folders[].FolderItems[].InternetMessageId are inside the JSON message field.
Stage 4 · ImpersonationMail sent as the user, and mail from a lookalike domain
dataSource.name = '<m365_source>'
<operation_field> in ('Send','SendAs','SendOnBehalf')
<user_field> contains:anycase '<upn>'
| columns timestamp, <operation_field>, message
// Item.Subject and Item.InternetMessageId are in the JSON message field. Recipients: message trace in the Exchange admin center.
Stage 8 · Wider targetsOther accounts on the attacker's IPs; the stealer on other hosts
* contains '<ip1>'
| group ct = count() by dataSource.name, <user_field>
// Endpoint: the stealer anywhere in the fleet:
tgt.process.image.sha256 = '<sha256>' OR indicator.category contains 'InfoStealer'
| group Hits = count() by endpoint.name
Stage 9 · Still activeSign-ins and mailbox activity after the revocation time
// Set the time range to start at the revocation time.
dataSource.name = '<m365_source>' <user_field> contains:anycase '<upn>'
| group ct = count() by <source ip field>, <session id field>
// And the victim device, still infected?
indicator.category contains 'InfoStealer' AND endpoint.name = '<victim host>'
Act
Broad first: block the attacker for the whole tenant and evict every mailbox on the sweep list together. On the victim's device, collect before you stop anything; the device is rebuilt, not cleaned.
T+5–35 · IdentityExport, block and evict in Entra and Exchange
SentinelOne doesn't export the UAL, block senders or revoke Entra sessions. Run the export (T+5–15), the tenant-wide block (T+10–20) and the native eviction sequence (T+20–35) from Respond; SentinelOne covers the devices.
T+20–35 · DeviceBlocklist the stealer across the tenant
The blocklist takes the SHA-1 of the file; type must be black_hash, any other value creates an exclusion instead.
POST /web/api/v2.1/restrictions
{"data": {"type": "black_hash", "value": "<sha1>", "osType": "windows", "description": "BEC case #<n>"},
"filter": {"tenant": true}}
T+20–35 · DeviceDisconnect the victim's device from the network
Endpoint › Actions › Disconnect from network.
Hour 1–24 · RebuildCollect, stop it, then rebuild the device
Fetch Files: the suspect binary from AppData or Downloads. RemoteOps › Script Library › upload Get-PersistenceSnapshot.ps1 a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df as a Data Collection script (arguments -Zip; the zip comes back as the script output) and Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d wrapped in a one-line PowerShell script that starts it elevated. Then Threat › Mitigation › Kill, Quarantine.
Hunt
Stage 1 · Mailbox accessVictim device: phishing page visited, infostealer or fake installer before first access
Collect on the victim's device:
Windows.Applications.Chrome.History (Chrome, Edge, Brave, Vivaldi, Opera profiles)
URLRegex = (?i)<phishing or lookalike domain> (or leave empty and read the visits just before first access)
Windows.Forensics.Prefetch
dateAfter = <14 days before first access> (new binaries: stealer, fake installer, cracked tool)
Windows.System.Amcache
(first-execution times for the same window)
Stage 8 · Wider targetsThe stealer on any other host
Hunt across all clients for the binary found on the victim's device:
Windows.Forensics.Prefetch
binaryRegex = (?i)<stealer file name>
Windows.System.Pslist
ProcessRegex = (?i)<stealer file name>
Windows.Search.FileFinder
SearchFilesGlob = C:\Users\*\{AppData\Local\Temp,Downloads}\**
Calculate_Hash = Y (then filter the SHA256 column on the hash from stage 1)
Stage 9 · Still activeThe stealer still running on the victim's device after revocation
Windows.System.Pslist
ProcessRegex = (?i)<stealer file name>
Windows.Forensics.Prefetch
dateAfter = <revocation time>
Act
Broad first: block the attacker for the whole tenant and evict every mailbox on the sweep list together. On the victim's device, collect before you stop anything; the device is rebuilt, not cleaned.
T+5–35 · IdentityExport, block and evict in Entra and Exchange
Velociraptor has no identity actions. Run the export (T+5–15), the tenant-wide block (T+10–20) and the native eviction sequence (T+20–35) from Respond.
T+20–35 · DeviceQuarantine every host the stealer hunt found
Label the affected clients, then Hunt Manager › New Hunt › Include Condition: Match by label, with the artifact below. The policy only allows the Velociraptor server, so collection keeps working. RemovePolicy = true lifts it after the eradication gate.
Windows.Remediation.Quarantine
Hour 1–24 · RebuildCollect before you stop anything
Velociraptor's own artifacts cover the collection: Windows.Memory.Acquisition for RAM, Windows.KapeFiles.Targets (KapeTriage) for the triage files. Where the case needs the same persistence snapshot as the other tabs, run Get-PersistenceSnapshot.ps1 a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df with -Zip through Windows.System.PowerShell after staging it on the host, or Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d elevated from removable media.
Windows.Memory.Acquisition
Windows.KapeFiles.Targets (KapeTriage = Y)
Windows.Applications.Chrome.History
Windows.Forensics.Prefetch
Windows.System.Amcache
Windows.System.Pslist
Hour 1–24 · RebuildStop the stealer, then rebuild the device
Windows.System.PowerShell
Command = Stop-Process -Id <pid> -Force
Hunt
Stage 1 · Mailbox accessSuccessful sign-ins for the user, first seen per IP
sourcetype=azure:monitor:aad category IN (SignInLogs, NonInteractiveUserSignInLogs) user="<upn>" action=success
| stats min(_time) as first_seen max(_time) as last_seen count values(properties.userAgent) as user_agent
values(properties.sessionId) as sessions by src properties.autonomousSystemNumber
| sort first_seen | convert ctime(first_seen) ctime(last_seen)
// Without the Cloud Services add-on (interactive sign-ins only):
// sourcetype=o365:management:activity Workload=AzureActiveDirectory Operation=UserLoggedIn UserId="<upn>" | stats min(_time) by ClientIP
Stage 1 · Mailbox accessAiTM: one session replayed from two networks, MFA carried by the token
sourcetype=azure:monitor:aad category IN (SignInLogs, NonInteractiveUserSignInLogs) user="<upn>" action=success
| stats dc(properties.autonomousSystemNumber) as asns values(src) as ips min(_time) as first max(_time) as last by properties.sessionId
| where asns > 1 | convert ctime(first) ctime(last)
// MFA satisfied by the token rather than performed:
sourcetype=azure:monitor:aad category=SignInLogs user="<upn>" action=success
| spath output=step path=properties.authenticationDetails{}.authenticationStepResultDetail
| search step="MFA requirement satisfied by claim in the token"
| table _time src properties.autonomousSystemNumber properties.appDisplayName properties.sessionId properties.userAgent
Stage 1 · Mailbox accessVictim device: infostealer or fake installer before first access
index=windows source="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventCode=1 host="<victim host>"
(Image="*\\AppData\\Local\\Temp\\*" OR Image="*\\Downloads\\*")
NOT Image IN ("*\\msedge.exe", "*\\chrome.exe", "*\\Teams.exe", "*\\OneDrive.exe")
| table _time User Image CommandLine Hashes ParentImage
Stage 2 · RulesInbox rules and mailbox forwarding created or changed
sourcetype=o365:management:activity Workload=Exchange
Operation IN ("New-InboxRule", "Set-InboxRule", "Enable-InboxRule", "UpdateInboxRules", "Set-Mailbox")
(UserId="<upn>" OR MailboxOwnerUPN="<upn>" OR ObjectId="*<mailbox alias>*")
| search NOT (Operation="Set-Mailbox" AND NOT Parameters{}.Name IN ("ForwardingSmtpAddress", "ForwardingAddress", "DeliverToMailboxAndForward"))
| eval src_ip=coalesce(ClientIPAddress, ClientIP)
| table _time Operation UserId ObjectId src_ip SessionId Parameters{}.Name Parameters{}.Value
Stage 2 · RulesPermissions, transport rules, auto-reply, junk settings and deletions from the attacker's IP
sourcetype=o365:management:activity Workload=Exchange
(Operation IN ("Add-MailboxPermission", "Add-RecipientPermission", "Add-MailboxFolderPermission", "Set-MailboxFolderPermission",
"UpdateFolderPermissions", "New-TransportRule", "Set-TransportRule", "Set-MailboxAutoReplyConfiguration",
"Set-MailboxJunkEmailConfiguration", "HardDelete", "SoftDelete", "MoveToDeletedItems")
OR (Operation="Set-Mailbox" AND Parameters{}.Name="GrantSendOnBehalfTo"))
| eval src_ip=coalesce(ClientIPAddress, ClientIP)
| search (UserId="<upn>" OR MailboxOwnerUPN="<upn>" OR ObjectId="*<mailbox alias>*" OR src_ip IN ("<ip1>", "<ip2>"))
| table _time Operation UserId MailboxOwnerUPN ObjectId src_ip SessionId Parameters{}.Name Parameters{}.Value AffectedItems{}.Subject
Stage 2 · RulesOAuth consent granted in the window
sourcetype=o365:management:activity Workload=AzureActiveDirectory
Operation IN ("Consent to application.", "Add OAuth2PermissionGrant.", "Add delegated permission grant.", "Add app role assignment grant to user.", "Add service principal.")
(UserId="<upn>" OR Target{}.ID="*<upn>*")
| table _time Operation UserId ActorIpAddress Target{}.ID ModifiedProperties{}.Name ModifiedProperties{}.NewValue
Stage 3 · ReadingMailItemsAccessed in the attacker's sessions, down to the message ids
sourcetype=o365:management:activity Workload=Exchange Operation=MailItemsAccessed MailboxOwnerUPN="<upn>"
(ClientIPAddress IN ("<ip1>", "<ip2>") OR SessionId IN ("<attacker session id>"))
| spath output=access_type path=OperationProperties{}.Value
| spath output=msg_ids path=Folders{}.FolderItems{}.InternetMessageId
| spath output=folders path=Folders{}.Path
| stats count min(_time) as first_seen max(_time) as last_seen values(ClientInfoString) as client
values(access_type) as access_type values(folders) as folders values(msg_ids) as message_ids by ClientIPAddress SessionId
| convert ctime(first_seen) ctime(last_seen)
Stage 4 · ImpersonationMail sent as the user, and mail from a lookalike domain
sourcetype=o365:management:activity Workload=Exchange Operation IN ("Send", "SendAs", "SendOnBehalf") (MailboxOwnerUPN="<upn>" OR UserId="<upn>")
| table _time Operation UserId ClientIPAddress SessionId Item.Subject Item.InternetMessageId
// Recipients (message trace; field names are camelCase on o365:graph:messagetrace):
sourcetype IN (o365:graph:messagetrace, o365:reporting:messagetrace) SenderAddress="<upn>" | table _time RecipientAddress Subject Status
// Lookalikes writing to your people, from a dnstwist lookup file:
sourcetype IN (o365:graph:messagetrace, o365:reporting:messagetrace)
| eval sender_domain=lower(mvindex(split(SenderAddress, "@"), 1))
| lookup dnstwist_<yourdomain>.csv domain as sender_domain OUTPUT domain as lookalike
| where isnotnull(lookalike) | table _time SenderAddress RecipientAddress Subject Status
Stage 5 · Payment requestPayment and bank-detail threads in the window, ranked by what the attacker read
sourcetype IN (o365:graph:messagetrace, o365:reporting:messagetrace) (SenderAddress="<upn>" OR RecipientAddress="<upn>")
earliest="<MM/DD/YYYY:HH:MM:SS first access>" latest="<MM/DD/YYYY:HH:MM:SS revocation>"
| eval attacker_read=if(match(MessageId, "<id 1>|<id 2>"), 1, 0)
| search attacker_read=1 OR Subject IN ("*invoice*", "*remittance*", "*payment*", "*wire*", "*IBAN*", "*bank details*", "*factuur*", "*betaling*", "*rekeningnummer*")
| table _time SenderAddress RecipientAddress Subject attacker_read | sort -attacker_read _time
Stage 8 · Wider targetsOther accounts on the attacker's IPs; the same rule elsewhere
sourcetype=azure:monitor:aad category IN (SignInLogs, NonInteractiveUserSignInLogs) (src IN ("<ip1>", "<ip2>") OR properties.autonomousSystemNumber=<asn>) action=success
| stats min(_time) as first_seen count by user src | convert ctime(first_seen)
// Same rule pattern or source in other mailboxes:
sourcetype=o365:management:activity Operation IN ("New-InboxRule", "Set-InboxRule", "UpdateInboxRules", "New-TransportRule")
| eval src_ip=coalesce(ClientIPAddress, ClientIP)
| search "<rule name or keyword>" OR src_ip IN ("<ip1>", "<ip2>")
| stats min(_time) as first_seen by UserId ObjectId src_ip
Stage 9 · Still activeSign-ins and mailbox activity after the revocation time
sourcetype=azure:monitor:aad category IN (SignInLogs, NonInteractiveUserSignInLogs) user IN (<evicted upns>) action=success
earliest="<MM/DD/YYYY:HH:MM:SS revocation time>"
| table _time category user src properties.autonomousSystemNumber properties.appDisplayName properties.sessionId properties.userAgent
sourcetype=o365:management:activity (UserId IN (<evicted upns>) OR MailboxOwnerUPN IN (<evicted upns>)) earliest="<MM/DD/YYYY:HH:MM:SS revocation time>"
| eval src_ip=coalesce(ClientIPAddress, ClientIP)
| stats count values(Operation) as operations max(_time) as last by UserId src_ip SessionId ClientInfoString | convert ctime(last)
Act
Broad first: block the attacker for the whole tenant and evict every mailbox on the sweep list together. On the victim's device, collect before you stop anything; the device is rebuilt, not cleaned.
T+5–35 · IdentityAct in Entra, Exchange and the EDR
Run the export (T+5–15), the tenant-wide block (T+10–20) and the native eviction sequence (T+20–35) from Respond, and the device steps in your EDR's tab, including Get-PersistenceSnapshot.ps1 a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df and Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d before the rebuild. With Splunk SOAR, the EDR's isolate / contain action runs from the playbook — feed it every host the sweep found, not only the victim's.
Hunt
Stage 1 · Mailbox accessSuccessful sign-ins for the user, first seen per IP
FROM logs-azure.signinlogs*
| WHERE @timestamp > NOW() - 30 days
AND azure.signinlogs.properties.user_principal_name == "<upn>" AND event.outcome == "success"
| STATS first_seen = MIN(@timestamp), last_seen = MAX(@timestamp), signins = COUNT(*),
sessions = VALUES(azure.signinlogs.properties.session_id)
BY source.ip, source.as.organization.name, azure.signinlogs.properties.app_display_name, user_agent.original
| SORT first_seen
Stage 1 · Mailbox accessAiTM: one session replayed from two networks, MFA carried by the token
FROM logs-azure.signinlogs*
| WHERE @timestamp > NOW() - 30 days
AND azure.signinlogs.properties.user_principal_name == "<upn>" AND event.outcome == "success"
AND azure.signinlogs.properties.session_id IS NOT NULL
| STATS asns = COUNT_DISTINCT(source.as.number), ips = VALUES(source.ip), first = MIN(@timestamp), last = MAX(@timestamp)
BY azure.signinlogs.properties.session_id
| WHERE asns > 1
// MFA satisfied by the token rather than performed (authentication_details is a nested array; read it in Discover):
FROM logs-azure.signinlogs*
| WHERE @timestamp > NOW() - 30 days AND azure.signinlogs.properties.user_principal_name == "<upn>"
AND TO_STRING(azure.signinlogs.properties.authentication_details) LIKE "*MFA requirement satisfied by claim in the token*"
| KEEP @timestamp, source.ip, source.as.number, azure.signinlogs.properties.app_display_name, azure.signinlogs.properties.session_id, user_agent.original
Stage 1 · Mailbox accessVictim device: infostealer or fake installer before first access
process where event.type == "start" and host.name == "<victim host>" and
process.executable : ("?:\\Users\\*\\AppData\\*", "?:\\Users\\*\\Downloads\\*") and
not process.name : ("msedge.exe", "chrome.exe", "Teams.exe", "OneDrive.exe", "ms-teams.exe")
Stage 2 · RulesInbox rules and mailbox forwarding created or changed
FROM logs-o365.audit-*
| WHERE @timestamp > NOW() - 30 days AND event.provider == "Exchange"
AND event.action IN ("New-InboxRule", "Set-InboxRule", "Enable-InboxRule", "UpdateInboxRules", "Set-Mailbox")
AND (o365.audit.UserId == "<upn>" OR o365.audit.MailboxOwnerUPN == "<upn>" OR o365.audit.ObjectId LIKE "*<mailbox alias>*")
AND (event.action != "Set-Mailbox"
OR o365.audit.Parameters.ForwardingSmtpAddress IS NOT NULL
OR o365.audit.Parameters.ForwardingAddress IS NOT NULL
OR o365.audit.Parameters.DeliverToMailboxAndForward IS NOT NULL)
| EVAL src_ip = COALESCE(o365.audit.ClientIPAddress, o365.audit.ClientIP)
| KEEP @timestamp, event.action, o365.audit.UserId, o365.audit.ObjectId, src_ip, o365.audit.SessionId,
o365.audit.Parameters.ForwardTo, o365.audit.Parameters.ForwardAsAttachmentTo, o365.audit.Parameters.RedirectTo,
o365.audit.Parameters.ForwardingSmtpAddress, o365.audit.Parameters.MoveToFolder, o365.audit.Parameters.DeleteMessage
| SORT @timestamp
Stage 2 · RulesPermissions, transport rules, auto-reply, junk settings and deletions from the attacker's IP
FROM logs-o365.audit-*
| WHERE @timestamp > NOW() - 30 days AND event.provider == "Exchange"
AND (event.action IN ("Add-MailboxPermission", "Add-RecipientPermission", "Add-MailboxFolderPermission", "Set-MailboxFolderPermission",
"UpdateFolderPermissions", "New-TransportRule", "Set-TransportRule", "Set-MailboxAutoReplyConfiguration",
"Set-MailboxJunkEmailConfiguration", "HardDelete", "SoftDelete", "MoveToDeletedItems")
OR (event.action == "Set-Mailbox" AND o365.audit.Parameters.GrantSendOnBehalfTo IS NOT NULL))
| EVAL src_ip = COALESCE(o365.audit.ClientIPAddress, o365.audit.ClientIP)
| WHERE o365.audit.UserId == "<upn>" OR o365.audit.MailboxOwnerUPN == "<upn>" OR o365.audit.ObjectId LIKE "*<mailbox alias>*"
OR src_ip IN ("<ip1>", "<ip2>")
| KEEP @timestamp, event.action, o365.audit.UserId, o365.audit.MailboxOwnerUPN, o365.audit.ObjectId, src_ip, o365.audit.SessionId, o365.audit.Parameters.*
| SORT @timestamp
Stage 2 · RulesOAuth consent granted in the window
FROM logs-azure.auditlogs*
| WHERE @timestamp > NOW() - 30 days
AND azure.auditlogs.operation_name IN ("Consent to application", "Add OAuth2PermissionGrant", "Add delegated permission grant",
"Add app role assignment grant to user", "Add service principal")
AND (azure.auditlogs.properties.initiated_by.user.userPrincipalName == "<upn>" OR TO_STRING(azure.auditlogs.properties.target_resources) LIKE "*<upn>*")
| KEEP @timestamp, azure.auditlogs.operation_name, azure.auditlogs.result, azure.auditlogs.properties.initiated_by.user.ipAddress, azure.auditlogs.properties.target_resources.*
// Without the Azure integration, the same events reach logs-o365.audit-* as event.provider "AzureActiveDirectory"
// with a trailing full stop: event.action == "Consent to application."
Stage 3 · ReadingMailItemsAccessed in the attacker's sessions, down to the message ids
FROM logs-o365.audit-*
| WHERE @timestamp > NOW() - 30 days AND event.action == "MailItemsAccessed"
AND o365.audit.MailboxOwnerUPN == "<upn>"
AND (o365.audit.ClientIPAddress IN ("<ip1>", "<ip2>") OR o365.audit.SessionId IN ("<attacker session id>"))
| STATS records = COUNT(*), first_seen = MIN(@timestamp), last_seen = MAX(@timestamp), clients = VALUES(o365.audit.ClientInfoString)
BY o365.audit.SessionId, o365.audit.ClientIPAddress
// Bind or Sync and IsThrottled: o365.audit.OperationProperties. Message ids: the Folders[].FolderItems[].InternetMessageId
// array in the raw event (aggregated records: o365.audit.ExchangeAggregatedFolders.FolderItems.InternetMessageId); export the
// matching documents from Discover and extract the ids there, ES|QL does not unnest the array.
Stage 4 · ImpersonationMail sent as the user, and mail from a lookalike domain
FROM logs-o365.audit-*
| WHERE @timestamp > NOW() - 30 days
AND event.action IN ("Send", "SendAs", "SendOnBehalf") AND (o365.audit.MailboxOwnerUPN == "<upn>" OR o365.audit.UserId == "<upn>")
| KEEP @timestamp, event.action, o365.audit.UserId, o365.audit.ClientIPAddress, o365.audit.SessionId, o365.audit.Item.Subject, o365.audit.Item.InternetMessageId
| SORT @timestamp
// Recipients and lookalikes: Start-HistoricalSearch (Respond, T+5-15) and match the sender domain column against the dnstwist output.
Stage 5 · Payment requestPayment and bank-detail subjects the attacker sent or read
FROM logs-o365.audit-*
| WHERE @timestamp >= TO_DATETIME("<first access, ISO 8601>") AND @timestamp <= TO_DATETIME("<revocation time, ISO 8601>")
AND event.action IN ("Send", "SendAs", "SendOnBehalf") AND (o365.audit.MailboxOwnerUPN == "<upn>" OR o365.audit.UserId == "<upn>")
| EVAL subject = TO_LOWER(o365.audit.Item.Subject)
| WHERE subject LIKE "*invoice*" OR subject LIKE "*remittance*" OR subject LIKE "*payment*" OR subject LIKE "*wire*"
OR subject LIKE "*iban*" OR subject LIKE "*bank details*" OR subject LIKE "*factuur*" OR subject LIKE "*betaling*" OR subject LIKE "*rekeningnummer*"
OR o365.audit.Item.InternetMessageId IN ("<bind id 1>", "<bind id 2>")
| KEEP @timestamp, event.action, o365.audit.ClientIPAddress, o365.audit.SessionId, o365.audit.Item.Subject, o365.audit.Item.InternetMessageId
| SORT @timestamp
Stage 8 · Wider targetsOther accounts on the attacker's IPs; the same rule elsewhere
FROM logs-azure.signinlogs*
| WHERE @timestamp > NOW() - 30 days AND (source.ip IN (TO_IP("<ip1>"), TO_IP("<ip2>")) OR source.as.number == <asn>) AND event.outcome == "success"
| STATS first_seen = MIN(@timestamp), signins = COUNT(*) BY azure.signinlogs.properties.user_principal_name, source.ip
// Same rule pattern or source in other mailboxes:
FROM logs-o365.audit-*
| WHERE @timestamp > NOW() - 30 days AND event.action IN ("New-InboxRule", "Set-InboxRule", "UpdateInboxRules", "New-TransportRule")
| EVAL src_ip = COALESCE(o365.audit.ClientIPAddress, o365.audit.ClientIP)
| WHERE src_ip IN ("<ip1>", "<ip2>") OR o365.audit.Parameters.Name LIKE "*<rule name or keyword>*"
| STATS first_seen = MIN(@timestamp) BY o365.audit.UserId, o365.audit.ObjectId, event.action, src_ip
Stage 9 · Still activeSign-ins and mailbox activity after the revocation time
FROM logs-azure.signinlogs*
| WHERE @timestamp > TO_DATETIME("<revocation time, ISO 8601>")
AND azure.signinlogs.properties.user_principal_name IN ("<evicted upn 1>", "<evicted upn 2>") AND event.outcome == "success"
| KEEP @timestamp, azure.signinlogs.category, azure.signinlogs.properties.user_principal_name, source.ip, source.as.number,
azure.signinlogs.properties.app_display_name, azure.signinlogs.properties.session_id, user_agent.original
// Mailbox activity after revocation, from any IP:
FROM logs-o365.audit-*
| WHERE @timestamp > TO_DATETIME("<revocation time, ISO 8601>")
AND (o365.audit.UserId IN ("<evicted upn 1>", "<evicted upn 2>") OR o365.audit.MailboxOwnerUPN IN ("<evicted upn 1>", "<evicted upn 2>"))
| EVAL src_ip = COALESCE(o365.audit.ClientIPAddress, o365.audit.ClientIP)
| STATS operations = VALUES(event.action), records = COUNT(*), last = MAX(@timestamp) BY o365.audit.UserId, src_ip, o365.audit.SessionId, o365.audit.ClientInfoString
Act
Broad first: block the attacker for the whole tenant and evict every mailbox on the sweep list together. On the victim's device, collect before you stop anything; the device is rebuilt, not cleaned.
T+5–35 · IdentityExport, block and evict in Entra and Exchange
Elastic has no Entra actions. Run the export (T+5–15), the tenant-wide block (T+10–20) and the native eviction sequence (T+20–35) from Respond.
T+20–35 · DeviceBlocklist the stealer on every host
Blocklist › Add blocklist entry › Field: Hash (MD5, SHA-1 or SHA-256) › Assignment: Global.
T+20–35 · DeviceIsolate the victim's device
Elastic Defend: host › Take action › Isolate host (response console: isolate). 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.
Hour 1–24 · RebuildCollect, stop it, then rebuild the device
The response console has no file upload: stage Get-PersistenceSnapshot.ps1 a9534865f9e8e7b1f5077b4e1cfd8c29e7e32f6e9e49a4918ce4f8fb713e64df and Magnet RESPONSE 1.7.2 d315c63d1ad4b89e03c7688b29169e977c2abc4559c23b9f6b2282f3c91a6c7d on a share the isolated host is allowed to reach (or run them from removable media), start them with execute, and get-file the snapshot zip. The RAM image stays on the share.
processes
get-file --path "C:\path\to\suspect.exe"
execute --command "powershell -ExecutionPolicy Bypass -File \\<share>\Get-PersistenceSnapshot.ps1 -Zip"
get-file --path "C:\<output>\PersistenceSnapshot-<host>-<timestamp>.zip"
kill-process --pid <pid>