BEC Response

Compromised mailbox, payment in flight — the first 60 minutes

Preparing, not responding? Readiness, detection and hardening are on BEC Defense. This page is for when it is happening.
Keeps the triage table and timed steps; hides the per-platform hunt queries.

How Far Has It Got?

Ask these at triage, top to bottom, and again at every update. The furthest stage you can show evidence for is how far the incident has got — write it in the case's status line. A stage you cannot answer yet is a scoping gap: say when it will be known. Why: the stage reached decides whether finance and the bank need a call before technical containment is finished.
#StageATT&CKQuestionWhere to look
1Mailbox accessT1078.004 T1550.004When 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”
2RulesT1114.003 T1098.002Did 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
3ReadingT1114.002Which threads did they read: invoices, payments, contracts?Unified Audit Log (MailItemsAccessed, by session)
4ImpersonationT1534 T1656Did 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
5Payment requestT1656Did anyone receive a request to pay or change bank details?Payment-thread hunt (stage 3 message ids against mail flow); finance team
6Money movedT1657Has 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
7Recall windowT1657Can the payment still be recalled?Our bank's fraud department, on the number on file (SWIFT gpi stop-and-recall; SEPA SCT Recall)
8Wider targetsT1534Were other mailboxes, clients or partners targeted from here?Sign-ins from the attacker's ASN; message-trace recipients; partner reports
9Still activeT1078.004Is any session still in use after containment?Sign-ins and mailbox audit after the revocation time, re-run at T+2h and T+24h
T+0 — T+60 min  •  CONTAIN

Respond: The First 60 Minutes

