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¶
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 valuesSet-Mailbox,New-InboxRule,Set-InboxRule,New-TransportRule, orSet-TransportRule.Destination: external or personal-mail domains; the same destination withAffectedMailboxCount > 1.Parameters: exact namesForwardTo,RedirectTo,BlindCopyTo, andForwardingSmtpAddress.SuppressionActionandParameters:DeleteMessage,Deleted Items, orJunk Email.RuleConditions: warning terms such ashelpdesk,alert,suspicious,malicious,phishing,spam,do not click, ordo not open.UserId,ClientIPAddress,OfficeObjectId, andResultStatus: an unfamiliar creator, unusual source, unexpected mailbox, and a successful change.
Then pivot¶
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:
- OfficeMailForwarding_hunting.yaml —
Solutions/Microsoft 365/Hunting Queries/OfficeMailForwarding_hunting.yaml - MultipleUsersEmailForwardedToSameDestination.yaml —
Solutions/Microsoft 365/Hunting Queries/MultipleUsersEmailForwardedToSameDestination.yaml - Malicious_Inbox_Rule.yaml —
Solutions/Microsoft 365/Analytic Rules/Malicious_Inbox_Rule.yaml - AnomolousUserAccessingOtherUsersMailbox.yaml —
Solutions/Microsoft 365/Hunting Queries/AnomolousUserAccessingOtherUsersMailbox.yaml
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¶
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 valuesInbox/folderorJunk foldershow that the message reached a user-visible folder.MessageThreatTypesorClickThreatTypes: exact values containingPhishorMalware.ClickAction == "ClickAllowed"andIsClickedThrough == 1: access was allowed or the warning was bypassed.NetworkMessageId,RecipientEmailAddress,Url,UrlChain, andClickIP: the stable message-to-user-to-URL relationship.LatestDeliveryAction,EmailAction, andEmailActionPolicy: whether policy allowed, remediated, or later changed the message.BrowserLaunchedToOpenUrl,FileOriginUrl,DownloadSHA1, andDownloadSHA256: 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, andNetworkTime: execution and follow-on communication from the same process instance.SmartScreenAppWarningfollowed bySmartScreenUserOverride: the user bypassed an application warning.
Then pivot¶
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:
- User clicks on malicious inbound emails.yaml —
Solutions/Microsoft Defender XDR/Hunting Queries/Email and Collaboration Queries/URL Click/User clicks on malicious inbound emails.yaml - Email link + download + SmartScreen warning.yaml —
Hunting Queries/Microsoft 365 Defender/Delivery/Email link + download + SmartScreen warning.yaml - SmartScreen app block ignored by user.yaml —
Hunting Queries/Microsoft 365 Defender/Protection events/SmartScreen app block ignored by user.yaml - Open email link.yaml —
Hunting Queries/Microsoft 365 Defender/Delivery/Open email link.yaml
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¶
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 == 1andIsOwnedThread == 0: inbound communication owned outside the tenant.- Absence from the preceding 23-day baseline: the exact
SenderEmailAddressis first seen in the recent seven-day window. ThreadType,MessageType, andMessageSubtype: unexpected one-to-one chat, call, meeting, bot, or add-in context.Url,UrlDomain,MessageThreatTypes,UrlThreatTypes, andUrlDetectionMethods: a payload or a URL already classified asPhish,Malware, orSpam.RecipientDetails,TeamsMessageId,ThreadId, andSenderDisplayName: who was targeted and whether the display name impersonates IT, help desk, security, or account support.ClickAllowedorIsClickedThrough == 1: a recipient reached or bypassed protection for the sender's URL.FileUploadedunderMicrosoft Teams Chat Files: a payload uploaded by the newly seen sender.QuickAssist.exe,AnyDesk.exe, orTeamViewer.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¶
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:
- First-Contact External Teams Senders.yaml —
Solutions/Microsoft Defender XDR/Hunting Queries/Email and Collaboration Queries/Microsoft Teams protection/First-Contact External Teams Senders.yaml - Hunt for RMM tool execution following Teams messages.yaml —
Solutions/Microsoft Defender XDR/Hunting Queries/Email and Collaboration Queries/Microsoft Teams protection/Hunt for RMM tool execution following Teams messages.yaml - External User added to Team and immediately uploads file.yaml —
Solutions/Microsoft 365/Analytic Rules/External User added to Team and immediately uploads file.yaml - ExternalUserFromNewOrgAddedToTeams.yaml —
Solutions/Microsoft 365/Hunting Queries/ExternalUserFromNewOrgAddedToTeams.yaml