Detecting Business Email Compromise: Malicious Inbox Rules, Auto-Forwarding, and Mailbox Delegation (T1114.003, T1564.008, T1098.002) with KQL and SPL
Most detection libraries are endpoint-heavy. Process creation, DLL loads, registry writes — that is where the mature coverage sits. But a business email compromise (BEC) never touches an endpoint you own. The attacker authenticates as a real user, from a real browser, and then quietly reconfigures the mailbox: a rule that moves anything mentioning invoice into RSS Subscriptions, an SMTP forward to an attacker-controlled domain, a Full Access grant on the CFO's mailbox. No malware, no EDR alert, no beacon.
That post-authentication window is where BEC actually lives, and it maps cleanly to MITRE ATT&CK: T1114.003 (Email Forwarding Rule), T1564.008 (Email Hiding Rules), T1098.002 (Additional Email Delegate Permissions), and T1114.002 (Remote Email Collection). The initial access that precedes it is usually T1078.004 (Cloud Accounts) following credential phishing, token theft, or MFA request generation.
This post gives you working detection logic for each stage, in both KQL (Microsoft Sentinel / Defender XDR) and SPL (Splunk with the Microsoft 365 add-on), plus the tuning notes that decide whether these rules survive contact with production.
Prerequisite: make sure the telemetry exists
These detections depend on the Unified Audit Log. Before you deploy anything, confirm three things:
- Mailbox auditing is on for the mailboxes you care about, and the audit actions you rely on are actually in the audit set — default action sets vary by license and by mailbox type.
- MailItemsAccessed is being generated. This action requires the advanced auditing tier; without it, you cannot detect bulk mailbox reads at all, only configuration changes.
- Audit latency is measured, not assumed. Unified Audit Log records are not real-time. Generate a test inbox rule in your own tenant, timestamp it, and record how long it takes to appear in your SIEM. That number becomes the SLA you communicate to the SOC — and it determines whether "detect" can ever mean "prevent".
Detection 1: Malicious inbox rules (T1114.003, T1564.008)
Inbox rules are the single highest-fidelity BEC signal available, because the malicious variants look nothing like user-created ones. Attackers build rules that (a) forward or redirect externally, or (b) hide replies from finance and security by deleting them or filing them somewhere nobody looks. Scoring on the rule's actions and its keyword conditions together is what keeps volume manageable.
let SuspiciousActions = dynamic(["ForwardTo","ForwardAsAttachmentTo","RedirectTo","DeleteMessage","MoveToFolder","MarkAsRead","StopProcessingRules"]);
let LureKeywords = dynamic(["invoice","payment","wire","remittance","swift","iban","ach","payroll","bank","password","phish","quarantine","helpdesk","mfa"]);
OfficeActivity
| where TimeGenerated > ago(14d)
| where Operation in~ ("New-InboxRule","Set-InboxRule","UpdateInboxRules")
| extend Params = todynamic(Parameters)
| mv-apply P = Params on (
summarize ParamNames = make_set(tostring(P.Name)),
ParamText = strcat_array(make_list(strcat(tostring(P.Name), "=", tostring(P.Value))), " | ")
)
| where ParamNames has_any (SuspiciousActions)
| extend HasLure = ParamText has_any (LureKeywords)
| extend Forwarding = ParamNames has_any (dynamic(["ForwardTo","ForwardAsAttachmentTo","RedirectTo"]))
| extend Severity = case(Forwarding and HasLure, "High",
Forwarding, "Medium",
HasLure, "Medium",
"Low")
| project TimeGenerated, Actor = tolower(UserId), Mailbox = tolower(column_ifexists("MailboxOwnerUPN", "")),
Operation, ClientIP, Severity, ParamText
| order by TimeGenerated descThe equivalent in Splunk, flattening the same parameter array:
index=o365 sourcetype="o365:management:activity" Workload=Exchange
Operation IN ("New-InboxRule","Set-InboxRule","UpdateInboxRules")
| spath output=pjson path=Parameters{}
| mvexpand pjson
| spath input=pjson output=pname path=Name
| spath input=pjson output=pvalue path=Value
| eval pair=pname."=".pvalue
| stats min(_time) as firstTime values(pair) as rule_params
by UserId, MailboxOwnerUPN, Operation, ClientIP
| eval rule_text=lower(mvjoin(rule_params, " "))
| where match(rule_text, "(forwardto|forwardasattachmentto|redirectto|deletemessage|movetofolder)")
| eval lure=if(match(rule_text, "(invoice|payment|wire|remittance|swift|iban|payroll|bank|password|phish|quarantine)"), "yes", "no")
| eval fwd=if(match(rule_text, "(forwardto|forwardasattachmentto|redirectto)"), "yes", "no")
| eval severity=case(fwd=="yes" AND lure=="yes", "high", fwd=="yes", "medium", lure=="yes", "medium", true(), "low")
| convert ctime(firstTime)
| table firstTime severity UserId MailboxOwnerUPN Operation ClientIP rule_textTuning notes. Outlook's Sweep and "clean up conversation" features generate legitimate Set-InboxRule and MoveToFolder activity, which is why the Low severity bucket exists — send it to a hunting table, not the analyst queue. Mail migration and archiving tools create rules in bulk under service identities; allowlist them by UserId, never by ClientIP. And watch for the tell that beats everything else: a rule where the acting UserId differs from MailboxOwnerUPN. That means someone with delegate rights created a rule in a mailbox that is not theirs, and it should alert regardless of the rule's contents.
Detection 2: External auto-forwarding at the mailbox and transport layer
Inbox rules are the loud version. The quieter path is mailbox-level forwarding (ForwardingSmtpAddress) or, when the attacker has reached an admin role, a transport rule that silently BCCs all mail. Transport-level forwarding is the highest-impact variant in this whole post and should be treated as a critical alert by default.
let AcceptedDomains = dynamic(["contoso.com","contoso.mail.onmicrosoft.com"]);
OfficeActivity
| where TimeGenerated > ago(14d)
| where Operation in~ ("Set-Mailbox","Set-RemoteDomain","New-TransportRule","Set-TransportRule","Set-HostedOutboundSpamFilterPolicy")
| extend Params = todynamic(Parameters)
| mv-expand Params
| extend PName = tostring(Params.Name), PValue = tostring(Params.Value)
| where PName in~ ("ForwardingSmtpAddress","ForwardingAddress","DeliverToMailboxAndForward",
"AutoForwardEnabled","RedirectMessageTo","BlindCopyTo","AddToRecipients")
| extend FwdDomain = tolower(tostring(split(replace_string(tolower(PValue), "smtp:", ""), "@")[1]))
| extend IsExternal = isnotempty(FwdDomain) and FwdDomain !in~ (AcceptedDomains)
| where IsExternal or PName in~ ("AutoForwardEnabled","DeliverToMailboxAndForward")
| project TimeGenerated, Actor = tolower(UserId), TargetObject = OfficeObjectId, Operation, PName, PValue, FwdDomain, ClientIP
| order by TimeGenerated descIn SPL, the same idea with domain extraction and an accepted-domain lookup:
index=o365 sourcetype="o365:management:activity" Workload IN ("Exchange","ExchangeAdmin")
Operation IN ("Set-Mailbox","Set-RemoteDomain","New-TransportRule","Set-TransportRule")
| spath output=pjson path=Parameters{}
| mvexpand pjson
| spath input=pjson output=pname path=Name
| spath input=pjson output=pvalue path=Value
| search pname IN ("ForwardingSmtpAddress","ForwardingAddress","RedirectMessageTo","BlindCopyTo","AutoForwardEnabled")
| eval fwd_domain=lower(replace(replace(lower(pvalue), "smtp:", ""), "^.*@", ""))
| lookup accepted_domains.csv domain as fwd_domain OUTPUT domain as known
| where isnull(known) OR pname=="AutoForwardEnabled"
| table _time UserId ObjectId Operation pname pvalue fwd_domain ClientIPMaintain accepted_domains.csv from your actual tenant configuration and include partner domains you genuinely forward to. If your organisation blocks automatic external forwarding by policy, then any hit here is either a misconfiguration or an intrusion — both worth a ticket.
Detection 3: Delegate and SendAs permission grants (T1098.002)
Delegation is the persistence mechanism that survives a password reset. An attacker who grants themselves Full Access or SendAs on a target mailbox keeps read access — and the ability to send convincing internal mail — long after the compromised account is remediated. This is also the technique most often missed during incident response, because responders reset credentials and revoke sessions without auditing permissions.
index=o365 sourcetype="o365:management:activity"
Operation IN ("Add-MailboxPermission","Add-RecipientPermission","AddFolderPermissions","UpdateFolderPermissions","Add-MailboxFolderPermission")
| spath output=pjson path=Parameters{}
| mvexpand pjson
| spath input=pjson output=pname path=Name
| spath input=pjson output=pvalue path=Value
| eval pair=pname."=".pvalue
| stats values(pair) as params by _time, UserId, ObjectId, Operation, ClientIP
| eval p=lower(mvjoin(params, " "))
| where match(p, "(fullaccess|sendas|sendonbehalf|owner|editor)")
| eval self_grant=if(lower(UserId)==lower(ObjectId), "yes", "no")
| search NOT [| inputlookup mailbox_admin_allowlist.csv | fields UserId ]
| table _time UserId ObjectId Operation self_grant ClientIP pTwo enrichments matter more than the base query. First, self_grant: an identity granting itself rights on another mailbox is far more interesting than a helpdesk admin provisioning a shared mailbox. Second, flag grants against VIP mailboxes from a watchlist — finance, executive, and payroll mailboxes deserve alerting at a lower threshold than the rest of the tenant.
Detection 4: Bulk mailbox reads (T1114.002)
When an attacker is harvesting rather than redirecting, the signal is access volume: many folders, many items, in a short window, often from a non-interactive client. MailItemsAccessed in CloudAppEvents captures this.
CloudAppEvents
| where Timestamp > ago(7d)
| where ActionType == "MailItemsAccessed"
| extend Raw = RawEventData
| extend Mailbox = tolower(tostring(Raw.MailboxOwnerUPN)),
AppId = tostring(Raw.AppId),
ClientIPAddr = tostring(Raw.ClientIPAddress),
ClientInfo = tostring(Raw.ClientInfoString),
Folders = Raw.Folders
| mv-expand Folders
| summarize DistinctFolders = dcount(tostring(Folders.Path)),
AccessEvents = count(),
SourceIPs = make_set(ClientIPAddr, 10),
Clients = make_set(ClientInfo, 5)
by bin(Timestamp, 1h), Mailbox, AppId
| where DistinctFolders >= 10 and AccessEvents >= 50
| order by AccessEvents descThresholds here are tenant-specific by definition. Run the summarize without the final where for two weeks, look at the distribution per mailbox, and set your floor above the 99th percentile of normal. Exclude known AppId values for backup, eDiscovery, archiving, and journaling applications — these legitimately sweep entire mailboxes and will otherwise dominate the results.
Correlation: the rule that actually pages someone
Individually, each detection above produces noise. Chained to an anomalous sign-in, they produce incidents. The pattern to encode is: successful authentication from an autonomous system this user has never used, followed within a couple of hours by a mailbox configuration change.
let LookbackBaseline = 30d;
let KnownASN = SigninLogs
| where TimeGenerated between (ago(LookbackBaseline) .. ago(2d))
| where ResultType == 0
| summarize by User = tolower(UserPrincipalName), ASN = tostring(AutonomousSystemNumber);
let NewGeoSignIns = SigninLogs
| where TimeGenerated > ago(2d) and ResultType == 0
| extend User = tolower(UserPrincipalName), ASN = tostring(AutonomousSystemNumber)
| join kind=leftanti KnownASN on User, ASN
| project SignInTime = TimeGenerated, User, ASN, SignInIP = IPAddress, UserAgent, AppDisplayName;
let MailboxChanges = OfficeActivity
| where TimeGenerated > ago(2d)
| where Operation in~ ("New-InboxRule","Set-InboxRule","UpdateInboxRules","Set-Mailbox",
"Add-MailboxPermission","Add-RecipientPermission","New-TransportRule")
| project ChangeTime = TimeGenerated, User = tolower(UserId), Operation, ChangeIP = ClientIP, Parameters;
NewGeoSignIns
| join kind=inner MailboxChanges on User
| where ChangeTime between (SignInTime .. SignInTime + 2h)
| project SignInTime, ChangeTime, User, ASN, SignInIP, ChangeIP, UserAgent, Operation, Parameters
| order by ChangeTime descDeploy the individual detections at low severity into a hunting workflow, and this correlation at high severity into the analyst queue. The individual rules give you retrospective scope during an investigation; the correlation gives you the page.
Validating the coverage
Do not ship these untested. In a test tenant, create a forwarding rule with a finance keyword condition (New-InboxRule -SubjectContainsWords "invoice" -ForwardTo an external address), set a mailbox-level ForwardingSmtpAddress, grant Full Access from one test user to another, and access a large number of folders in a target mailbox via a script. For each action, record: did the event appear, how long did it take, did the rule fire, and was the alert body sufficient to triage without pivoting? The last question is where most detections fail — an alert that says "suspicious inbox rule created" with no rule text forces the analyst into the audit log anyway.
Triage order for a confirmed hit
- Enumerate all mailbox configuration for the affected identity: inbox rules, forwarding addresses, delegates, folder permissions, and registered OAuth grants. Attackers rarely plant only one mechanism.
- Revoke sessions and refresh tokens, not just the password. A password reset alone leaves stolen refresh tokens usable.
- Scope the delegation graph. Any mailbox the compromised identity had rights over is in scope, and so is any identity holding rights over it.
- Check for sent mail and financial context. Search for outbound messages referencing payment changes, and pull the audit trail for the mailboxes that received them.
- Look one step back. Correlate to the initial access vector — phishing, token theft, or consent abuse — and confirm whether other identities were targeted in the same wave.
BEC is not a malware problem, and it will not show up in your endpoint coverage metrics. It is an identity-and-configuration problem, detected in audit logs, and the detections above cover the four mailbox actions that every BEC operator eventually performs. If your detection library has full coverage of process injection and none of New-InboxRule, that gap is worth closing this sprint.