Overview#
Builds the list of accepted domains with Get-AcceptedDomain and treats every other domain as external. For each mailbox it checks (a) mailbox-level forwarding (ForwardingSmtpAddress and ForwardingAddress, the latter resolved with Get-EXORecipient so mail contacts and mail users that point outside are detected) and (b) inbox rules (Get-InboxRule) that forward, forward as attachment or redirect to external recipients - the classic business email compromise (BEC) persistence technique. One row per finding is written to CSV. With -Remediate (and confirmation) offending inbox rules are disabled and mailbox-level forwarding is cleared; the default is a read-only report.
Safety: Makes changes only when you pass an explicit switch; supports -WhatIf and -Confirm. Run Get-Help .\Get-EXOExternalForwardingReport.ps1 -Full for the complete help text.
Parameters#
| Parameter | What it does |
|---|---|
-Identity | One or more mailbox identities (UPN, primary SMTP address, alias or GUID). When omitted, all user and shared mailboxes are scanned. |
-IncludeInternalForwarding | Also report forwarding to recipients inside the tenant (IsExternal = False). Internal rows are never remediated. |
-Remediate | Disable each external forwarding inbox rule (Disable-InboxRule) and clear mailbox-level forwarding (Set-Mailbox). Every change is wrapped in ShouldProcess, so -WhatIf and -Confirm work. |
-OutputPath | Path of the CSV report. Defaults to .\Reports\EXOExternalForwarding_yyyyMMdd-HHmm.csv. |
-PassThru | Also emit the finding objects to the pipeline. |
Examples#
PS> .\Get-EXOExternalForwardingReport.ps1Scans all user and shared mailboxes and writes every external forward to .\Reports\EXOExternalForwarding_<timestamp>.csv.
PS> .\Get-EXOExternalForwardingReport.ps1 -Identity finance@contoso.com -IncludeInternalForwarding -PassThruLists every forward (internal and external) configured on one mailbox and returns the rows to the pipeline.
PS> .\Get-EXOExternalForwardingReport.ps1 -Remediate -WhatIfShows which inbox rules would be disabled and which mailboxes would have forwarding cleared, without changing anything.
Permissions, modules and notes#
Author : Omer Eltayeb Blog : https://www.oeltayeb.com GitHub : https://github.com/omer-eltayeb Version : 1.0.0 Requires : PowerShell 5.1 or 7.x, ExchangeOnlineManagement 3.x Permissions : View-Only Recipients for the report; Exchange Administrator (or Mail Recipients role) for -Remediate Notes : Get-InboxRule is a per-mailbox call and is the slow part - expect 1-3 seconds per mailbox, so run large tenants during off-hours. Changing inbox rules through PowerShell deletes any rules the user previously turned off in Outlook, which is why Disable-InboxRule is called with -Force. Prevention beats detection: block automatic forwarding in the Defender for Office 365 outbound spam policy (Set-HostedOutboundSpamFilterPolicy -AutoForwardingMode Off), or use a mail flow (transport) rule that rejects auto-forwarded messages sent to recipients outside the organization.
Full source#
<#
.SYNOPSIS
Finds mail leaving the tenant through mailbox forwarding or inbox rules, and optionally removes it.
.DESCRIPTION
Builds the list of accepted domains with Get-AcceptedDomain and treats every other domain as external.
For each mailbox it checks (a) mailbox-level forwarding (ForwardingSmtpAddress and ForwardingAddress, the
latter resolved with Get-EXORecipient so mail contacts and mail users that point outside are detected) and
(b) inbox rules (Get-InboxRule) that forward, forward as attachment or redirect to external recipients - the
classic business email compromise (BEC) persistence technique.
One row per finding is written to CSV. With -Remediate (and confirmation) offending inbox rules are
disabled and mailbox-level forwarding is cleared; the default is a read-only report.
.PARAMETER Identity
One or more mailbox identities (UPN, primary SMTP address, alias or GUID). When omitted, all user and
shared mailboxes are scanned.
.PARAMETER IncludeInternalForwarding
Also report forwarding to recipients inside the tenant (IsExternal = False). Internal rows are never remediated.
.PARAMETER Remediate
Disable each external forwarding inbox rule (Disable-InboxRule) and clear mailbox-level forwarding
(Set-Mailbox). Every change is wrapped in ShouldProcess, so -WhatIf and -Confirm work.
.PARAMETER OutputPath
Path of the CSV report. Defaults to .\Reports\EXOExternalForwarding_yyyyMMdd-HHmm.csv.
.PARAMETER PassThru
Also emit the finding objects to the pipeline.
.EXAMPLE
PS> .\Get-EXOExternalForwardingReport.ps1
Scans all user and shared mailboxes and writes every external forward to .\Reports\EXOExternalForwarding_<timestamp>.csv.
.EXAMPLE
PS> .\Get-EXOExternalForwardingReport.ps1 -Identity finance@contoso.com -IncludeInternalForwarding -PassThru
Lists every forward (internal and external) configured on one mailbox and returns the rows to the pipeline.
.EXAMPLE
PS> .\Get-EXOExternalForwardingReport.ps1 -Remediate -WhatIf
Shows which inbox rules would be disabled and which mailboxes would have forwarding cleared, without changing anything.
.NOTES
Author : Omer Eltayeb
Blog : https://www.oeltayeb.com
GitHub : https://github.com/omer-eltayeb
Version : 1.0.0
Requires : PowerShell 5.1 or 7.x, ExchangeOnlineManagement 3.x
Permissions : View-Only Recipients for the report; Exchange Administrator (or Mail Recipients role) for -Remediate
Notes : Get-InboxRule is a per-mailbox call and is the slow part - expect 1-3 seconds per mailbox, so run
large tenants during off-hours. Changing inbox rules through PowerShell deletes any rules the user
previously turned off in Outlook, which is why Disable-InboxRule is called with -Force.
Prevention beats detection: block automatic forwarding in the Defender for Office 365 outbound spam
policy (Set-HostedOutboundSpamFilterPolicy -AutoForwardingMode Off), or use a mail flow (transport)
rule that rejects auto-forwarded messages sent to recipients outside the organization.
.LINK
https://learn.microsoft.com/defender-office-365/outbound-spam-policies-external-email-forwarding
.LINK
https://learn.microsoft.com/powershell/module/exchange/get-inboxrule
#>
#Requires -Version 5.1
#Requires -Modules ExchangeOnlineManagement
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter()]
[string[]]$Identity,
[Parameter()]
[switch]$IncludeInternalForwarding,
[Parameter()]
[switch]$Remediate,
[Parameter()]
[string]$OutputPath,
[Parameter()]
[switch]$PassThru
)
$ErrorActionPreference = 'Stop'
#region Helpers
function Connect-ExchangeIfNeeded {
<# Connects to Exchange Online (or Security & Compliance PowerShell) only when no live session exists. #>
[CmdletBinding()]
param(
[Parameter()]
[switch]$Compliance
)
$connections = @(Get-ConnectionInformation -ErrorAction SilentlyContinue)
if ($Compliance) {
$active = @($connections | Where-Object { $_.ConnectionUri -like '*compliance*' -and $_.State -eq 'Connected' })
if ($active.Count -eq 0) {
Write-Verbose 'Connecting to Security & Compliance PowerShell.'
Connect-IPPSSession -ErrorAction Stop
}
}
else {
$active = @($connections | Where-Object { $_.ConnectionUri -notlike '*compliance*' -and $_.State -eq 'Connected' })
if ($active.Count -eq 0) {
Write-Verbose 'Connecting to Exchange Online.'
Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
}
}
}
function Resolve-ForwardingTarget {
<# Turns a forwarding entry - "Name" [SMTP:addr], "Name" [EX:legacyDN], smtp:addr or a recipient identity - into an SMTP address.
Directory recipients are resolved through a cached Get-EXORecipient lookup so mail contacts and mail users expose their external address. #>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Entry
)
$text = $Entry.Trim() -replace '^smtp:', ''
if ($text -match '\[SMTP:([^\]]+)\]') { return $Matches[1] }
if ($text -match '^[^\s"@\[\]]+@[^\s"@\[\]]+$') { return $text }
$lookupKey = $text
if ($text -match '\[EX:([^\]]+)\]') { $lookupKey = $Matches[1] }
if (-not $script:RecipientCache.ContainsKey($lookupKey)) {
$resolved = $text
try {
$recipientParams = @{ Properties = 'PrimarySmtpAddress', 'ExternalEmailAddress'; ErrorAction = 'Stop' }
if ($lookupKey -like '/o=*') {
$recipientParams['Filter'] = "LegacyExchangeDN -eq '$lookupKey'"
}
else {
$recipientParams['Identity'] = $lookupKey
}
$recipient = Get-EXORecipient @recipientParams | Select-Object -First 1
if ($null -ne $recipient) {
if (-not [string]::IsNullOrWhiteSpace($recipient.ExternalEmailAddress)) {
$resolved = ([string]$recipient.ExternalEmailAddress -replace '^smtp:', '')
}
elseif (-not [string]::IsNullOrWhiteSpace($recipient.PrimarySmtpAddress)) {
$resolved = [string]$recipient.PrimarySmtpAddress
}
}
}
catch {
Write-Verbose "Could not resolve forwarding recipient '$lookupKey': $($_.Exception.Message)"
}
$script:RecipientCache[$lookupKey] = $resolved
}
return $script:RecipientCache[$lookupKey]
}
function Test-ExternalAddress {
<# True when the value is an SMTP address whose domain is not an accepted domain of the tenant. #>
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
[AllowEmptyString()]
[string]$Address
)
if ([string]::IsNullOrWhiteSpace($Address)) { return $false }
if ($Address -match '^[^\s"@\[\]]+@([^\s"@\[\]]+)$') {
return ($script:AcceptedDomains -notcontains $Matches[1].ToLowerInvariant())
}
# Unresolved directory entries ("Name" [EX:...]) stay inside the tenant and are not flagged.
return $false
}
#endregion Helpers
#region Main
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$reportFolder = Join-Path -Path (Get-Location).Path -ChildPath 'Reports'
$OutputPath = Join-Path -Path $reportFolder -ChildPath ('EXOExternalForwarding_{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmm'))
}
$outputFolder = Split-Path -Path $OutputPath -Parent
if (-not [string]::IsNullOrWhiteSpace($outputFolder) -and -not (Test-Path -Path $outputFolder)) {
New-Item -Path $outputFolder -ItemType Directory -Force | Out-Null
}
try {
Connect-ExchangeIfNeeded
}
catch {
throw "Unable to connect to Exchange Online: $($_.Exception.Message)"
}
$script:RecipientCache = @{}
try {
$script:AcceptedDomains = @(Get-AcceptedDomain -ErrorAction Stop | ForEach-Object { ([string]$_.DomainName).ToLowerInvariant() })
}
catch {
throw "Failed to read accepted domains: $($_.Exception.Message)"
}
if ($script:AcceptedDomains.Count -eq 0) { throw 'Get-AcceptedDomain returned no domains; external recipients cannot be classified.' }
Write-Verbose "Accepted domains: $($script:AcceptedDomains -join ', ')"
$mailboxProperties = @('DisplayName', 'UserPrincipalName', 'PrimarySmtpAddress', 'RecipientTypeDetails', 'ForwardingAddress', 'ForwardingSmtpAddress', 'DeliverToMailboxAndForward')
$mailboxes = New-Object -TypeName System.Collections.Generic.List[object]
if ($PSBoundParameters.ContainsKey('Identity')) {
foreach ($id in $Identity) {
try {
$mailboxes.Add((Get-EXOMailbox -Identity $id -Properties $mailboxProperties -ErrorAction Stop))
}
catch {
Write-Warning "Mailbox '$id' was not found or is not accessible: $($_.Exception.Message)"
}
}
}
else {
try {
foreach ($mailbox in (Get-EXOMailbox -ResultSize Unlimited -RecipientTypeDetails UserMailbox, SharedMailbox -Properties $mailboxProperties -ErrorAction Stop)) {
$mailboxes.Add($mailbox)
}
}
catch {
throw "Failed to retrieve mailboxes: $($_.Exception.Message)"
}
}
Write-Verbose "Scanning $($mailboxes.Count) mailbox(es)."
# Inbox-rule properties that send mail elsewhere, mapped to the FindingType they produce.
$ruleTargetTypes = @{ ForwardTo = 'InboxRuleForward'; ForwardAsAttachmentTo = 'InboxRuleForwardAsAttachment'; RedirectTo = 'InboxRuleRedirect' }
$findings = New-Object -TypeName System.Collections.Generic.List[object]
$index = 0
foreach ($mailbox in $mailboxes) {
$index++
$status = '{0} of {1} - {2} ({3} findings so far)' -f $index, $mailboxes.Count, $mailbox.UserPrincipalName, $findings.Count
Write-Progress -Activity 'Checking forwarding and inbox rules' -Status $status -PercentComplete (($index / $mailboxes.Count) * 100)
$candidates = New-Object -TypeName System.Collections.Generic.List[object]
if (-not [string]::IsNullOrWhiteSpace($mailbox.ForwardingSmtpAddress)) {
$candidates.Add(@{ Type = 'MailboxForwardingSmtp'; Rule = $null; Entries = @([string]$mailbox.ForwardingSmtpAddress) })
}
if (-not [string]::IsNullOrWhiteSpace($mailbox.ForwardingAddress)) {
$candidates.Add(@{ Type = 'MailboxForwardingRecipient'; Rule = $null; Entries = @([string]$mailbox.ForwardingAddress) })
}
try {
$rules = @(Get-InboxRule -Mailbox $mailbox.UserPrincipalName -ErrorAction Stop)
}
catch {
Write-Warning "Could not read inbox rules for '$($mailbox.UserPrincipalName)': $($_.Exception.Message)"
$rules = @()
}
foreach ($rule in $rules) {
foreach ($propertyName in $ruleTargetTypes.Keys) {
$entries = @($rule.$propertyName | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | ForEach-Object { [string]$_ })
if ($entries.Count -gt 0) {
$candidates.Add(@{ Type = $ruleTargetTypes[$propertyName]; Rule = $rule; Entries = $entries })
}
}
}
foreach ($candidate in $candidates) {
$addresses = @(foreach ($entry in $candidate.Entries) { Resolve-ForwardingTarget -Entry $entry })
$external = @($addresses | Where-Object { Test-ExternalAddress -Address $_ })
if ($external.Count -eq 0 -and -not $IncludeInternalForwarding) { continue }
$rule = $candidate.Rule
$description = $null
if ($null -ne $rule -and -not [string]::IsNullOrWhiteSpace($rule.Description)) {
$description = ([string]$rule.Description -replace '\s+', ' ').Trim()
if ($description.Length -gt 300) { $description = $description.Substring(0, 300) }
}
$findings.Add([PSCustomObject]@{
Mailbox = $mailbox.DisplayName
UserPrincipalName = $mailbox.UserPrincipalName
FindingType = $candidate.Type
RuleName = $(if ($null -ne $rule) { [string]$rule.Name } else { $null })
RuleIdentity = $(if ($null -ne $rule) { [string]$rule.RuleIdentity } else { $null })
RuleEnabled = $(if ($null -ne $rule) { [bool]$rule.Enabled } else { $null })
IsExternal = ($external.Count -gt 0)
ExternalRecipients = ($external -join ';')
AllRecipients = ($addresses -join ';')
DeliverToMailboxAndForward = [bool]$mailbox.DeliverToMailboxAndForward
RuleDescription = $description
Remediated = $false
})
}
}
Write-Progress -Activity 'Checking forwarding and inbox rules' -Completed
if ($Remediate) {
$clearedMailboxes = @{}
foreach ($finding in @($findings | Where-Object { $_.IsExternal })) {
try {
if ($finding.FindingType -like 'InboxRule*') {
if ($finding.RuleEnabled -and $PSCmdlet.ShouldProcess($finding.UserPrincipalName, "Disable inbox rule '$($finding.RuleName)' forwarding to $($finding.ExternalRecipients)")) {
Disable-InboxRule -Identity $finding.RuleIdentity -Mailbox $finding.UserPrincipalName -Force -Confirm:$false -ErrorAction Stop
$finding.Remediated = $true
}
}
elseif (-not $clearedMailboxes.ContainsKey($finding.UserPrincipalName)) {
if ($PSCmdlet.ShouldProcess($finding.UserPrincipalName, "Clear mailbox forwarding to $($finding.ExternalRecipients)")) {
Set-Mailbox -Identity $finding.UserPrincipalName -ForwardingSmtpAddress $null -ForwardingAddress $null -DeliverToMailboxAndForward $false -ErrorAction Stop
$clearedMailboxes[$finding.UserPrincipalName] = $true
$finding.Remediated = $true
}
}
else {
# Both ForwardingSmtpAddress and ForwardingAddress were set; a single Set-Mailbox already cleared them.
$finding.Remediated = $true
}
}
catch {
Write-Warning "Remediation failed for '$($finding.UserPrincipalName)' ($($finding.FindingType)): $($_.Exception.Message)"
}
}
}
if ($findings.Count -gt 0) {
$findings | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
else {
Write-Verbose 'No forwarding was found; no CSV written.'
}
$externalFindings = @($findings | Where-Object { $_.IsExternal })
$affectedMailboxes = @($externalFindings | Select-Object -ExpandProperty UserPrincipalName -Unique)
$externalRules = @($externalFindings | Where-Object { $_.FindingType -like 'InboxRule*' })
$externalMailboxLevel = @($externalFindings | Where-Object { $_.FindingType -like 'MailboxForwarding*' })
Write-Host ''
Write-Host 'External forwarding summary' -ForegroundColor Cyan
Write-Host (' Mailboxes scanned : {0}' -f $mailboxes.Count)
Write-Host (' Mailboxes with external forwarding : {0}' -f $affectedMailboxes.Count) -ForegroundColor $(if ($affectedMailboxes.Count -gt 0) { 'Yellow' } else { 'Green' })
Write-Host (' External inbox rules : {0}' -f $externalRules.Count)
Write-Host (' Mailbox-level external forwards : {0}' -f $externalMailboxLevel.Count)
if ($IncludeInternalForwarding) {
Write-Host (' Internal forwards (informational) : {0}' -f @($findings | Where-Object { -not $_.IsExternal }).Count)
}
if ($Remediate) {
Write-Host (' Remediated : {0}' -f @($findings | Where-Object { $_.Remediated }).Count) -ForegroundColor Green
}
if ($findings.Count -gt 0) {
Write-Host (' Report : {0}' -f $OutputPath)
}
if ($PassThru) {
$findings
}
#endregion Main