A mailbox is compromised, the attacker may still be in the inbox, and a payment may be in flight. Goal in this phase: stop the money, cut the access, keep the evidence. The mailbox containment order is the same as for any account takeover — see Identity Breach Response for the full sequence.
Money first, then mailbox. If a payment is in flight, finance calls the bank before technical containment finishes, not after. A recall is a race measured in hours.
T+0–5 minIs a payment in flight?
Ask finance: any payment requested, approved or sent from a conversation this mailbox was part of, or any bank-detail change? If yes, finance calls the bank's fraud department on the number already on file — never a number from the email — and asks by name for the recall mechanism that fits the rail: SWIFT gpi stop-and-recall for an international wire (the receiving bank is asked to freeze while the funds are still in the gpi chain); SEPA SCT Recall for a euro transfer (the originator bank can raise it for fraud within 10 banking business days of execution; after that only a Request for Recall by the Originator, up to 13 months, which the beneficiary bank may refuse). For a wire from a US bank: an international transfer of USD 50,000 or more, within 72 hours, with the SWIFT recall already raised, qualifies for the FBI IC3 Financial Fraud Kill Chain; file at ic3.gov with the bank's recall reference. Same call: freeze every pending payment to the supplier named in the thread until the bank details are verified by phone on a number from the contract, not the email. Why: the chance of recalling a transfer falls with every hour, and the attacker is watching the mailbox for exactly this call.
T+5–15 minExport first: hold the mailbox, dump the audit, capture rules and grants
Litigation hold before any purge or reset, then export what rolls off: Entra sign-ins (30 days on P1/P2, 7 on the free tier), the Unified Audit Log for the user (180 days on Audit Standard, 1 year on Premium), a historical message trace (the live trace covers 10 days, the historical search 90), and the mailbox's rules, forwarding, delegates, folder permissions, OAuth grants, MFA methods and registered devices as they are now. Save the attacker's messages as .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>
Contain at least as wide as the attacker could be. Evicting one mailbox and then sweeping for the others tells the attacker they've been found while their other footholds stay live. Sweep first, then evict every mailbox on the list in one pass. If you can't rule out spread quickly, go broad rather than surgical.
T+10–20 minSweep the tenant for the same pattern — before evicting
Look for other mailboxes with forwarding or redirect rules, rules named or shaped like the ones you just captured, tenant transport rules created in the window, sign-ins from the attacker's IP, ASN or session, and internal recipients of the compromised mailbox's outbound mail during the window. Every mailbox this finds goes on the eviction list. If the sweep can't finish in this window, go broad: block the attacker's source in Conditional Access tenant-wide and revoke sessions for every internal recipient of the account's outbound mail. Why: attackers rarely take one mailbox, and the first eviction is the moment they learn to use the others.
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
T+20–35 minEvict every mailbox on the list in one pass
For each mailbox, in this order: disable the account (no new tokens while you work), reset the password, revoke refresh tokens and sessions, then strip every channel the attacker could have added — inbox rules, forwarding, delegates, SendAs and SendOnBehalf, folder permissions, OAuth consent grants, MFA methods and registered devices added in the window (compare with the T+5–15 capture) — close the legacy protocols, verify at T+35–45 and again at T+2h and T+24h, and only then re-enable with a fresh MFA enrolment from a known-good device. All of it together, for every mailbox on the list, not one channel per hour. Attack disruption may already have disabled the user: check the Action center first. The full cmdlet sequence is in the Defender tab under Hunt & Act. Why: refresh tokens survive a password reset, access tokens already issued survive the revocation for 60–90 minutes, inbox rules and OAuth grants survive everything else, and any channel left live is how they come back.
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
T+35–45 minVerify the containment held — and book the re-checks
No successful sign-in, token refresh or mailbox access for any evicted account after the revocation time. A clean result now is not proof: access tokens issued before the revocation keep working for 60–90 minutes (CAE-capable clients hold tokens up to 28 hours but receive the revocation within minutes), and OfficeActivity arrives in Sentinel about an hour late, so at T+45 the query cannot yet show a token that is still being used. Run it now to catch the obvious (a channel you forgot), then put the same query in the case at T+2h and T+24h; the T+24h run is the one that clears the account for re-enable. Anything after revocation means a channel was missed — usually a registered device, an app grant or the still-infected laptop — so widen the containment before moving on. Why: revocation that didn't hold looks exactly like revocation that did, until the logs have caught up.
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
T+45–60 minScope what the attacker saw and sent
Sent Items and Drafts for anything the attacker sent or left unsent (drafts never audit, so open the folder); the message ids from the MailItemsAccessed Bind records, joined to mail flow and widened with invoice, remittance, IBAN and bank-detail keywords, to list every payment thread the attacker knows (the stage 5 hunt); whether a planted Canarytoken (e.g. "Wire Transfer Procedures.docx") was opened; and, for every supplier in those threads, finance checks the vendor master for bank-detail changes in the last 90 days and freezes pending payments to that supplier until the details are verified by phone. On the attacker's messages, read the 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  •  RECOVER & NOTIFY

Hour 1–24: Verify, Re-enable, Notify

