Monitor External Mailbox Forwards in all Office 365 Customer tenants
It’s common for hackers and bad actors to set up an external email forwarder on an Office 365 account they have gained access to.
We’ve come across this action on almost every account breach we’ve investigated, and have implemented this monitoring solution to help identify breaches when they occur.
It works by monitoring the ForwardingSMTPAddress property on Office 365 Exchange Online mailboxes. It compares the domain for this address against the accepted domains for your organisation. If this mailbox is forwarding externally, an action will be performed.
It’s important to note that in almost all cases we’ve looked at, the breaches were due to phishing attacks, with the users willingly providing their credentials to non-Microsoft login screens. No breaches were attributed to vulnerabilities in the Office 365 service and all could have been prevented by implementing multi factor authentication.
This solution involves the following components
A Timer Triggered PowerShell Azure Function
This script will check the mailboxes for all customer Office 365 tenants for external forwards. If a forward is detected, it will then update our table storage and trigger a Microsoft Flow
Azure table storage
Table storage stores details about the external forwards, and whether or not they’re approved.
Microsoft Flow
Microsoft Flow is used for notifications, and to allow us to quickly approve or remove detected forwards
A HTTP Triggered PowerShell Azure Function
This Azure function will either remove a forward from a mailbox, or update the table storage when the forward is approved.
Standalone script to check all Office 365 customer tenants for external email forwards
Before you implement this solution, I recommend you kick off this script. It’ll scan all customer tenants for external forwards, and export the details to a CSV. This script will let you know whether your customers are currently being affected by an unauthorised external forward.
How to monitor Office 365 customer tenants for external email forwards
The first step is to set up your two Azure Functions.
Use the steps in this guide to create an Azure Function App and two PowerShell Azure functions that can connect to Office 365. You’ll need to change it a little as follows:
- Create an Azure Function App
- Ensure that your Azure Function can connect to Office 365 tenants with an MFA enabled account by whitelisting its IPs.
- Encrypt your Office 365 delegated admin credentials and add the details to the Function App’s Application Settings. You’ll need to use the credentials of a user with delegated admin permissions on your customer tenants.
- Create a Timer Triggered PowerShell Azure Function called TT-CheckForwardingAddresses and set it to run on a daily schedule: eg: 0 0 19 * * *
- Create a HTTP Triggered PowerShell Azure Function called HT-HandleForwardingAddresses
- Make sure you’ve connected to the function app via FTP and uploaded the key and MSOnline module into a bin folder within both function folders.
- Copy and paste the below scripts into the functions and click save.
- Copy the URL from the HTTP Triggered Azure Function by clicking Get function URL on the top right of the function, and save it somewhere for later.
Script for Azure Function to monitor external forwarding addresses (TT-CheckForwardingAddresses)
$FunctionName = 'TT-CheckForwardingAddresses' $ModuleName = 'MSOnline' $ModuleVersion = '1.1.166.0' $username = $Env:user $pw = $Env:password #import PS module $PSModulePath = "D:\home\site\wwwroot\$FunctionName\bin\$ModuleName\$ModuleVersion\$ModuleName.psd1" # $flowUri = "URLFromMicrosoftFlowGoesHere" $storageAccount = "StorageAccountNameGoesHere" $accesskey = "StorageAccountPrimaryKeyGoesHere" $TableName = "externalforwardingaddress" Import-module $PSModulePath # Build Credentials $keypath = "D:\home\site\wwwroot\$FunctionName\bin\keys\PassEncryptKey.key" $secpassword = $pw | ConvertTo-SecureString -Key (Get-Content $keypath) $credential = New-Object System.Management.Automation.PSCredential ($username, $secpassword) function GetTableEntityAll($TableName) { $version = "2017-04-17" $resource = "$tableName" $table_url = "https://$storageAccount.table.core.windows.net/$resource" $GMTTime = (Get-Date).ToUniversalTime().toString('R') $stringToSign = "$GMTTime`n/$storageAccount/$resource" $hmacsha = New-Object System.Security.Cryptography.HMACSHA256 $hmacsha.key = [Convert]::FromBase64String($accesskey) $signature = $hmacsha.ComputeHash([Text.Encoding]::UTF8.GetBytes($stringToSign)) $signature = [Convert]::ToBase64String($signature) $headers = @{ 'x-ms-date' = $GMTTime Authorization = "SharedKeyLite " + $storageAccount + ":" + $signature "x-ms-version" = $version Accept = "application/json;odata=fullmetadata" } $item = Invoke-RestMethod -Method GET -Uri $table_url -Headers $headers -ContentType application/json return $item.value } function InsertReplaceTableEntity($TableName, $PartitionKey, $RowKey, $entity) { $version = "2017-04-17" $resource = "$tableName(PartitionKey='$PartitionKey',RowKey='$Rowkey')" $table_url = "https://$storageAccount.table.core.windows.net/$resource" $GMTTime = (Get-Date).ToUniversalTime().toString('R') $stringToSign = "$GMTTime`n/$storageAccount/$resource" $hmacsha = New-Object System.Security.Cryptography.HMACSHA256 $hmacsha.key = [Convert]::FromBase64String($accesskey) $signature = $hmacsha.ComputeHash([Text.Encoding]::UTF8.GetBytes($stringToSign)) $signature = [Convert]::ToBase64String($signature) $headers = @{ 'x-ms-date' = $GMTTime Authorization = "SharedKeyLite " + $storageAccount + ":" + $signature "x-ms-version" = $version Accept = "application/json;odata=fullmetadata" } $body = $entity | ConvertTo-Json $item = Invoke-RestMethod -Method PUT -Uri $table_url -Headers $headers -Body $body -ContentType application/json } # Connect to MSOnline Connect-MsolService -Credential $credential -erroraction SilentlyContinue # Start Script $forwards = GetTableEntityAll -TableName $TableName $customers = Get-msolpartnercontract -All -erroraction SilentlyContinue foreach ($customer in $customers) { Write-Output "Connecting to $($customer.name)" $Msoldomains = Get-MsolDomain -TenantId $customer.tenantid | Sort-Object Name $InitialDomain = ($Msoldomains | Where-Object {$_.IsInitial}) try { $DelegatedOrgURL = "https://outlook.office365.com/powershell-liveid?DelegatedOrg=" + $InitialDomain.Name $s = New-PSSession -ConnectionUri $DelegatedOrgURL -Credential $credential -Authentication Basic -ConfigurationName Microsoft.Exchange -AllowRedirection -erroraction SilentlyContinue Import-PSSession $s -CommandName Get-Mailbox, Get-AcceptedDomain -AllowClobber -ErrorAction Stop $mailboxes = $null $mailboxes = Get-Mailbox -ResultSize Unlimited $domains = $null $domains = Get-AcceptedDomain | Sort-Object name $domainMatch = $false if ($Msoldomains[0].name -contains $domains[0].name) { $domainMatch = $true } if ($domainmatch) { foreach ($mailbox in $mailboxes) { $forwardingSMTPAddress = $null $forwardingSMTPAddress = $mailbox.forwardingsmtpaddress $externalRecipient = $null if ($forwardingSMTPAddress) { $email = ($forwardingSMTPAddress -split "SMTP:")[1] $domain = ($email -split "@")[1] if ($domains.DomainName -notcontains $domain) { $externalRecipient = $email } if ($externalRecipient) { Write-Output "$($mailbox.displayname) - $($mailbox.primarysmtpaddress) forwards to $externalRecipient" $forwardHash = $null $forwardHash = [ordered]@{ Customer = $customer.Name TenantId = $customer.TenantId PrimarySmtpAddress = $mailbox.PrimarySmtpAddress DisplayName = $mailbox.DisplayName ExternalRecipient = $externalRecipient } $filter = $null $filter = $forwards | Where-Object {$_.primarysmtpaddress -contains $mailbox.PrimarySmtpAddress} if (!$filter -or $filter.externalrecipient -notcontains $externalRecipient) { InsertReplaceTableEntity -TableName $TableName -PartitionKey $customer.TenantId -RowKey $mailbox.PrimarySmtpAddress -entity $forwardHash } if ($filter.allowedaddress -notcontains $externalRecipient) { $json = $forwardHash | ConvertTo-Json Invoke-RestMethod -method POST -uri $flowUri -body $json -ContentType application/json } } } } } Remove-PSSession $s -erroraction SilentlyContinue } catch { Write-Output "Couldn't connect to $($customer.name)" } }
Script for Azure Function to handle external forwarding addresses (HT-HandleForwardingAddresses)
# GET method: each querystring parameter is its own variable if ($req_query_tenantid) { $tenantid = $req_query_tenantid } if ($req_query_response) { $response = $req_query_response } if ($req_query_user) { $user = $req_query_user } if ($req_query_ext) { $ext = $req_query_ext } $FunctionName = 'HT-HandleForwardingAddresses' $ModuleName = 'MSOnline' $ModuleVersion = '1.1.166.0' $username = $Env:user $pw = $Env:password #import PS module $PSModulePath = "D:\home\site\wwwroot\$FunctionName\bin\$ModuleName\$ModuleVersion\$ModuleName.psd1" $storageAccount = "StorageAccountNameGoesHere" $accesskey = "StorageAccountPrimaryKeyGoesHere" $TableName = "externalforwardingaddress" function MergeTableEntity($TableName, $PartitionKey, $RowKey, $entity) { $version = "2017-04-17" $resource = "$tableName(PartitionKey='$PartitionKey',RowKey='$Rowkey')" $table_url = "https://$storageAccount.table.core.windows.net/$resource" $GMTTime = (Get-Date).ToUniversalTime().toString('R') $stringToSign = "$GMTTime`n/$storageAccount/$resource" $hmacsha = New-Object System.Security.Cryptography.HMACSHA256 $hmacsha.key = [Convert]::FromBase64String($accesskey) $signature = $hmacsha.ComputeHash([Text.Encoding]::UTF8.GetBytes($stringToSign)) $signature = [Convert]::ToBase64String($signature) $body = $entity | ConvertTo-Json $headers = @{ 'x-ms-date' = $GMTTime Authorization = "SharedKeyLite " + $storageAccount + ":" + $signature "x-ms-version" = $version Accept = "application/json;odata=minimalmetadata" 'If-Match' = "*" 'Content-Length' = $body.length } $item = Invoke-RestMethod -Method MERGE -Uri $table_url -Headers $headers -ContentType application/json -Body $body } Import-module $PSModulePath # Build Credentials $keypath = "D:\home\site\wwwroot\$FunctionName\bin\keys\PassEncryptKey.key" $secpassword = $pw | ConvertTo-SecureString -Key (Get-Content $keypath) $credential = New-Object System.Management.Automation.PSCredential ($username, $secpassword) if ($response -contains "remove") { # Connect to MSOnline Connect-MsolService -Credential $credential # Start Script $Customers = Get-MsolPartnerContract -All | where-object {$_.tenantid -contains $tenantid} foreach ($customer in $customers) { $InitialDomain = Get-MsolDomain -TenantId $customer.TenantId | Where-Object {$_.IsInitial -eq $true} Write-Output "Removing forward from $user" $DelegatedOrgURL = "https://outlook.office365.com/powershell-liveid?DelegatedOrg=" + $InitialDomain.Name $s = New-PSSession -ConnectionUri $DelegatedOrgURL -Credential $credential -Authentication Basic -ConfigurationName Microsoft.Exchange -AllowRedirection Import-PSSession $s -CommandName Set-Mailbox -AllowClobber set-mailbox $user -forwardingsmtpaddress $null Remove-PSSession $s } } if ($response -contains "approve") { $hash = @{ AllowedAddress = $ext } MergeTableEntity -TableName $tablename -PartitionKey $tenantid -RowKey $user -entity $hash } Out-File -Encoding Ascii -FilePath $res -inputObject "Response processed"
Prepare your Azure Table Storage
- Download and install Azure Storage Explorer from www.storageexplorer.com
- Sign into your Azure account to access your storage accounts.
- Select a Storage Account (You can use the same one that is used by your Azure Function App if you like)
- Right click on tables and click Create Table, call it externalforwardingaddress
- Right click on the Storage account to retrieve its Primary Key.
- Switch back to your Azure Function App and update the $StorageAccount and $accessKey variables with the storage account name and Primary Key. Do this for both Azure Function Apps.
Create your Microsoft Flow
- Visit flow.microsoft.com and sign in with your Office 365 account.
- Click My Flows
- Click Create from blank.
- Click Search from hundreds of connectors or triggers
- Search for Request and click Request – When a HTTP Request is received.
- Copy and paste the following schema into the Request Body JSON Schema field:
{ "type": "object", "properties": { "Customer": { "type": "string" }, "TenantId": { "type": "string" }, "PrimarySmtpAddress": { "type": "string" }, "DisplayName": { "type": "string" }, "ExternalRecipient": { "type": "string" } } }
- Click + New Step.
- Add a sample action, eg. a mobile notification. It doesn’t matter what you add here, we just need to add an action so that we can save the flow and retrieve the Request URL.
- Name your flow something descriptive like ‘Request Forwarding Address Approval‘ and save it.
- Save the flow and copy the URL from the HTTP Request Step, then save it somewhere for later.
- Edit the flow again and remove the action you just added.
- Add an Outlook – Send email with Options Step.
- Enter your own email, or the email address for your support desk as the recipient.
- Define the following subject and body, dragging in the relevant output values from the Dynamic content menu.
- Add Approve and Remove as options.
- Add a Condition to your flow. You can rename it to Check response.
- Add SelectedOption from the Dynamic content menu into the first field. Choose is equal to and type Approve
- In the Yes section, add a HTTP – HTTP action. Rename it to Allow forwarding address
- Choose GET as the method and copy and paste the URI of the HTTP Triggered Azure Function that we created earlier.
- At the end of the URI, append &tenantid=, then drag in the tenantId from the initial request output. Append &user= and drag in the PrimarySmtpAddress value. Append &ext= and drag in the ExternalRecipient value.
- Finally, add &response=approve.
- In the No section, add a HTTP-HTTP action. Rename it to Remove forwarding address.
- Paste in the HTTP Triggered Azure function URL. Append &tenantid= and drag in the TenantId value. Append &user= and drag in the PrimarySmtpAddress value.
- Finally append &response=remove
- Your completed flow should look like this.
- Click Update flow.
Update your Azure Function with the Flow details
- Return to your Timer Triggered Azure Function (TT-CheckForwardingAddresses), update the $flowUri variable with the URL that we copied from the Microsoft Flow.
- Click Save and Run
Your Azure Function should now connect to all customer tenants. Any external forwarders on mailboxes will trigger a Microsoft Flow.
The Microsoft Flow will send you an email asking whether this forward is to be approved or removed.
If you choose Approve, the Azure storage table will be updated with the approved external address. If you choose remove, the forwarder will be removed from the user’s mailbox. You can then contact the user and perform the appropriate actions to secure the account.
Leave a Reply
Want to join the discussion?Feel free to contribute!