Skip to content

Email and Collaboration

Use these hunts in Microsoft Sentinel Log Analytics. They use TimeGenerated; Defender XDR tables are assumed to be ingested into the workspace where listed.

Mailbox forwarding, collection, or evidence suppression

Purpose: Find Exchange rules that forward mail, concentrate several mailboxes into one destination, or move/delete messages that might warn a compromised user.

Run this first

Find forwarding and evidence-suppression rules
let Lookback = 7d;
let RuleEvents = materialize(
    OfficeActivity
    | where TimeGenerated > ago(Lookback)
    | where OfficeWorkload =~ "Exchange"
    | where Operation in~ ("Set-Mailbox", "New-InboxRule", "Set-InboxRule", "New-TransportRule", "Set-TransportRule")
    | where Parameters has_any ("ForwardTo", "RedirectTo", "BlindCopyTo", "ForwardingSmtpAddress", "DeleteMessage", "Deleted Items", "Junk Email")
    | mv-apply Parameter = todynamic(Parameters) on (
        summarize ParameterBag = make_bag(bag_pack(tostring(Parameter.Name), tostring(Parameter.Value)))
    )
    | extend Destination = tolower(coalesce(
        tostring(ParameterBag.ForwardTo),
        tostring(ParameterBag.RedirectTo),
        tostring(ParameterBag.BlindCopyTo),
        trim_start(@"smtp:", tostring(ParameterBag.ForwardingSmtpAddress))))
    | extend SuppressionAction = strcat_array(pack_array(
        tostring(ParameterBag.DeleteMessage),
        tostring(ParameterBag.MoveToFolder),
        tostring(ParameterBag.CopyToFolder)), "; ")
    | extend RuleConditions = strcat_array(pack_array(
        tostring(ParameterBag.SubjectContainsWords),
        tostring(ParameterBag.BodyContainsWords),
        tostring(ParameterBag.SubjectOrBodyContainsWords)), "; ")
    | where isnotempty(Destination)
        or Parameters has_any ("DeleteMessage", "Deleted Items", "Junk Email")
    | extend ClientIPAddress = tostring(extract(@"^\[?(?:\:\:ffff\:)?([^\]\:]+(?:\.[^\]\:]+){3}|[^\]]+)\]?", 1, ClientIP))
    | project TimeGenerated, UserId, Operation, ResultStatus, Destination,
        SuppressionAction, RuleConditions, ClientIPAddress, ClientIP,
        OfficeObjectId, OriginatingServer, Parameters
);
let DestinationCounts = RuleEvents
    | where isnotempty(Destination)
    | summarize AffectedMailboxCount = dcount(UserId), AffectedMailboxes = make_set(UserId, 50) by Destination;
RuleEvents
| join kind=leftouter DestinationCounts on Destination
| order by AffectedMailboxCount desc, TimeGenerated desc

Pay attention to:

  • Operation: exact values Set-Mailbox, New-InboxRule, Set-InboxRule, New-TransportRule, or Set-TransportRule.
  • Destination: external or personal-mail domains; the same destination with AffectedMailboxCount > 1.
  • Parameters: exact names ForwardTo, RedirectTo, BlindCopyTo, and ForwardingSmtpAddress.
  • SuppressionAction and Parameters: DeleteMessage, Deleted Items, or Junk Email.
  • RuleConditions: warning terms such as helpdesk, alert, suspicious, malicious, phishing, spam, do not click, or do not open.
  • UserId, ClientIPAddress, OfficeObjectId, and ResultStatus: an unfamiliar creator, unusual source, unexpected mailbox, and a successful change.

Then pivot

