Monday, April 15, 2019

Office 365 Spam Remover

Problem: A spam campaign has hit your tenant and affected more mailboxes than can be processed by the Search n Purge option in Exchange Online.

Resolution: Adjust this script to replace CONTOSO with your domain. This will prompt you for your Exchange Admin credentials, offer you the chance to add another exchange admin account to run this under, prompt for the evil sender(s), date and time the spam campaign hit, and optionally the subject line(s) of the evil email messages so you don't accidentally remove too many messages. The script uses a message trace of all email sent to your tenant by the evil senders during the time frame specified and then searches those mailboxes to find the message(s) and removes them but uses multiple PowerShell windows to perform this function so that you can watch it in real-time and see quicker progress.



#REQUIRES -Version 5
<#
.SYNOPSIS
Performs a message trace for a spam message and searches for and purges it from recipient mailboxes

.DESCRIPTION
Connects to O365 - assuming you have Exchange Admin permissions - and performs a Get-MessageTrace. From there, it will take all of the recipients, split them into 4 groups, open 3 new powershell windows, and perform Search-Mailbox -DeleteContent commands against each recipient. Optionally prompts for a second account to give you another 4 windows

.PARAMETER StartDate
REQUIRED The beginning of the search for the spam message - typically the day BEFORE users receive the message, must be within the last 7 days
.PARAMETER EndDate
REQUIRED The end of the search for the spam message - typically the day AFTER users receive the message
.PARAMETER Recipients
OPTIONAL Will be determined by message trace but can be supplied separately - expecting a comma-separated string of email addresses, max of 1000 for performance's sake
.PARAMETER CredU
OPTIONAL Username of the Exchange Admin credentials
.PARAMETER CredP
OPTIONAL Encrypted password secure string of the Exchange Admin credentials (password encrypted via convertfrom-securestring command)
.PARAMETER SearchQuery
OPTIONAL Uses the date when users are expected to have received the spam message and the evil senders to form a query for Search-Mailbox command

.NOTES
  Created by: Brendan Horner (www.hornerit.com)
  Notes: MUST BE RUN AS SCRIPT FILE, do NOT copy-paste into PS to run
  Version History:
  --2019-04-15-Initial public version

.EXAMPLE
.\O365-SPAM-REMOVER.ps1
.\O365-SPAM-REMOVER.ps1 -Recipients "someone@CONTOSO.COM,someoneelse@CONTOSO.COM" -SearchQuery "FROM:bob@something.com AND Received:04/19/2018"
#>
param(
[string]
$StartDate,
[string]
$EndDate,
[string]
$Recipients,
[string]
$CredU,
[string]
$CredP,
[string]
$SearchQuery
)

#If supplied, create the Credential object used to log into O365 session. If a second login is supplied, use it to speed up the process but still keep records of the deleted emails in the primary credential supplied
try { $Cred = New-Object PSCredential($CredU,(ConvertTo-SecureString $CredP)) } catch {
    $Cred = Get-Credential
    $Cred2 = Read-Host "If you wish to use a second account to speed up the process, please type the email address and press enter; otherwise, just press Enter"
    if($Cred2.Length -gt 0){
        $Cred2 = Get-Credential -UserName $Cred2 -Message "Please enter the password for $Cred2"
    }
}

#Create powershell remote session
Write-Host Connecting to O365...
try {
    if($Cred2.UserName.Length -gt 0){
        $Session2 = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri https://outlook.office365.com/powershell-liveid/ -Credential $Cred2 -Authentication Basic -AllowRedirection -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
        Remove-PSSession $Session2
    }
}
catch {
    Read-Host The second account you supplied is invalid or had an error. Stopping script, press any key to continue...
    Remove-PSSession $Session
    Exit
}
try {
    $Session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri https://outlook.office365.com/powershell-liveid/ -Credential $Cred -Authentication Basic -AllowRedirection -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
}
catch {
    Read-Host The primary account you supplied is invalid or had an error. Stopping script, press any key to continue...
    Exit
}
Write-host Done

