Loading_
Loading_
Finds external forwarding, suspicious inbox rules and delegate access across every mailbox — the three artefacts a business email compromise always leaves behind.
#Requires -Modules ExchangeOnlineManagement<#.SYNOPSIS Audits Exchange Online for the three artefacts a BEC always leaves: external forwarding, malicious inbox rules and unexpected delegates. .EXAMPLE .\Get-MailboxForwardingAudit.ps1 -OutputPath .\bec-audit#>[CmdletBinding()]param( [string] $OutputPath = ".\bec-audit", [switch] $IncludeSharedMailboxes) $ErrorActionPreference = "Stop"New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null Connect-ExchangeOnline -ShowBanner:$false # Accepted domains define what counts as "external"$internalDomains = (Get-AcceptedDomain).DomainNameWrite-Host "Internal domains: $($internalDomains -join ', ')" -ForegroundColor DarkGray function Test-External { param([string] $Address) if ([string]::IsNullOrWhiteSpace($Address)) { return $false } $domain = ($Address -split "@")[-1] -replace ">", "" -replace "'", "" return -not ($internalDomains -contains $domain)} $types = if ($IncludeSharedMailboxes) { @("UserMailbox","SharedMailbox") } else { @("UserMailbox") }$mailboxes = Get-Mailbox -ResultSize Unlimited -RecipientTypeDetails $types Write-Host "Auditing $($mailboxes.Count) mailboxes..." -ForegroundColor Cyan $forwards = New-Object System.Collections.Generic.List[object]$rules = New-Object System.Collections.Generic.List[object]$delegates = New-Object System.Collections.Generic.List[object] # Patterns that separate an attacker's rule from a tidy user's rule$suspiciousKeywords = @( "invoice","payment","bank","wire","transfer","urgent","password", "security","phish","suspicious","alert","helpdesk","admin")$suspiciousFolders = @("Deleted Items","Junk Email","RSS Feeds","RSS Subscriptions","Conversation History","Archive") $i = 0foreach ($mailbox in $mailboxes) { $i++ if ($i % 100 -eq 0) { Write-Host " $i / $($mailboxes.Count)" -ForegroundColor DarkGray } $upn = $mailbox.UserPrincipalName # ── 1. Mailbox-level forwarding ────────────────────────────────────── if ($mailbox.ForwardingSmtpAddress -or $mailbox.ForwardingAddress) { $target = if ($mailbox.ForwardingSmtpAddress) { $mailbox.ForwardingSmtpAddress -replace "^smtp:", "" } else { $mailbox.ForwardingAddress } $external = Test-External $target $forwards.Add([pscustomobject]@{ Mailbox = $upn; Target = $target External = $external DeliverAndForward = $mailbox.DeliverToMailboxAndForward Severity = if ($external) { "Critical" } else { "Low" } }) } # ── 2. Inbox rules ─────────────────────────────────────────────────── try { foreach ($rule in Get-InboxRule -Mailbox $upn -ErrorAction Stop) { $score = 0 $reasons = New-Object System.Collections.Generic.List[string] $forwardTo = @($rule.ForwardTo) + @($rule.ForwardAsAttachmentTo) + @($rule.RedirectTo) foreach ($entry in $forwardTo) { if ($entry -and (Test-External "$entry")) { $score += 60; $reasons.Add("Forwards externally to $entry") } } if ($rule.DeleteMessage) { $score += 30; $reasons.Add("Deletes matching mail") } if ($rule.MarkAsRead) { $score += 10; $reasons.Add("Marks as read") } $folder = "$($rule.MoveToFolder)" if ($suspiciousFolders | Where-Object { $folder -like "*$_*" }) { $score += 35; $reasons.Add("Files into $folder") } $conditions = "$($rule.SubjectContainsWords) $($rule.BodyContainsWords) $($rule.SubjectOrBodyContainsWords)".ToLower() $hits = $suspiciousKeywords | Where-Object { $conditions -like "*$_*" } if ($hits) { $score += 25; $reasons.Add("Keyword trigger: $($hits -join ', ')") } if ($score -ge 30) { $rules.Add([pscustomobject]@{ Mailbox = $upn; Rule = $rule.Name; Enabled = $rule.Enabled Score = $score Severity = if ($score -ge 60) { "Critical" } elseif ($score -ge 40) { "High" } else { "Medium" } Reasons = $reasons -join "; " }) } } } catch { Write-Verbose "Rules unavailable for $upn" } # ── 3. Delegates ───────────────────────────────────────────────────── try { Get-MailboxPermission -Identity $upn -ErrorAction Stop | Where-Object { -not $_.IsInherited -and $_.User -notlike "NT AUTHORITY\*" -and $_.User -ne $upn } | ForEach-Object { $delegates.Add([pscustomobject]@{ Mailbox = $upn; Delegate = $_.User Rights = ($_.AccessRights -join ", ") Severity = if ($_.AccessRights -contains "FullAccess") { "Medium" } else { "Low" } }) } } catch { Write-Verbose "Permissions unavailable for $upn" }} $forwards | Sort-Object Severity | Export-Csv (Join-Path $OutputPath "forwarding.csv") -NoTypeInformation -Encoding UTF8$rules | Sort-Object Score -Descending | Export-Csv (Join-Path $OutputPath "inbox-rules.csv") -NoTypeInformation -Encoding UTF8$delegates | Export-Csv (Join-Path $OutputPath "delegates.csv") -NoTypeInformation -Encoding UTF8 $externalForwards = ($forwards | Where-Object External).Count$criticalRules = ($rules | Where-Object Severity -eq "Critical").Count Write-Host ""Write-Host "Audit complete." -ForegroundColor GreenWrite-Host " External forwarding : $externalForwards" -ForegroundColor $(if ($externalForwards) { "Red" } else { "Green" })Write-Host " Critical rules : $criticalRules" -ForegroundColor $(if ($criticalRules) { "Red" } else { "Green" })Write-Host " Delegate grants : $($delegates.Count)"Write-Host ""Write-Host "Written to $OutputPath" -ForegroundColor Green if ($externalForwards -or $criticalRules) { Write-Warning "Triage the critical findings before anything else. These survive a password reset."} Disconnect-ExchangeOnline -Confirm:$falseAfter a mailbox compromise the attacker does three things: sets forwarding to an external address, creates an inbox rule that files security alerts into Deleted Items, and adds a delegate. All three survive a password reset.
This enumerates all of them across every mailbox, resolves whether each forward target is internal or external, and scores rules by the patterns that indicate malice rather than tidiness — moving mail to RSS Feeds, deleting anything mentioning "invoice" or "payment", marking as read and filing away.
Read-only. The output is a triage list ordered by suspicion, not a raw dump of every rule in the tenant.
| Name | Type | Required | Description |
|---|---|---|---|
OutputPath | string | Optional | Directory for the three CSV exports. |
IncludeSharedMailboxes | switch | Optional | Also audit shared mailboxes. |
The platform turns any script into a governed automation — versioned, gated, audited and reversible.