Pivot rule owners into sign-ins and mailbox access
let Lookback = 7d;
let Candidates = materialize(
    OfficeActivity
    | where TimeGenerated > ago(Lookback)
    | where OfficeWorkload =~ "Exchange"
    | where Operation in~ ("Set-Mailbox", "New-InboxRule", "Set-InboxRule", "New-TransportRule", "Set-TransportRule")
    | where Parameters has_any ("ForwardTo", "RedirectTo", "BlindCopyTo", "ForwardingSmtpAddress", "DeleteMessage", "Deleted Items", "Junk Email")
    | summarize RuleStart = min(TimeGenerated), RuleEnd = max(TimeGenerated),
        RuleOperations = make_set(Operation, 20) by CandidateUser = tolower(UserId)
);
union
(
    SigninLogs
    | where TimeGenerated > ago(Lookback + 1d)
    | project ActivityTime = TimeGenerated, CandidateUser = tolower(UserPrincipalName),
        Activity = "Sign-in", SourceIP = IPAddress,
        Detail = strcat("ResultType=", ResultType, "; App=", AppDisplayName,
            "; Client=", ClientAppUsed, "; CA=", ConditionalAccessStatus),
        Target = tostring(ResourceDisplayName), CorrelationId
),
(
    OfficeActivity
    | where TimeGenerated > ago(Lookback + 1d)
    | where OfficeWorkload =~ "Exchange" and Operation =~ "MailItemsAccessed"
    | project ActivityTime = TimeGenerated, CandidateUser = tolower(UserId),
        Activity = "MailItemsAccessed", SourceIP = tostring(ClientIP),
        Detail = strcat("ResultStatus=", ResultStatus,
            "; LogonType=", tostring(column_ifexists("LogonType", "")),
            "; Client=", tostring(column_ifexists("ClientInfoString", "")),
            "; ExternalAccess=", tostring(column_ifexists("ExternalAccess", ""))),
        Target = tostring(OfficeObjectId), CorrelationId = tostring(column_ifexists("SessionId", ""))
)
| join kind=inner Candidates on CandidateUser
| where ActivityTime between ((RuleStart - 1h) .. (RuleEnd + 1d))
| project ActivityTime, CandidateUser, Activity, SourceIP, Detail, Target,
    CorrelationId, RuleStart, RuleEnd, RuleOperations
| order by CandidateUser asc, ActivityTime asc

Needs: Sentinel; Microsoft 365 connector with Exchange OfficeActivity; SigninLogs; Exchange MailItemsAccessed requires mailbox auditing and the applicable audit licensing.

Expected noise: Approved delegates, migration tools, journaling/transport workflows, shared-mailbox automation, and users intentionally forwarding mail; compare against sanctioned destinations, admin IPs, and change records.

Source paths:

Threat-bearing message to click, download, and execution

Purpose: Start with delivered inbound phish or malware, identify an allowed or overridden click, and carry the recipient into endpoint download and execution evidence.

Run this first

Find delivered threat-bearing email with an allowed click
let Lookback = 7d;
let Clicks = UrlClickEvents
    | where TimeGenerated > ago(Lookback)
    | where Workload =~ "Email"
    | where ActionType == "ClickAllowed" or tostring(IsClickedThrough) == "1"
    | project ClickTime = TimeGenerated, NetworkMessageId,
        ClickAccount = tolower(AccountUpn), Url, UrlChain, ClickAction = ActionType,
        IsClickedThrough, ClickIP = IPAddress, ClickThreatTypes = ThreatTypes;
EmailEvents
| where TimeGenerated > ago(Lookback)
| where EmailDirection =~ "Inbound"
| where ThreatTypes has_any ("Phish", "Malware")
| where LatestDeliveryLocation in~ ("Inbox/folder", "Junk folder")
| project MessageTime = TimeGenerated, NetworkMessageId, RecipientEmailAddress,
    SenderFromAddress, SenderIPv4, Subject, MessageThreatTypes = ThreatTypes,
    LatestDeliveryAction, LatestDeliveryLocation, EmailAction, EmailActionPolicy
| join kind=inner Clicks on NetworkMessageId
| where ClickAccount == tolower(RecipientEmailAddress)
| project MessageTime, ClickTime, NetworkMessageId, RecipientEmailAddress,
    SenderFromAddress, SenderIPv4, Subject, MessageThreatTypes,
    LatestDeliveryAction, LatestDeliveryLocation, ClickAction, IsClickedThrough,
    ClickIP, Url, UrlChain, ClickThreatTypes
| order by ClickTime desc

Pay attention to:

  • LatestDeliveryLocation: exact values Inbox/folder or Junk folder show that the message reached a user-visible folder.
  • MessageThreatTypes or ClickThreatTypes: exact values containing Phish or Malware.
  • ClickAction == "ClickAllowed" and IsClickedThrough == 1: access was allowed or the warning was bypassed.
  • NetworkMessageId, RecipientEmailAddress, Url, UrlChain, and ClickIP: the stable message-to-user-to-URL relationship.
  • LatestDeliveryAction, EmailAction, and EmailActionPolicy: whether policy allowed, remediated, or later changed the message.
  • BrowserLaunchedToOpenUrl, FileOriginUrl, DownloadSHA1, and DownloadSHA256: endpoint navigation and the resulting payload.
  • ExecutedSHA1 == DownloadSHA1: the downloaded file itself ran, not merely another process on the device.
  • ProcessCommandLine, parent command line, RemoteUrl, RemoteIP, and NetworkTime: execution and follow-on communication from the same process instance.
  • SmartScreenAppWarning followed by SmartScreenUserOverride: the user bypassed an application warning.