Verify clean before re-enabling
No OAuth consent grants, attacker-registered MFA methods or devices, unexpected mailbox delegates, folder permissions or app passwords left from the access window (diff the live state against the T+5–15 capture), and the T+24h run of the stage 9 query clean. Re-enable only then, with a new password and a fresh MFA enrolment from a known-good device, and re-open only the protocols the user needs. Purge the attacker's mail across the tenant from Threat Explorer (30 days of data) or, for older mail, a Compliance Search purge; the litigation hold keeps a copy. Why: an app consent, a registered device or a delegate keeps mailbox access after every credential is changed.
Rebuild the device if malware stole the credentials
If the session cookie or password came from an infostealer on the user's laptop, rebuild the device — don't clean it. Collect first: Get-PersistenceSnapshot.ps1 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.
Warn everyone the attacker wrote to
Internal staff, clients and suppliers who received mail from the account during the window, by phone or a known channel. Why: the next victim is usually a partner who trusts the compromised address.
Notification clocks and police report
GDPR: supervisory authority within 72 h of awareness if personal data was read. NIS2: early warning 24 h, notification 72 h, if the organisation is in scope. File a police report for any payment fraud — the bank and insurer will ask for it: in the Netherlands, aangifte with the politie (online or at the bureau, with the bank's recall reference and the .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.
Day 1 — 30  •  LEARN

After the Incident: Feed the Case Back

Route what the case taught you
How they got in (lookalike domain, phishing lure, token theft) goes to posture and detection. What detection missed goes to the detection backlog. Where the process failed — a payment or bank-detail change approved without out-of-band verification — goes to finance controls (BEC Defense). Why: a BEC that ends at "mailbox restored" will be paid for twice.
Machines collect, humans curate
The attacker's IPs, lookalike domains, rule shapes and OAuth app IDs are collected automatically. They become detections, blocks or shared intelligence only after an analyst checks them — not shared infrastructure, not a sandbox artefact — and gives each a date, a source, an expiry and a confidence level. Why: attacker IPs rotate within days; an unexpired IP block becomes noise, and an unchecked one blocks someone legitimate.

Hunt & Act by Platform

What to look for in your EDR or SIEM for each stage of the BEC questions, and the response actions in each tool. Pick your platform.
Test these before you need them. The queries follow each vendor's documented schema, but field names depend on your data sources, versions and ingestion. Run each one in your own tenant during peacetime and fix it there — not during an incident.
KQL. Sign-ins and mailbox audit from Sentinel (SigninLogs, AADNonInteractiveUserSignInLogs, AuditLogs, OfficeActivity via the Entra ID and Microsoft 365 connectors); EmailEvents, EmailAttachmentInfo, CloudAppEvents, AlertInfo and DeviceProcessEvents in Defender XDR advanced hunting. Windows: the 30-day look-back below is the Entra sign-in retention on P1/P2 (7 days on the free tier) and the advanced-hunting retention; OfficeActivity reaches back 180 days on Audit (Standard) and 1 year on Audit (Premium), so widen the UAL hunts when the first sign-in is older than a month.

Hunt

Stage 1 · Mailbox accessSuccessful sign-ins for the user, first seen per IP

Why: The earliest sign-in from an IP the user never uses starts the access window; every later question (what was read, what was sent) is bounded by it. Export this result now: the 30-day Entra retention rolls off during the case.
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
T1078.004 Tuning: Mobile carriers and VPN concentrators give the same user a new IP every day; judge on ASN and user agent, not on the IP alone. Non-interactive sign-ins from Microsoft service ranges (Exchange acting for Outlook) are normal and should not be read as attacker sessions.

Stage 1 · Mailbox accessAiTM: one session replayed from two networks, MFA carried by the token

Why: A proxy phishing kit (Tycoon 2FA, EvilProxy) steals the session cookie after the real MFA, so a password reset alone does not end the access. The tells are one SessionId used from two ASNs within minutes, sign-ins where MFA was “satisfied by claim in the token” from an IP the user has never used, and the Defender alerts “Stolen session cookie was used” and “Possible use of a stolen session cookie”.
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
T1557 T1550.004 T1539 Tuning: A laptop moving from office Wi-Fi to a phone hotspot changes ASN mid-session; a replay shows two networks within the same minutes, one of them usually a hosting provider. “Satisfied by claim in the token” is normal for every token refresh on a device that did MFA once; it only matters from a new IP or ASN.

Stage 1 · Mailbox accessVictim device: infostealer or fake installer before first access

Why: If the session cookie or password was stolen by malware on the laptop, a password reset is not enough: the device keeps stealing new ones until it is rebuilt.
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
T1555.003 T1204.002 Tuning: Installers, updaters and portable tools run from Downloads and Temp all day; the lead is a binary the user cannot explain, launched shortly before the first foreign sign-in, with a SHA256 that is new to the tenant. Exclude the browsers, Teams and OneDrive updaters as above.

Stage 2 · RulesInbox rules and mailbox forwarding created or changed

Why: The rule's creation time and client IP bound the access window and give an attacker IP to pivot on. Rules made in Outlook desktop arrive as UpdateInboxRules with the IP in Client_IPAddress; rules made on the web or in PowerShell arrive as New-InboxRule / Set-InboxRule with the IP in ClientIP and the target mailbox in OfficeObjectId (the UserId is whoever ran the cmdlet). Set-Mailbox only matters with a forwarding parameter.
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
T1114.003 T1564.008 Tuning: Users make rules too. Attacker rules move mail to RSS Feeds, Archive or Deleted Items, delete it, or match on invoice, payment, bank or the attacker's own domain; names are often a single character or a full stop.

Stage 2 · RulesPermissions, transport rules, auto-reply, junk settings and deletions from the attacker's IP

Why: Delegate and SendAs permissions outlive a password reset; a tenant transport rule is a forwarding rule the mailbox owner never sees; an auto-reply or junk-list entry hides the victim's own replies; HardDelete and MoveToDeletedItems from the attacker's IP are the attacker cleaning the thread they hijacked.
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
T1098.002 T1070.008 Tuning: Deletions by the owner from the office IP are the user tidying up; keep the IP and SessionId filter. Helpdesk staff grant FullAccess for leavers and shared mailboxes: a grant to a user who already exists, from an admin IP, with a ticket, is not the attacker.

Stage 2 · RulesOAuth consent granted in the window, and what the app did with it

Why: An app the user consented to keeps reading mail through Graph after every password reset and session revocation; it is removed by deleting the grant, not the credential. The consent is in the Entra audit log; the app's activity afterwards is in CloudAppEvents under its OAuthAppId.
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
T1528 T1550.001 Tuning: Teams add-ins, Adobe, Zoom and signature tools ask for Mail.Read and offline_access legitimately. A grant to a multi-tenant app with no publisher verification, created minutes after a foreign sign-in, is the one to remove.

Stage 3 · ReadingMailItemsAccessed in the attacker's sessions, down to the message ids

Why: Sync access means the whole folder counts as read; Bind access lists each message. Pivot on the SessionId from stage 1 rather than on IP alone: the proxy kit changes IP, the session does not. The message-id list decides the personal-data notification and feeds the payment-thread hunt at stage 5. Throttling: more than 1,000 MailItemsAccessed records for one mailbox in 24 hours stops the logging for 24 hours, and the record that tripped it carries IsThrottled; a gap in the window is itself a finding, not an all-clear.
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
T1114.002 Tuning: The user's own Outlook and phone sync whole folders every few minutes (ClientInfoString Outlook, Outlook-iOS, OutlookService); without the session or IP filter every message in the mailbox looks read.

Stage 4 · ImpersonationMail the attacker sent from the real mailbox, with recipients

Why: Send (owner), SendAs and SendOnBehalf (delegate) audit records carry the subject, client IP and session. Audit (Standard) has included Send and MailItemsAccessed since Microsoft's 2024 roll-out unless the mailbox's audit set was customised (check: Get-Mailbox <upn> | fl DefaultAuditSet, AuditOwner). Recipients are not in the audit record; EmailEvents (30 days) or a message trace give those. Drafts never send and never audit: the attacker's unsent drafts are found by opening the Drafts folder in a Content Search.
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
T1534 T1656 Tuning: The user kept sending mail during the window. Separate the attacker's sends by Client_IPAddress and SessionId from stage 1, and by subject lines that reply to threads the attacker read at stage 3.

Stage 4 · ImpersonationLookalike domains writing to your people, and envelope-versus-header mismatches

Why: Generate the candidate list rather than guessing one: dnstwist -r on your own domain and on the top suppliers' domains, then match the list against SenderFromDomain. A second tell is the envelope sender domain (SenderMailFromDomain, P1) differing from the header From domain (SenderFromDomain, P2) on mail that claims a trusted domain. EmailEvents has no Reply-To column: for a Reply-To mismatch, run a Content Search on the recipients for the subject and read the headers of the .eml it returns.
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
T1583.001 T1656 Tuning: Mailing-list, ticketing and marketing platforms set P1 to their own bounce domain on purpose; exclude the platforms your suppliers use and anything whose AuthenticationDetails shows DMARC pass.

Stage 5 · Payment requestPayment and bank-detail threads in the window, ranked by what the attacker read

Why: The payment request is a reply inside a thread the attacker already read. Join the message ids from the Bind records at stage 3 to EmailEvents, then widen with subject and attachment keywords for the same window; everything that comes back goes to finance by phone, with the attacker-read ones first.
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
T1656 T1657 Tuning: A finance mailbox matches the keywords on most of its mail; the AttackerRead column is the ranking, the keywords only widen the net. Add your own ERP's subject prefixes and the Dutch or German terms your suppliers use.

Stage 8 · Wider targetsOther accounts on the attacker's IPs or sessions; the same rule elsewhere

Why: Attackers rarely take one mailbox. A second account on the same IP, or with the same rule shape, is a second incident that needs the same containment, in the same pass.
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.
T1078.004 T1534 Tuning: A shared egress IP (office NAT, the company VPN) puts every colleague on the attacker's IP; match on ASN plus user agent, or on the session, before adding an account to the eviction list.

Stage 9 · Still activeSign-ins and mailbox activity after the revocation time

Why: Access tokens issued before revocation keep working until they expire (60–90 min for ordinary clients; CAE-capable clients hold tokens up to 28 h but receive the revocation within minutes), and OfficeActivity arrives about an hour late, so a clean result in the first hour is not proof. Re-run at T+2h and T+24h. A success after revocation means a channel was missed: a registered device, an OAuth grant, a delegate, or the still-infected laptop.
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
T1078.004 T1550.004 Tuning: The user's own devices reconnect the moment the account is re-enabled; a hit from a known device, known ASN and the fresh SessionId created at re-enrolment is expected. Anything from the attacker's ASN, or from a SessionId that predates the revocation, is not.
Stage 6 · Money movedNo hunt in this tool. Payments and vendor-master changes live in the ERP, the banking portal and the payment-approval workflow, not in Entra, Exchange or endpoint telemetry. Finance pulls the payment record, the approval chain and the vendor bank-detail change history for the last 90 days; the hunt here is the payment-thread hunt at stage 5, which tells finance which supplier records to check first.
Stage 7 · Recall windowNo hunt in this tool. Whether a transfer can still be stopped is decided by the bank, not by a log: the bank's fraud desk runs the SWIFT gpi stop-and-recall or SEPA recall and reports back. Record the call time, the reference the bank gives and the answer in the case; see T+0–5 in Respond for the thresholds.

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

Why: a litigation hold keeps the attacker's rules, drafts and the purged messages recoverable; the audit export outlives the 30-day Entra and advanced-hunting retention; the mailbox audit age limit is 90 days by default and only applies forward; a historical message trace is the only trace older than 10 days.

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

Why: the lookalike domain and the attacker's sender reach every mailbox, not just the one you are evicting; blocking them tenant-wide stops the next message while you work.

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

Why: attack disruption may already have disabled the user (check the Action center first). The order matters: disable stops new tokens being issued while you work, the reset invalidates the password, the revocation kills refresh tokens, and only then are the rules, forwarding, permissions, OAuth grants, MFA methods and devices removed, so none of them re-opens the door before the others are shut. Native Exchange Online and Graph cmdlets only; the archived O365 investigation scripts depend on MSOnline and AzureAD, retired in 2025.

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

Why: the phishing and fraud messages are still sitting in recipients' inboxes; removing them across the tenant stops the next person acting on one. The litigation hold from T+5–15 keeps a recoverable copy.

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

Why: if an infostealer took the cookie or password, the same file may be on other machines; a tenant-wide indicator blocks it everywhere, not just on the victim's laptop.
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

Why: isolation stops the stealer sending new credentials while Live Response still works for collection; the device is rebuilt afterwards, because a cleaned device you can't prove is clean keeps feeding the attacker.

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

Why: adding the BEC response tasks to the incident automatically keeps the one-pass eviction from being done channel by channel.

Content hub › SOAR essentials solution › Defender XDR BEC Playbook for SecOps-Tasks.