#Change window appearance if this is a child window so that it is small
if($Recipients.Length -gt 0){
    $title = "SPAM REMOVER - Processing "+$Recipients.Substring(0,$Recipients.IndexOf("@"))+" thru "+$Recipients.Substring($Recipients.LastIndexOf(",")+1,$Recipients.LastIndexOf("@")-$Recipients.LastIndexOf(",")-1)
    $Host.ui.RawUI.WindowTitle = $title
    $newSize = $Host.UI.RawUI.WindowSize
    $newSize.Height = 30
    $newSize.Width = 75
    $Host.UI.RawUI.WindowSize = $newSize
    $newBuffer = $Host.UI.RawUI.BufferSize
    $newBuffer.Height = 3000
    $newBuffer.Width = 75
    $Host.UI.RawUI.BufferSize = $newBuffer
}

#If SearchQuery has not been supplied, get the evil sender(s) then the received date and, finally, the subject line(s) of the evil emails
if($SearchQuery.Length -eq 0){
    $Senders = @{}
    do {
        $BadSender = read-host "[Required]Email address of evil sender. This prompt will repeat until you press enter with no information. Do not enter quotes or empty, extra spaces"
        do {
            #Validate that each email address at least matches the typical email pattern
            $SendersGood = 1
            if($BadSender.length -gt 0){
                if(!($BadSender -match ".*@.*\..*")){
                    $SendersGood = 0
                    $BadSender = read-host "Bad email address input, try again"
                } else {
                    $Senders.add($BadSender,$null)
                }
            }
        } until ($SendersGood -eq 1)
    } until ($BadSender.length -eq 0)
    $Senders = $Senders.keys -join ","

    #Get the start date and time of the range when that users should have received the spam
    $StartDate = read-host "[Required]Start Date AND TIME for range when users received message (e.g. 7/18/2018 12:20 AM)"
    do {
        $good = 0
        try {
            $StartDate = Get-Date($StartDate)
            $good = 1
        }
        catch {
            $StartDate = read-host "Start date invalid, try again"
        }
    } until ($good -eq 1)
    $SearchStartDate = (get-date $StartDate).ToUniversalTime()

    #Get the end date and time of the range that users should have received the spam
    $EndDate = read-host "[Required]End Date AND TIME for range when users received message (e.g. 7/18/2018 3:59 PM)"
    do {
        $good = 0
        try {
            $EndDate = Get-Date($EndDate)
            if($EndDate -gt $StartDate){ $good = 1 } else { throw }
        }
        catch {
            $EndDate = read-host "End date invalid or before start date, try again"
        }
    } until ($good -eq 1)
    $SearchEndDate = (get-date $EndDate).ToUniversalTime()

    #Get the subject line(s) of the evil messages to filter
    $SubjectLineFilter = @{}
    do {
        $SubjectLine = read-host "[Optional]Subject line to filter. This prompt will repeat until you press enter with no information. Do not enter any quotes or backticks unless actually in the subject"
        if($SubjectLine.length -gt 0){
            $SubjectLineFilter.add($SubjectLine,$null)
        }
    } until ($SubjectLine.length -eq 0)
    $SubjectLineStr = '(Subject:'
    if($SubjectLineFilter.count -gt 0){
        foreach($subject in $SubjectLineFilter.keys){
            $SubjectLineStr += '"'+$subject.Replace('"','""')+'" OR Subject:'
        }
    }
    $SubjectLineStr = $SubjectLineStr.TrimEnd(" OR Subject:")
    $SubjectLineStr += ")"

    #Build Search Query from the SearchStartDate, SearchEndDate, subject line(s), and evil sender(s)

    $SearchQuery = "(Received:`""+$SearchStartDate.toString()+".."+$SearchEndDate.toString()+"`") AND "
    $SenderList = New-Object System.Collections.ArrayList
    #If more than one sender, create filter string with a bunch of ORs for senders; otherwise, just set one
    if($Senders.IndexOf(",") -gt 0){
        $SearchQuery += "("
        foreach($EvilSender in $Senders.Split(",")){
            $addy = $EvilSender.Trim()
            $SearchQuery += "From:$addy OR "
            $SenderList.add($addy) | Out-Null
        }
        $SearchQuery = $SearchQuery.TrimEnd(" OR ")
        $SearchQuery += ")"
    } else {
        $SearchQuery += "From:$Senders"
        $SenderList.add($Senders) | Out-Null
    }
    #If a subject line is specified, add it to the search query
    if ($SubjectLineStr -ne "()") {
        $SearchQuery += " AND "+$SubjectLineStr
    }
}

#If a string of spam recipients has not been supplied, we perform a message trace to get them; otherwise, they were supplied - probably by this script
if($Recipients.length -eq 0){
    $MyRecipients = @{}
    $Page = 0
    $SearchStartDateStr = $SearchStartDate.ToString()
    $SearchEndDateStr = $SearchEndDate.ToString()
    write-host Getting recipients of evil message...
    #Since Message Traces cut off after 5k results, we use PageSize to limit it to 5k users and try another page of results till we run out
    do {
        $Page++
        write-host "  Getting Page $Page of results, can take up to 5 minutes..."
        $a = (Invoke-Command -Session $Session -ScriptBlock { Get-MessageTrace -SenderAddress $Using:SenderList -StartDate $Using:SearchStartDateStr -EndDate $Using:SearchEndDateStr -Pagesize 5000 -Page $Using:Page -Status "Pending","Delivered" -ErrorAction Stop | select-object recipientaddress} -HideComputerName).recipientaddress
        write-host "  Done."
        if($null -ne $a){
            #For every person found in the trace, we look to make sure it is not already in the list and that it is an LU address to which we can actually do something
            foreach($Recipient in $a){
                if(!($MyRecipients.ContainsKey($Recipient)) -and $Recipient.IndexOf("@CONTOSO.COM") -gt 0){
                    $MyRecipients.Add($Recipient,$null)
                }
            }
        }
        #Just because these searches can be resource-intensive and occasionally freak out, wait a second before trying again
        start-sleep 1
    } until ($null -eq $a)
    write-host Done
    #When done, we want to use Remove-PSSession to make sure that we properly close our powershell o365 sessions
    Remove-PSSession $Session

    #This begins the part where we prepare to open 4 other PowerShell windows to help with the workload
    #Get the Username and encrypted password of the Exchange Admin who is running this powershell script
    $u = $Cred.UserName
    $p = ConvertFrom-SecureString $Cred.Password
    if($Cred2.UserName.Length -gt 0){
        $u2 = $Cred2.UserName
        $p2 = ConvertFrom-SecureString $Cred2.Password
    }

    #Test that you are running this as a script (needed to spawn child windows)
    Write-Host Testing that you ran this as a script and did not copy paste it...
    try{
        $ScriptPath = $MyInvocation.MyCommand.Definition
        Resolve-Path $ScriptPath | out-null
    } catch {
        write-host STOPPING - You are not running this as a script. Press any key to close...
        Exit;
    }
    Write-host Done

    #Just for clarity to the person running the script
    write-host Spawning child powershell windows with these parameters:
    write-host "  Path to script file - $ScriptPath"
    write-host "  Received Date - $StartDate to $EndDate"
    write-host "  Search Query - $SearchQuery"
    write-host "  Total Mailboxes being processed - "$MyRecipients.Count
    $timer = [System.Diagnostics.Stopwatch]::StartNew()

    #When sending stuff with double quotes to the child powershell windows, double quotes get lost due to how powershell works. This adds a backslash to escape them so it works correctly.
    $SearchQuery = $SearchQuery.replace('"','\"')

    #Here is where we sort the list of recipients for later chunking into smaller groups, $MaxChunk MUST BE EVENLY DIVISIBLE BY 4 and the string length combined per chunk should be less than 8k characters for compatibility w/ 2008R2
    $keys = $MyRecipients.keys | Sort-Object | foreach-object { $_.toString() }
    $maxChunk = 1000

    #If our maximum chunking size is actually too big - because we have such a small set of users - re-calculate the maxchunk size to essentially be the size of our results
    if ($keys.count -lt $maxChunk -and $u2.Length -lt 1) {
        $maxChunk = $keys.count
    } elseif ($keys.count -lt $maxChunk -and $u2.Length -gt 0) {
        $maxChunk = $keys.count / 2
    }

    #Here is where we determine how many chunks each child powershell window should process x 4 windows per account used (max of 8 windows possible currently)
    if($u2.Length -gt 0){
        #This is for when we have a second account to assist in processing - double the max chunk we can process and set the chunk size for each window to the max chunk divided by 8
        $maxChunk = $maxChunk*2
        $ChunkSize = [math]::round($maxChunk/8)
        Write-Host "  Mailboxes per child window - $ChunkSize"
    } else {
        #This is for when we only have a single account processing
        $ChunkSize = [math]::round($maxChunk/4)
        Write-Host "  Mailboxes per child window - $ChunkSize"
    }

    #Begin the process of chunking the data, communicating that to the screen, and spawning child windows to handle each chunk
    Write-Host "Child Window Data:"
    for($i=0;$i -lt $keys.count;$i+=$maxChunk){
        $min1 = $i
        $min2 = $min1+$ChunkSize
        $min3 = $min2+$ChunkSize
        $min4 = $min3+$ChunkSize
        $max1 = $min1+$ChunkSize-1
        $max2 = $min2+$ChunkSize-1
        $max3 = $min3+$ChunkSize-1
        $max4 = $min4+$ChunkSize-1

        write-host "  Window1 will be"$keys[$min1]"to"$keys[$max1]
        $Recipients1 = $keys[($min1)..($max1)] -join ","
        write-host "  Window2 will be"$keys[$min2]"to"$keys[$max2]
        $Recipients2 = $keys[($min2)..($max2)] -join ","
        write-host "  Window3 will be"$keys[$min3]"to"$keys[$max3]
        $Recipients3 = $keys[($min3)..($max3)] -join ","
        write-host "  Window4 will be"$keys[$min4]"to"$keys[$max4]
        $Recipients4 = $keys[($min4)..($max4)] -join ","

        #If we are using a second account to process and there are still boxes remaining, calculate more recipients
        if($u2.Length -gt 0){
            $min5 = $min4+$ChunkSize
            $min6 = $min5+$ChunkSize
            $min7 = $min6+$ChunkSize
            $min8 = $min7+$ChunkSize
            $max5 = $min5+$ChunkSize-1
            $max6 = $min6+$ChunkSize-1
            $max7 = $min7+$ChunkSize-1
            $max8 = $min8+$ChunkSize-1

            write-host "  Window5 will be"$keys[$min5]"to"$keys[$max5]
            $Recipients5 = $keys[($min5)..($max5)] -join ","
            write-host "  Window6 will be"$keys[$min6]"to"$keys[$max6]
            $Recipients6 = $keys[($min6)..($max6)] -join ","
            write-host "  Window7 will be"$keys[$min7]"to"$keys[$max7]
            $Recipients7 = $keys[($min7)..($max7)] -join ","
            write-host "  Window8 will be"$keys[$min8]"to"$keys[$max8]
            $Recipients8 = $keys[($min8)..($max8)] -join ","
        }

        #This is the freaking magic that opens another powershell window and supplies all the values that are set as parameters up at the top of this script
        #!!!NOTICE THE BACKTICK CHARACTERS FOR FILE AT BEGINNING AND SEARCH QUERY AT THE END! IF YOU TAKE THEM AWAY, THE SEARCH QUERY FOR THESE SPAWNED PROCESSES IS INCOMPLETE AND DELETES LOTS MORE EMAILS!!!
        $ps1 = Start-Process powershell -Passthru -ArgumentList "-file `"$ScriptPath`" -Recipients $Recipients1 -CredU $u -CredP $p -SearchQuery `"$SearchQuery`""
        $ps2 = Start-Process powershell -Passthru -ArgumentList "-file `"$ScriptPath`" -Recipients $Recipients2 -CredU $u -CredP $p -SearchQuery `"$SearchQuery`""
        $ps3 = Start-Process powershell -Passthru -ArgumentList "-file `"$ScriptPath`" -Recipients $Recipients3 -CredU $u -CredP $p -SearchQuery `"$SearchQuery`""
        $ps4 = Start-Process powershell -Passthru -ArgumentList "-file `"$ScriptPath`" -Recipients $Recipients4 -CredU $u -CredP $p -SearchQuery `"$SearchQuery`""

        #If we are using a second account for processing, spawn 4 child windows for those as well.
        if($u2.Length -gt 0){
            $ps5 = Start-Process powershell -Passthru -ArgumentList "-file `"$ScriptPath`" -Recipients $Recipients5 -CredU $u2 -CredP $p2 -SearchQuery `"$SearchQuery`""
            $ps6 = Start-Process powershell -Passthru -ArgumentList "-file `"$ScriptPath`" -Recipients $Recipients6 -CredU $u2 -CredP $p2 -SearchQuery `"$SearchQuery`""
            $ps7 = Start-Process powershell -Passthru -ArgumentList "-file `"$ScriptPath`" -Recipients $Recipients7 -CredU $u2 -CredP $p2 -SearchQuery `"$SearchQuery`""
            $ps8 = Start-Process powershell -Passthru -ArgumentList "-file `"$ScriptPath`" -Recipients $Recipients8 -CredU $u2 -CredP $p2 -SearchQuery `"$SearchQuery`""
        }

        #Wait for all the child windows to be complete to loop through the next group
        write-host "Waiting for all child windows to close to proceed to the next set or to complete the script..."
        $ps1.WaitForExit()
        $ps2.WaitForExit()
        $ps3.WaitForExit()
        $ps4.WaitForExit()

        #If we are using a second account for processing, wait for those to complete as well before looping through the next group.
        if($u2.Length -gt 0){
            $ps5.WaitForExit()
            $ps6.WaitForExit()
            $ps7.WaitForExit()
            $ps8.WaitForExit()
        }
    }
    $timer.Stop()
    read-host "Done, the runtime for this entire process was"($timer.Elapsed.TotalMinutes)"minutes. Press any key to complete script"
    exit
} else {
    #Go ahead and process the recipients supplied to the script already
    write-host "Processing recipients using this query: $SearchQuery"
    #FOR TESTING, uncomment this next line:
    #Read-Host "Press any key to actually run this; press CTRL + C to cancel it"
    $total = ([regex]::Matches($Recipients,"@")).count
    $counter = 0
    foreach ($Recipient in ($Recipients.split(",") | Sort-Object)){
        $counter++
        try{
            $SearchResults = Invoke-Command -Session $Session -Scriptblock { Search-Mailbox -Identity $Using:Recipient -SearchQuery $Using:SearchQuery -deletecontent -Force -ErrorAction Continue -WarningAction SilentlyContinue } -HideComputerName
            Write-Host $SearchResults.ResultItemsCount item removed from $Recipient - $counter of $total boxes
        } catch {
            if($Session.State -ne "Opened" -or $_.exception.message -like "*Starting a command on the remote server failed with the following error message : The I/O operation has been aborted because of either a thread exit or an application request*"){
                Get-PSSession | Remove-PSSession
                $Session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri https://outlook.office365.com/powershell-liveid/ -Credential $Cred -Authentication Basic -AllowRedirection -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
                $SearchResults = Invoke-Command -Session $Session -Scriptblock { Search-Mailbox -Identity $Using:Recipient -SearchQuery $Using:SearchQuery -deletecontent -Force -ErrorAction Continue -WarningAction SilentlyContinue } -HideComputerName
                Write-Host $SearchResults.ResultItemsCount item removed from $Recipient - $counter of $total boxes
            }
        }
    }

    #When done, we want to use Remove-PSSession to make sure that we properly close our powershell o365 sessions
    Get-PSSession | Remove-PSSession
}

No comments:

Post a Comment