Then pivot

Follow the click into download, execution, and outbound activity
let Lookback = 7d;
let ClickSeeds = materialize(
    UrlClickEvents
    | where TimeGenerated > ago(Lookback)
    | where Workload =~ "Email"
    | where ActionType == "ClickAllowed" or tostring(IsClickedThrough) == "1"
    | join kind=inner (
        EmailEvents
        | where TimeGenerated > ago(Lookback)
        | where EmailDirection =~ "Inbound"
        | where ThreatTypes has_any ("Phish", "Malware")
        | where LatestDeliveryLocation in~ ("Inbox/folder", "Junk folder")
        | project NetworkMessageId, Recipient = tolower(RecipientEmailAddress), Subject
    ) on NetworkMessageId
    | where tolower(AccountUpn) == Recipient
    | project NetworkMessageId, Recipient, Subject, ClickTime = TimeGenerated,
        ClickUrl = Url, ClickUrlChain = tostring(UrlChain)
);
let BrowserStarts = materialize(
    DeviceEvents
    | where TimeGenerated > ago(Lookback)
    | where ActionType == "BrowserLaunchedToOpenUrl" and isnotempty(RemoteUrl)
    | project BrowserTime = TimeGenerated, DeviceId, DeviceName,
        Recipient = tolower(InitiatingProcessAccountUpn), BrowserUrl = RemoteUrl,
        BrowserProcess = InitiatingProcessFileName,
        BrowserProcessId = InitiatingProcessId,
        BrowserProcessCreationTime = InitiatingProcessCreationTime
    | join kind=inner ClickSeeds on Recipient
    | where BrowserTime between (ClickTime .. (ClickTime + 15m))
    | where BrowserUrl == ClickUrl or ClickUrlChain contains BrowserUrl
);
let Downloads = materialize(
    DeviceFileEvents
    | where TimeGenerated > ago(Lookback)
    | where isnotempty(FileOriginUrl)
    | where InitiatingProcessFileName in~ ("msedge.exe", "chrome.exe", "firefox.exe", "browser_broker.exe")
    | project DownloadTime = TimeGenerated, DeviceId, DownloadedFile = FileName,
        DownloadPath = FolderPath, DownloadSHA1 = SHA1, DownloadSHA256 = SHA256,
        FileOriginUrl, FileOriginReferrerUrl
    | join kind=inner BrowserStarts on DeviceId
    | where DownloadTime between (BrowserTime .. (BrowserTime + 15m))
);
let Executions = materialize(
    DeviceProcessEvents
    | where TimeGenerated > ago(Lookback)
    | project ExecutionTime = TimeGenerated, DeviceId, ExecutedFile = FileName,
        ExecutedPath = FolderPath, ExecutedSHA1 = SHA1,
        ProcessCommandLine, ProcessId, ProcessCreationTime,
        ParentFile = InitiatingProcessFileName, ParentCommandLine = InitiatingProcessCommandLine
    | join kind=inner Downloads on DeviceId
    | where isnotempty(DownloadSHA1) and ExecutedSHA1 == DownloadSHA1
    | where ExecutionTime between (DownloadTime .. (DownloadTime + 30m))
);
Executions
| join kind=leftouter (
    DeviceNetworkEvents
    | where TimeGenerated > ago(Lookback)
    | project NetworkTime = TimeGenerated, DeviceId,
        NetworkProcessId = InitiatingProcessId,
        NetworkProcessCreationTime = InitiatingProcessCreationTime,
        RemoteUrl, RemoteIP, RemotePort, Protocol
) on DeviceId
| where isempty(NetworkTime)
    or (NetworkProcessId == ProcessId
        and NetworkProcessCreationTime == ProcessCreationTime
        and NetworkTime between (ExecutionTime .. (ExecutionTime + 30m)))
| project NetworkMessageId, Recipient, Subject, ClickTime, ClickUrl,
    BrowserTime, BrowserUrl, DeviceName, DownloadTime, DownloadedFile,
    DownloadPath, DownloadSHA1, DownloadSHA256, ExecutionTime, ExecutedFile,
    ExecutedPath, ProcessCommandLine, ParentFile, ParentCommandLine,
    NetworkTime, RemoteUrl, RemoteIP, RemotePort, Protocol
| order by ExecutionTime desc

Needs: Sentinel; Defender XDR connector with EmailEvents, UrlClickEvents, DeviceEvents, DeviceFileEvents, DeviceProcessEvents, and DeviceNetworkEvents ingested; Defender for Office 365 and Defender for Endpoint coverage.

Expected noise: Security testing, sanctioned software downloads, newsletters, reclassified URLs, and messages remediated after delivery; validate timing, hash equality, signer/prevalence, and the complete process tree.

Source paths:

External Teams first contact followed by payload or remote control

Purpose: Surface a newly seen external Teams sender, then test whether the contact was followed by a URL interaction, a Teams-file upload, or remote-support execution for a recipient.

Run this first

Find first-contact external Teams senders and their URLs
let RecentWindow = 7d;
let BaselineWindow = 30d;
let KnownSenders = MessageEvents
    | where TimeGenerated between (ago(BaselineWindow) .. ago(RecentWindow))
    | where IsOwnedThread == 0 and IsExternalThread == 1
    | where isnotempty(SenderEmailAddress)
    | summarize by SenderKey = tolower(SenderEmailAddress);
let FirstContactMessages = materialize(
    MessageEvents
    | where TimeGenerated > ago(RecentWindow)
    | where IsOwnedThread == 0 and IsExternalThread == 1
    | where isnotempty(SenderEmailAddress)
    | summarize arg_max(TimeGenerated, *) by TeamsMessageId
    | extend SenderKey = tolower(SenderEmailAddress)
    | join kind=leftanti KnownSenders on SenderKey
);
FirstContactMessages
| join kind=leftouter (
    MessageUrlInfo
    | where TimeGenerated > ago(RecentWindow)
    | project TeamsMessageId, Url, UrlDomain,
        UrlThreatTypes = ThreatTypes, UrlDetectionMethods = DetectionMethods
) on TeamsMessageId
| project MessageTime = TimeGenerated, TeamsMessageId, SenderEmailAddress,
    SenderDisplayName, RecipientDetails, ThreadId, ThreadName, ThreadType,
    IsExternalThread, IsOwnedThread, MessageType, MessageSubtype, Subject,
    MessageThreatTypes = ThreatTypes, DeliveryAction, DeliveryLocation,
    Url, UrlDomain, UrlThreatTypes, UrlDetectionMethods
| order by MessageTime desc

Pay attention to:

  • IsExternalThread == 1 and IsOwnedThread == 0: inbound communication owned outside the tenant.
  • Absence from the preceding 23-day baseline: the exact SenderEmailAddress is first seen in the recent seven-day window.
  • ThreadType, MessageType, and MessageSubtype: unexpected one-to-one chat, call, meeting, bot, or add-in context.
  • Url, UrlDomain, MessageThreatTypes, UrlThreatTypes, and UrlDetectionMethods: a payload or a URL already classified as Phish, Malware, or Spam.
  • RecipientDetails, TeamsMessageId, ThreadId, and SenderDisplayName: who was targeted and whether the display name impersonates IT, help desk, security, or account support.
  • ClickAllowed or IsClickedThrough == 1: a recipient reached or bypassed protection for the sender's URL.
  • FileUploaded under Microsoft Teams Chat Files: a payload uploaded by the newly seen sender.
  • QuickAssist.exe, AnyDesk.exe, or TeamViewer.exe: initial remote-support execution values; extend from the approved-support inventory, not campaign indicators.
  • RecipientObjectId == InitiatingProcessAccountObjectId, DeviceName, ProcessCommandLine, SHA1, and parent command line: user, device, and execution continuity inside the 30-minute sequence.

Then pivot

Follow first contact into clicks, file uploads, or RMM execution
let RecentWindow = 7d;
let BaselineWindow = 30d;
let KnownSenders = MessageEvents
    | where TimeGenerated between (ago(BaselineWindow) .. ago(RecentWindow))
    | where IsOwnedThread == 0 and IsExternalThread == 1
    | where isnotempty(SenderEmailAddress)
    | summarize by SenderKey = tolower(SenderEmailAddress);
let Seeds = materialize(
    MessageEvents
    | where TimeGenerated > ago(RecentWindow)
    | where IsOwnedThread == 0 and IsExternalThread == 1
    | where isnotempty(SenderEmailAddress)
    | summarize arg_max(TimeGenerated, *) by TeamsMessageId
    | extend SenderKey = tolower(SenderEmailAddress)
    | join kind=leftanti KnownSenders on SenderKey
    | mv-expand Recipient = todynamic(RecipientDetails)
    | extend RecipientObjectId = tostring(Recipient.RecipientObjectId),
        RecipientAddress = tolower(tostring(Recipient.RecipientSmtpAddress)),
        RecipientName = tostring(Recipient.RecipientDisplayName)
    | project MessageTime = TimeGenerated, TeamsMessageId,
        SenderAddress = SenderEmailAddress, SenderKey,
        SenderDisplayName, RecipientObjectId, RecipientAddress, RecipientName,
        ThreadId, MessageThreatTypes = ThreatTypes
);
let UrlFollowOn =
    Seeds
    | join kind=inner (
        MessageUrlInfo
        | where TimeGenerated > ago(RecentWindow)
        | project TeamsMessageId, Url, UrlThreatTypes = ThreatTypes
    ) on TeamsMessageId
    | join kind=inner (
        UrlClickEvents
        | where TimeGenerated > ago(RecentWindow)
        | where Workload =~ "Teams"
        | where ActionType == "ClickAllowed" or tostring(IsClickedThrough) == "1"
        | project ClickTime = TimeGenerated, Url, ClickAccount = tolower(AccountUpn),
            ClickAction = ActionType, IsClickedThrough, ClickIP = IPAddress
    ) on Url
    | where ClickTime between (MessageTime .. (MessageTime + 30m))
    | where isempty(RecipientAddress) or ClickAccount == RecipientAddress
    | project EventTime = ClickTime, FollowOn = "Teams URL allowed or clicked through",
        SenderAddress, SenderDisplayName, Recipient = coalesce(RecipientAddress, RecipientName),
        TeamsMessageId, ThreadId, Detail = strcat("Url=", Url, "; ThreatTypes=", UrlThreatTypes,
            "; ActionType=", ClickAction, "; IsClickedThrough=", IsClickedThrough,
            "; IP=", ClickIP), DeviceName = "", FileName = "";
let UploadFollowOn =
    Seeds
    | join kind=inner (
        OfficeActivity
        | where TimeGenerated > ago(RecentWindow)
        | where RecordType =~ "SharePointFileOperation"
        | where Operation =~ "FileUploaded"
        | where SourceRelativeUrl has "Microsoft Teams Chat Files"
        | project UploadTime = TimeGenerated, UploaderKey = tolower(UserId),
            UploadedFile = OfficeObjectId, SourceRelativeUrl, Site_Url,
            UploadClientIP = ClientIP
    ) on $left.SenderKey == $right.UploaderKey
    | where UploadTime between (MessageTime .. (MessageTime + 30m))
    | project EventTime = UploadTime, FollowOn = "External sender uploaded Teams chat file",
        SenderAddress, SenderDisplayName, Recipient = coalesce(RecipientAddress, RecipientName),
        TeamsMessageId, ThreadId,
        Detail = strcat("Object=", UploadedFile, "; RelativeUrl=", SourceRelativeUrl,
            "; Site=", Site_Url, "; IP=", UploadClientIP),
        DeviceName = "", FileName = tostring(UploadedFile);
let RmmFollowOn =
    Seeds
    | where isnotempty(RecipientObjectId)
    | join kind=inner (
        DeviceProcessEvents
        | where TimeGenerated > ago(RecentWindow)
        | where FileName in~ ("QuickAssist.exe", "AnyDesk.exe", "TeamViewer.exe")
        | project RmmTime = TimeGenerated, DeviceName, FileName,
            RecipientObjectId = tostring(InitiatingProcessAccountObjectId),
            ProcessCommandLine, SHA1, InitiatingProcessFileName,
            InitiatingProcessCommandLine
    ) on RecipientObjectId
    | where RmmTime between (MessageTime .. (MessageTime + 30m))
    | project EventTime = RmmTime, FollowOn = "RMM tool executed after Teams contact",
        SenderAddress, SenderDisplayName, Recipient = coalesce(RecipientAddress, RecipientName),
        TeamsMessageId, ThreadId,
        Detail = strcat("CommandLine=", ProcessCommandLine, "; SHA1=", SHA1,
            "; Parent=", InitiatingProcessFileName,
            "; ParentCommandLine=", InitiatingProcessCommandLine),
        DeviceName, FileName;
union UrlFollowOn, UploadFollowOn, RmmFollowOn
| order by EventTime desc

Needs: Sentinel; Defender XDR connector with MessageEvents, MessageUrlInfo, UrlClickEvents, and DeviceProcessEvents ingested; Microsoft 365 connector with Teams/SharePoint OfficeActivity for the upload branch.

Expected noise: New vendors, recruiters, federated partners, approved help-desk sessions, and legitimate external file collaboration; maintain partner-domain, support-tool, support-account, and managed-device allowlists.

Source paths: